How-to guide

Receive Stripe webhooks through Queuey

Stripe expects your endpoint to answer 2xx quickly, retries for up to three days when it does not, and then stops. With a Queuey queue in between, the event is verified, stored and acknowledged first — and then delivered to your handler with retries, a dead-letter queue and replay. Queuey checks Stripe's signature at the door, so your handler only has to recognise Queuey: an API key on the delivery is what we recommend. A handler that already verifies Stripe-Signature can keep doing that too — Queuey can forward the header and the body untouched. That part is optional.

How it fits together

the path of one event
Stripe ──POST──▶ Queuey ingress ──▶ your queue ──POST──▶ your handler

  Stripe-Signature   verified here,       stored, retried,     checks your API key
  over the raw body  5-minute window,     dead-lettered,       (recommended); may also
                     then 2xx to Stripe   replayable           verify Stripe-Signature

Two hops, each with its own proof. Stripe proves itself to Queuey with its signature, inside the five-minute window Stripe's own libraries use — which means something here, because the request is seconds old. Queuey proves itself to your handler with a credential that is just as valid on a retry three days later. Everything on the Queuey side is configuration: no SDK, no code.

Do you need anything between Stripe and your handler?

Not always. Stripe's webhooks are well built: signed, retried, resendable. That is also why a queue in between costs you nothing in safety — Stripe's retries now cover the hop into Queuey, an ingress built to accept and store. What Stripe cannot do is run your side of the connection, and that is where events get lost: a deploy that returns 500 for an afternoon, a bug that throws on one event type for a week, a handler that does its work before it answers and times out on the first of the month. The honest comparison — the same reasoning for any provider is on its own page:

 Stripe straight to your handlerWith Queuey in between
Answering in timeYour handler has to return 2xx quickly, before it does any real work. Stripe's own advice is to put events on an asynchronous queue first.Queuey returns the 2xx once the event is stored. Your handler gets the time its work needs, and can be slow, mid-deploy or down.
When the handler failsStripe retries with exponential backoff for up to three days in live mode, then stops. After that it is a manual Resend, event by event — for 15 days in the Dashboard, 30 with the CLI.Retries follow the kind of failure: a handler that is down is held and probed instead of burning attempts, and what cannot be delivered lands in a dead-letter queue. Nothing expires before your retention window does.
Finding outAn email from Stripe once an endpoint has been failing for a while, a dashboard nobody watches, or a customer.An issue is raised when deliveries start failing, with an alert by email or Slack — while the events are still waiting in the queue.
ResendingResend in Stripe, event by event, inside Stripe's window.Replay one event, or a filtered set in bulk, once the cause is fixed — in one console, for every provider you receive from.
Finding an eventStripe's event log for what was sent; your own logs for what your code did with it.Search and filter by status, event type, customer key and time. Every attempt shows what the handler answered and the decision it led to.
Understanding a failureReading logs.The assistant in the console reads the attempts with you: a diagnosis, a suggested fix, and a resolve plan that runs only when you confirm it.
BurstsA renewal run arrives as a burst, straight at your handler.The burst lands in the queue. Deliveries go out at the concurrency you set.
More than one consumerOne Stripe endpoint per consumer — each with its own secret and its own failure handling, up to 16.One endpoint at the provider. The queue fans out to several targets, each with its own auth and retries.
Verifying the signatureIn your handler, on your network.At Queuey's ingress, before anything reaches you — and again in your handler if you want it (step 5).

When it is not worth adding. Your endpoint already does nothing but verify, write to a durable queue you operate, and return 2xx — and you are content with the dead-letter handling, replay and visibility you have there: Queuey would replace that plumbing, not close a gap. Or the events are low-stakes, the handler is simple and idempotent, and three days of Stripe retries plus an occasional manual resend is enough. And the few event types Stripe waits on belong on a direct endpoint either way.

What it costs. One more hop and one more vendor in your payment event path. Stripe's retries still stand behind Queuey's ingress exactly as they stood behind your endpoint: an event Queuey cannot accept is retried by Stripe, the same as before.

1 — Create the queue

Create a queue in the console — stripe in the examples below. Its ingress URL is https://ingress.queuey.ai/events/ten_yourTenant/stripe; the quickstart shows where to find yours. Use one queue per Stripe endpoint: Stripe issues a separate signing secret for every endpoint, and live mode and sandboxes never share one, so stripe-live and stripe-test are two queues with two secrets.

