Stripe emits a webhook whenever something happens in your account that your code did not directly cause: a card finally clears after three retries, a subscription renews at 3am, a dispute lands, a checkout completes on a device you never see. These are the events your billing logic depends on, and they arrive asynchronously over HTTP. Getting them wrong means a customer keeps access after their card fails, or gets charged twice for one order. This guide covers receiving Stripe events correctly — verification, acknowledgement, retries, and the failure modes that bite in production.
What Stripe webhooks are
A Stripe webhook is an HTTP POST Stripe sends to a URL you register, carrying an Event object. Every event has a stable id (prefixed evt_), a type such as invoice.payment_failed, and a data.object holding the resource the event is
about — an invoice, a subscription, a checkout session. Because payment state is
authoritative on Stripe’s side and only eventually consistent on yours, webhooks are
how your application learns the truth about money after the fact.
Common use cases
- Provisioning after payment — grant access on
checkout.session.completedorinvoice.paidrather than trusting a client-side redirect that a user can close. - Dunning and failed payments — react to
invoice.payment_failedto email the customer, pause a subscription, or start a retry sequence. - Subscription lifecycle — keep entitlements in sync from
customer.subscription.updated,customer.subscription.deleted, and trial-ending events. - Reconciliation — record
charge.refunded, disputes, and payouts against your own ledger.
Configuring the webhook in Stripe
You register endpoints in the Stripe Dashboard under Developers → Webhooks, or with
the API. At a high level you supply the destination URL, select the event types you
care about (subscribe narrowly — do not send every event to every endpoint), and
Stripe issues that endpoint a signing secret (prefixed whsec_) used to verify
deliveries. Test mode and live mode have separate endpoints and separate secrets.
Exact dashboard labels and options change over time, so treat the flow above as stable and confirm current specifics against Stripe’s webhook documentation. Store the signing secret as a secret, not in source, and remember that a test-mode secret will never verify a live event.
Recommended endpoint setup
Stripe waits only a short time for a response and treats a slow endpoint as a failure, so the handler’s job is to acknowledge quickly and do the real work elsewhere:
- Read the raw request body before any JSON parsing (you need the exact bytes for signature verification).
- Verify the signature.
- Persist or enqueue the raw event, keyed by its
evt_id. - Return
2xximmediately. - Process asynchronously — provisioning, emails, ledger writes — off the request path.
Filter by event type at the edge too. If an endpoint only cares about invoice events, ignore the rest early. HookWatch endpoints support an event-type allowlist so unrelated types never reach your handler in the first place.
Verifying authenticity
Stripe signs every delivery with the Stripe-Signature header. It contains a
timestamp and one or more signatures, formatted roughly like this:
Stripe-Signature: t=1753228800,v1=6ffbb59b2300aabbccddeeff00112233445566778899aabbccddeeff0011223344 The v1 value is an HMAC-SHA256, computed with your endpoint’s signing secret, over
the string timestamp + "." + raw_request_body. To verify: recompute the HMAC over
the concatenation of the t value, a literal ., and the raw body bytes, then
compare in constant time against v1. Also reject deliveries whose timestamp is
outside a tolerance window (Stripe’s libraries default to five minutes) to blunt
replay attacks.
The single most common mistake is verifying a parsed-and-re-serialised body instead of the raw bytes — any reordering or whitespace change breaks the HMAC. Keep the raw body. The mechanics, with constant-time comparison and worked code, are covered in how to verify webhook signatures.
A note on gateways: HookWatch’s configurable inbound verification covers plain
HMAC-over-body schemes (header name, algorithm, encoding, optional prefix). Stripe’s
scheme signs a timestamped payload and packs the result into a composite Stripe-Signature header, so verify it in your own handler with Stripe’s library or
the raw-body HMAC pattern above rather than expecting a generic gateway check to
replace it.
Common failure scenarios
- Signature failures from a parsed body — a middleware that JSON-parses before your verifier runs, or a proxy that rewrites the body, produces a mismatch even though the secret is correct.
- Wrong-mode secret — verifying live events with a test-mode
whsec_, common right after go-live. - Missed events after an endpoint is disabled — Stripe can disable an endpoint that fails persistently; deliveries stop and gaps appear in your data. Watch for this and re-enable deliberately.
- Timeouts under load — synchronous provisioning or a slow downstream call pushes the response past Stripe’s window, so a delivered event is recorded as failed and retried.
- Duplicate side effects — a retry after a timeout that actually succeeded runs your handler twice.
Testing workflow
The Stripe CLI is the fastest local loop. stripe listen --forward-to forwards live
account events (or CLI-triggered ones) to a local URL, and stripe trigger invoice.payment_failed fabricates a specific event on demand — ideal for exercising
one branch without real payment flows. The CLI prints its own signing secret for the
forwarded session; use that secret locally.
To test the real path — including your public TLS, proxy, and framework body
handling — expose your local handler with a tunnel and point a real Stripe endpoint
at it. See testing webhooks locally; hookwatch tunnel streams captured deliveries straight to your local process so Stripe can
reach your laptop through a HookWatch endpoint.
Retry behaviour
Stripe retries failed deliveries automatically, with exponential backoff, over a limited window. The exact schedule and window have changed before, so rely on the behaviour rather than specific intervals and confirm the current policy in Stripe’s docs. What matters for your handler:
- A
2xxis the only signal Stripe reads as success. A500, a timeout, or a connection error all trigger retries. - Retries mean the same event will arrive more than once during incidents — plan for it rather than hoping it does not happen.
- Return
4xxonly for requests you genuinely reject; Stripe treats client errors as permanent and stops retrying, so a400from a transient bug loses the event.
Your own retry posture matters as much as Stripe’s — see backoff, jitter and attempt limits.
Idempotency
Because retries and at-least-once delivery are normal, dedupe on the event id (evt_...), which is stable across every retry of the same event. Record processed
ids and make the processing itself idempotent — provisioning the same subscription
twice should be a no-op, not a second charge. A dedupe that already saw an event
should still return 2xx so Stripe stops retrying. Full patterns are in webhook idempotency.
Debugging checklist
- Confirm the event was sent from the Stripe Dashboard’s event log, to the URL and mode (test vs live) you expect.
- Check the response Stripe recorded — a status code, a timeout, or a connection error each point somewhere different.
- Verify your handler reads the raw body before any parser touches it.
- Confirm the signing secret matches the endpoint and the mode of the event.
- Confirm the timestamp tolerance check is not failing on clock skew.
- Make sure the handler returns
2xxwell inside Stripe’s timeout, with heavy work deferred. - Match your application logs to the delivery by
evt_id. - Verify duplicate events are deduped and still answered with
2xx. - Check the endpoint is still enabled in Stripe and not auto-disabled after failures.
For a Stripe-specific walkthrough, see Stripe webhooks, why a Stripe webhook is not working, and Stripe signature verification.
How HookWatch helps
Point a HookWatch endpoint at Stripe and it records every delivery in full — request
headers and body, response status and body, and a numbered attempt history — so a
failed invoice.payment_failed still shows its exact payload and your handler’s
error when you investigate later. When you have deployed a fix, replay the exact
captured event (individually or in bulk) without asking Stripe to resend, pause an
endpoint while you work and resume it after, and let the event-type allowlist keep
noise out. Repeated failures on the same endpoint are grouped into an incident so a
Stripe outage on your side is one alert, not a hundred.