Skip to content
Back to site

Receive Stripe webhooks reliably

Stripe sends its webhooks to a Railhook source. The source verifies the signature, stores each event as it arrived and forwards it to your service. Failed forwards are retried, and anything that still fails can be replayed later.

Why Stripe’s retries are not enough on their own

Section titled “Why Stripe’s retries are not enough on their own”

In live mode Stripe retries a failed event for up to three days with exponential backoff; in a sandbox, three times over a few hours. The Dashboard can resend an event for up to 15 days, the Stripe CLI for up to 30. Events can arrive out of order and more than once. (Stripe: Receive Stripe events in your webhook endpoint)

What is left to you:

  • Answering fast. Stripe expects a 2xx before any complex logic, or the delivery times out and counts as failed. Railhook answers Stripe once the event is stored; your service gets as long as the destination’s timeout allows.
  • Recovery after an outage. The Dashboard resends one event at a time. Railhook replays every stored event from a time range in one call.
  • Duplicates. Railhook recognises the evt_… id: a second delivery of the same event is answered 202 with the stored copy and is not forwarded again.

You need a Railhook project and an API key. The same steps are available under Incoming in the dashboard.

Terminal window
export RAILHOOK_URL=https://railhook.io # or your own instance
export RAILHOOK_API_KEY=...
export PROJECT_ID=...
  1. Create the source. Stripe shows the signing secret only after you register a URL, so create the source first, without the secret. With verificationMode set to PROVIDER and no secret, it answers every request 401 until you add one, so nothing unverified is stored.

    Terminal window
    curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources" \
    -H "X-API-Key: $RAILHOOK_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"name":"Stripe","providerType":"STRIPE","verificationMode":"PROVIDER"}'

    Keep the id and the ingressUrl from the response.

  2. Register the URL in Stripe. In Workbench, open Webhooks and click Create an event destination. Choose Your account, pick the event types you handle, choose Webhook endpoint, and paste the ingressUrl as the Endpoint URL.

  3. Give Railhook the signing secret. On the endpoint’s page in Stripe, click Reveal secret and copy the value that starts with whsec_. Set it on the source:

    Terminal window
    curl -X PUT "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources/$SOURCE_ID" \
    -H "X-API-Key: $RAILHOOK_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"name":"Stripe","hmacSecret":"whsec_..."}'

    Only the fields you send change; the secret is write-only and never returned.

  4. Add a destination, the URL of your service:

    Terminal window
    curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources/$SOURCE_ID/destinations" \
    -H "X-API-Key: $RAILHOOK_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"url":"https://api.example.com/webhooks/stripe","authType":"BEARER","authConfig":"{\"token\":\"...\"}","enabled":true}'
  5. Send a test event from Stripe, or run stripe trigger payment_intent.succeeded. It appears under Incoming → Received, with one forward per destination.

Railhook reads Stripe-Signature, computes the hex HMAC-SHA256 of <t>.<raw body> with the whsec_ secret and compares it with each v1 value. t must be within 300 seconds of Railhook’s clock. While you roll the secret in Stripe, it sends one v1 per active secret; the request verifies if any of them matches, so put the new secret on the source as soon as you roll it. A request that fails is answered 401 and not stored, and Stripe retries it.

The body exactly as Stripe sent it, byte for byte, with its Content-Type. Stripe-Signature is not forwarded: your service authenticates Railhook through the destination’s credentials instead, so it does not need the Stripe secret at all. Each request carries Idempotency-Key, stable across attempts, and X-Forward-Attempt. See Destinations.

A forward is retried on 408, 429, any 5xx, a timeout or a connection error, up to 5 attempts. Any other 4xx moves it to Failed Forwards at once, where you can retry it after fixing your service.

Point a destination at your machine through a tunnel to receive verified Stripe test events locally.

  1. Install the CLI and log in, as in CLI, then open a tunnel to the port your app listens on:

    Terminal window
    railhook listen 3000
  2. Add a second destination to the source with the printed public URL and your route appended, such as https://<host>/tunnel/<slug>/webhooks/stripe. A path after the tunnel URL is kept, so this reaches http://localhost:3000/webhooks/stripe.

  3. Trigger an event in your Stripe sandbox. When you stop the tunnel, disable or delete that destination so its forwards do not pile up in Failed Forwards.

If your service was down longer than the retries lasted, replay what arrived in the meantime. This creates new forwards to every destination of the source, starting from attempt 1:

Terminal window
curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-events/bulk-replay" \
-H "X-API-Key: $RAILHOOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sourceId":"'"$SOURCE_ID"'","from":"2026-09-18T10:00:00Z","to":"2026-09-18T14:00:00Z","verified":true}'

Keep your handler idempotent on the Stripe event id: a replay sends events your service may already have processed. Replay reaches back as far as events are kept: 7 days on Railhook Cloud, and whatever you configure when self-hosting. See Replay.