A new queue is Log-only: it stores what arrives and delivers nothing. Leave it that way until step 4 is done.

2 — Point a Stripe endpoint at it

In Stripe, open Workbench → Webhooks and create an event destination of type Webhook endpoint:

Stripe event destination
Endpoint URL:    https://ingress.queuey.ai/events/ten_yourTenant/stripe
Events:          only the types your handler acts on
Signing secret:  whsec_…        # revealed once the endpoint exists — step 3 needs it

Queuey answers 202 Accepted once the event is durably stored, and Stripe counts any 2xx as delivered. Rehearse in a sandbox first: stripe trigger payment_intent.succeeded sends a real, signed event through the whole path. Already have an endpoint in production? See moving an existing endpoint — it can keep its secret.

3 — Verify Stripe at ingress

Stripe cannot send custom headers, so an API key is not an option on this hop. It signs every request instead, and Queuey verifies that signature before it accepts the event. On the queue's Security tab:

Security → How senders authenticate
Ingress authentication:  Signed request (HMAC)
Signing template:        Stripe (Stripe-Signature)
Signing credential:      Create
                           Name:    stripe-live
                           Key ID:  stripe-live   # any label — Stripe's scheme has no key id
                           Secret:  whsec_…       # this endpoint's signing secret

The secret is stored encrypted and is never shown again. On every request Queuey then:

  • computes HMAC-SHA256 over {t}.{raw body} with the secret and compares it, in constant time, with every v1 value in the header — so a request signed while you roll the secret still passes. v0 and unknown schemes are ignored;
  • requires the timestamp t to be within five minutes of now, the same window Stripe's libraries use;
  • refuses anything else with 401 and a code — missing_required_header, invalid_signature or timestamp_out_of_range. Stripe sees a failed attempt and retries with a fresh timestamp and signature, so a refused request is not a lost event.

Stripe recommends an IP allowlist next to the signature. If you want both, add Stripe's published webhook addresses to the queue's source-IP allowlist. The list changes now and then; treat it as a second gate, never the only one.

4 — Deliver to your handler with an API key

On the queue's Delivery tab, set the endpoint and tell Queuey how to authenticate to it. We recommend an API key: one header, one constant-time comparison in your handler, and it is as valid on a replay next week as on the first attempt.

Delivery → How we authenticate to you
Endpoint:         https://api.example.com/webhooks/stripe
Authentication:   API key header
Header name:      X-Api-Key
Credential:       Create → a long random value you generate
                  # your handler's secret — not a Queuey API key

Any other mode does the same job if it suits your handler better: a bearer token, basic auth, OAuth2 client credentials, or signed deliveries — an HMAC signature Queuey makes fresh for every attempt. Then switch the queue from Log-only to Deliver. This is what reaches your handler:

delivery to your handler
POST /webhooks/stripe HTTP/1.1
Content-Type: application/json
X-Api-Key: 9f2c…e1                      # step 4 — how your handler knows it is Queuey
Idempotency-Key: queuey:evt_nd9qgy1sjPIW
X-Queuey-Event-Id: evt_nd9qgy1sjPIW
X-Queuey-Path: ten_yourTenant/stripe
Stripe-Signature: t=1758350000,v1=5257a869e7ec…,v0=6ffbb59b…   # step 5 — only if you map it

{ "id": "evt_1QxK2mLkdIwHu7ix", "object": "event", "type": "invoice.paid", "data": { … } }

The body is the bytes Stripe sent — not parsed, not re-serialized. The Idempotency-Key and the X-Queuey-* headers are Queuey's own and stay the same across retries of one event. Your handler checks the key and reads the event; the Stripe signature has already been checked:

import crypto from "node:crypto";
import express from "express";
import type Stripe from "stripe";

const deliveryKey = process.env.QUEUEY_DELIVERY_KEY!; // the value you stored in step 4

