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, without a code change: Queuey can recalculate the Stripe signature on each delivery, over the bytes Stripe sent. 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 and goes out at the pace you set — a rate limit per destination, a set number of requests per time window. What exceeds it waits in the queue.
More than one consumerOne Stripe endpoint per consumer — each with its own secret and its own failure handling, up to 16.Still one endpoint per consumer — each a queue with its own target and auth — but every consumer gets the same failure handling, alerts and replay, in one console.
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
User-Agent: Queuey-Delivery/1.0 (+https://queuey.ai/abuse)
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: que_yourStripeQueue
Stripe-Signature: t=1758350000,v1=5257a869e7ec…   # step 5 — only if you turn it on

{ "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, unchanged

Nothing requires this. It is for a handler that already calls constructEvent and should keep doing so. With Stripe signature recalculation on the queue, it keeps calling constructEvent exactly as it does today: the Stripe endpoint's whsec_… secret, no code change. Queuey signs Stripe's unchanged bytes again with a fresh timestamp on each attempt — first delivery, retries and replays — so your handler's constructEvent keeps working with the default tolerance. Only events that arrive through this queue's Stripe-verified ingress after you turn it on are signed.

Turn it on under Delivery → Request signing: tick Recalculate the Stripe signature on every delivery. Queuey signs with the secret this queue's Stripe ingress verifies with — the signing secret of the Stripe endpoint from step 2 — and with no other, so a handler that used to receive from that endpoint directly already has it (see moving an existing endpoint); a handler that verified another endpoint needs this one's secret. The checkbox is there only when the queue's ingress verifies Stripe as in step 3, and only on the queue itself, not as a workspace default. It takes the place of Queuey HMAC rather than adding to it: a queue signs one way.

A handler that admits only Stripe's IP addresses will reject every delivery
Recalculation changes the signature, not the sender. Deliveries come from Queuey's own IP addresses, with the User-Agent Queuey-Delivery/1.0 (+https://queuey.ai/abuse) and the X-Queuey-Event-Id and X-Queuey-Path headers. If your handler, or a firewall in front of it, checks for Stripe's published addresses, remove that check for this handler. Queuey does not publish addresses to put in its place; the API key from step 4 is what identifies it.

What Queuey signs, and what it refuses

Queuey signs an event as Stripe only if it arrived through the queue's ingress after recalculation started, and only with the secret the ingress verified it with. While recalculation is on, the ingress can't be switched away from verifying Stripe, and giving it another secret starts recalculation again, so every event Queuey signs passed the check from step 3 with that secret. Recalculation starts 30 seconds after you save — the longest the ingress can take to pick up a changed setting — and the queue shows when. On every delivery attempt Queuey then signs the stored bytes: t is the time of this attempt, and v1 is HMAC-SHA256 over {t}.{raw body}. Stripe does the same on its own retries and when you use Resend in its Dashboard: a fresh t every time, while the event's own time stays in the body as created. So a retry after an outage, a queue that was held and a replay from the dead-letter queue all reach your handler with a signature seconds old. It refuses three things:

  • Events from before recalculation started. An event that arrived earlier — for example while the queue's ingress was still open — is not signed as Stripe: it goes to the dead-letter queue with failure class OriginNotVerified, and the rest of the queue keeps delivering. Resend in Stripe brings it back as a new event. A test delivery typed in the console is refused the same way, with the reason in its result, because Stripe never sent it. To test the path, send a real event: stripe trigger in a Stripe sandbox, or Resend in Stripe.
  • Bodies Queuey has changed. A queue that recalculates the Stripe signature cannot have a Transform Map or payload patches — its own or inherited from the workspace — and its payload format must be Raw (as received). The combination is refused when you save.
  • A second Stripe-Signature. The header belongs to the signature, so it cannot also be a static header, a mapped header or the GroupKey header. That is also why recalculating the signature and forwarding Stripe's own one, below, cannot share a queue.

A receiver you do not run. A service that accepts Stripe webhooks verifies them with the signing secret you give it. Give it the secret of the Stripe endpoint from step 2: Queuey signs with that secret and no other, so a service that insists on a secret of its own can't sit behind this queue — give it a Stripe endpoint of its own instead.

Your handler still deduplicates on Stripe's event id. A valid signature does not mean a first delivery: Queuey's retries and replays deliver the same event again, each signed afresh, just as Stripe's own retries do — and Stripe already asks you to skip the ids you have processed. Keep the API key from step 4 as well, as defence in depth: the signature vouches for the bytes, the key for the caller.

If you want proof independent of Queuey: forward Stripe's own signature

With recalculation, the v1 your handler checks was made by Queuey, with your secret, after Queuey checked Stripe's. If the handler should check a signature Stripe made itself, forward Stripe's header instead. The two cannot be combined on one queue — Stripe-Signature cannot be both mapped and recalculated — so leave recalculation off, leave the payload format on Raw (as received) — the default — and add one mapped header under Delivery → Payload & ordering:

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. Use it together with the API key from step 4, not instead of it.

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 price is the tolerance. Stripe's libraries reject a signature whose timestamp is more than five minutes old, and a forwarded signature keeps the timestamp Stripe gave it when it sent the event. 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 a forwarded signature only gets older. The events Queuey exists to save would be exactly the ones your handler refuses. Recalculation removes that problem; forwarding has to work around it.

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, without a gap — and, with Stripe signature recalculation, without changing the handler:

  1. Set up the queue as in steps 1 and 3, with the secret your handler already uses. On the Delivery tab, give it the handler's current URL, tick Recalculate the Stripe signature on every delivery with the same secret (step 5), and set the API key from step 4 — a handler that does not check the key yet ignores the header. Switch the queue to Deliver.
  2. 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, and your handler verifies each delivery the way it verified Stripe's. Rolling back is the same edit in reverse.
  3. When Stripe's event deliveries show the new URL, add the API-key check to the handler when it suits you — defence in depth, not a condition for the move.

One thing can still stop deliveries: a handler or firewall that admits only Stripe's IP addresses. Change that before step 2 (step 5 says why). Without recalculation, the handler has to change first. To rely on the API key alone, make it accept both callers — a request with the key is Queuey, anything else goes through the Stripe check you already have — and deploy that before step 2. To forward the signature instead, widen its tolerance before step 2. 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's ingress. If the queue recalculates the Stripe signature, that starts it again with the new secret 30 seconds later: an event that arrived before is never signed with the new secret — it goes to the dead-letter queue, and you resend it from Stripe. Give the handler the new secret at the same time. If you forward the signature instead, update the handler too, and deliver or replay the events accepted before the roll 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.
Recalculation: an event is dead-lettered with failure class OriginNotVerified — “Not signed as Stripe: …”The event arrived before recalculation started — it starts 30 seconds after you save the delivery settings — for example while the queue's ingress was still open. Queuey can't vouch that Stripe sent it, so it doesn't sign it as Stripe. (A test delivery typed in the console is refused the same way, with the reason in its result; it never becomes an event.)Nothing on the queue needs fixing, and the other events keep delivering. For a real Stripe event, use Resend in Stripe: it arrives as a new event with a current signature. To test the path, send a real event with stripe trigger.
Recalculation: the queue is held and asks for actionThe attempt's failure class is SignatureRecalculationFailed, and its error names the cause: the queue's ingress no longer verifies Stripe, a Transform Map or payload patch — the queue's own or inherited from the workspace — now changes the body, the ingress secret can't be used (revoked or deleted), or the ingress got another secret in a change that didn't save the queue. Queuey held the delivery before sending, and the events wait in the queue.Put the ingress back on Signed request with the Stripe template, remove the transform or patch — at the workspace, if that is where it lives — replace the secret, or save the queue's delivery settings to start recalculation again with the new secret. Then Verify & resume.
Recalculation: the handler answers 400 — “No signatures found matching the expected signature”The handler's secret is not the one Queuey signs with — the secret this queue's ingress verifies with: it belongs to another Stripe endpoint, or the ingress secret was rolled and the handler still has the old one.Give both sides the same secret, then replay the events from the dead-letter queue. Every attempt is signed afresh, so a replay verifies.
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.Turn on Stripe signature recalculation for the queue (and remove the mapped header), or 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