function isQueuey(presented = ""): boolean {
  const a = Buffer.from(presented, "utf8");
  const b = Buffer.from(deliveryKey, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post("/webhooks/stripe", express.json(), (req, res) => {
  // 401 tells Queuey the credential is wrong: it holds the queue and asks
  // for action, instead of dead-lettering events that did nothing wrong.
  if (!isQueuey(req.get("X-Api-Key"))) return res.sendStatus(401);

  const event = req.body as Stripe.Event; // Stripe's signature was verified by Queuey at ingress

  // Your own code from here. Deduplicate on event.id, as Stripe advises.
  res.sendStatus(200);
});

5 — Optional: keep verifying Stripe's signature in your handler

Nothing requires this. It is for a handler that already calls constructEvent and should keep doing so, or for a team that wants proof at the handler that Stripe produced exactly these bytes, independent of Queuey. Use it together with the API key from step 4, not instead of it. Under Delivery → Payload & ordering, leave the payload format on Raw (as received) — the default — and add one mapped header:

Delivery → Payload & ordering → Mapped headers
Sends header        ←   Read from the incoming request
Stripe-Signature    ←   Header: Stripe-Signature

Queuey reads the header off Stripe's request when the event is accepted, stores it with the event, and sends that stored value on every delivery attempt. Leave required off: with the Stripe template on, a request without the header never gets this far. Because the body is forwarded byte for byte, the signature still matches. Content-Type arrives without Stripe's charset parameter, which does not matter: the signature covers the timestamp and the body only.

No payload mutations on a queue that forwards the signature
A Transform Map or a JSON Patch changes the bytes, and a signature over the original bytes cannot match a changed body. Queuey still forwards the header, and your handler rejects the event. If you need to reshape Stripe events, rely on step 4 alone.

The one change in your handler: the tolerance

Stripe's libraries reject a signature whose timestamp is more than five minutes old. Stripe signs when it sends, and Queuey forwards that signature unchanged — it has to, because only Stripe can make a new one. A first delivery reaches your handler within moments, well inside the window. A retry after your handler was down, a queue that was held, or a replay from the dead-letter queue does not. With the default tolerance that delivery fails verification and your handler answers 400. Queuey reads a 400 as the receiver rejecting that event: it is not retried, it goes to the dead-letter queue — and replaying it cannot help, because the signature only gets older. The events Queuey exists to save would be exactly the ones your handler refuses.

So pass a tolerance that covers the longest an event may wait in Queuey. Your plan's retention window is the upper bound:

const secret = process.env.STRIPE_WEBHOOK_SECRET!; // the same whsec_… Queuey verifies with

// The longest an event may wait in Queuey and still be delivered — a retry
// after an outage, or a replay from the dead-letter queue. In seconds.
const TOLERANCE = 7 * 24 * 60 * 60;

app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
  if (!isQueuey(req.get("X-Api-Key"))) return res.sendStatus(401); // step 4 stays in front

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,                     // the raw bytes, never a parsed object
      req.get("Stripe-Signature")!, // forwarded by Queuey exactly as Stripe sent it
      secret,
      TOLERANCE,                    // the library default is 300
    );
  } catch {
    return res.status(400).send("signature verification failed");
  }

  // Your own code from here. Deduplicate on event.id.
  res.sendStatus(200);
});

Use a real number rather than 0. Stripe's documentation says 0 switches the check off, which is true of most of its libraries — but Stripe.net compares against the value as it is, and would reject nearly every event.

What a wide tolerance gives up, and what covers it
The five-minute window is replay protection, and it has moved to where it works: Queuey enforces it at ingress, seconds after Stripe signed. At your handler the signature still proves that Stripe produced these exact bytes; what a wide tolerance gives up is freshness. Two things cover that. The API key from step 4 means only Queuey can reach the handler at all. And Stripe already asks you to record the event ids you have processed and skip the ones you have seen — which makes a replayed request a no-op.

Moving an existing endpoint

A Stripe signing secret belongs to the endpoint, not to its URL. You can therefore put Queuey in front of a handler that is already in production without issuing a new secret, and without a gap:

  1. Make the handler accept both callers. A request with the API key from step 4 is Queuey; anything else goes through the Stripe signature check you already have. Deploy that first — it changes nothing while Stripe still calls you directly.
  2. Set up the queue as in steps 1, 3 and 4, with the secret your handler already uses and the handler's current URL as the delivery endpoint. Switch it to Deliver.
  3. In Stripe, edit the endpoint's URL to the queue's ingress URL. The secret stays the same; from the next event on, the path runs through Queuey. Rolling back is the same edit in reverse.
  4. When Stripe's event deliveries show the new URL, remove the fallback so the handler answers only to the API key — or keep the Stripe check as well, the way step 5 describes.

The shortest move of all is step 5 on its own: with the signature forwarded and the tolerance widened, your handler cannot tell a delivery through Queuey from one straight from Stripe, so step 1 above is a one-line change. Add the API key afterwards. Either way, avoid running two live Stripe endpoints side by side — one direct, one through Queuey — unless the queue stays in Log-only: your handler would get every event twice, signed with two different secrets.

Keep the events Stripe waits on at a direct endpoint
For a few event types Stripe acts on your endpoint's response, and a queue answers before your code has run. issuing_authorization.request is a synchronous question — the response is the decision — and cannot go through a queue at all. Stripe also holds automatic invoice finalization until invoice.created is acknowledged, and Checkout holds its redirect for up to ten seconds waiting on checkout.session.completed; through Queuey both are acknowledged at once. If you rely on any of these, register a second Stripe endpoint for those types that points straight at your handler, and send everything else through the queue.

What changes, and what does not

  • Stripe's delivery view stops at Queuey. An event is Delivered in Stripe when Queuey has stored it. Whether your handler has processed it is in the Queuey console: every attempt, what the handler answered, and why Queuey retried, held or stopped (operational runbook).
  • Order. Stripe does not guarantee that events arrive in the order they happened, and Queuey cannot restore an order Stripe never had. Keep the handler independent of order, as Stripe advises. Per-key lanes need a top-level body field, and Stripe keeps the customer inside data.object. Connect is the exception: events from connected accounts carry a top-level account, which works as a GroupKey — one lane per connected account.
  • Duplicates. Stripe can deliver the same event more than once and sends no Idempotency-Key, so Queuey has nothing to deduplicate on at ingress: two deliveries from Stripe become two events. Deduplicate on Stripe's id in the handler. Queuey's own retries of one event carry the same body and the same Idempotency-Key.
  • Event types in the console. Set event typing on the queue's Events tab to the JSON body field type, and every event is labelled invoice.paid, customer.subscription.updated and so on — filterable, without opening a payload.
  • Rolling the secret. Stripe signs with the old and the new secret for up to 24 hours. Within that window, create a new signing credential and select it on the queue. If you forward the signature, update the handler too — and note that events accepted before the roll carry only the old signature, so deliver or replay them before the handler forgets the old secret.
  • Thin events are signed the same way, with the same header. Give a thin-event destination its own queue: it is a separate endpoint with its own secret.

Troubleshooting

What you seeWhyFix
Stripe shows 401 from Queuey, code invalid_signatureThe signing credential holds another endpoint's secret. Every Stripe endpoint has its own, and live mode and sandboxes never share one.Reveal the signing secret on this endpoint in Stripe and store that one.
401 missing_required_header when you test with curlThe request carries no Stripe-Signature. That is the gate working.Send a real event: stripe trigger payment_intent.succeeded, or create the object in a sandbox.
The queue is held and asks for action after a 401 or 403 from your handlerThe API key Queuey sends is not the one your handler expects. Queuey treats that as a broken target, not a bad event: nothing is dead-lettered, and nothing is delivered until it is fixed.Correct the credential or the header name under Delivery, then resume the queue.
Forwarded signature: “Timestamp outside the tolerance zone” / “outside of the allowed tolerance”The delivery came more than five minutes after Stripe signed it — a retry, a held queue or a replay — and the handler still uses the library default.Pass a tolerance that covers your queue, as in step 5. Then replay the event from the dead-letter queue.
Forwarded signature: “No signatures found matching the expected signature”The bytes or the secret differ: a Transform Map or JSON Patch on the queue, a framework that parsed the body before you verified it, or a handler that holds another endpoint's secret.Keep the queue on Raw with no payload mutations, verify the raw body, and use this endpoint's secret on both sides.
Forwarded signature: the handler gets no Stripe-Signature headerThe event was accepted before the mapped header was saved. The value is read once, at accept, and changing the mapping does not rewrite events already queued.Save the mapping before Stripe starts sending. For the events in between, use Resend in Stripe.

Related