- A Stripe webhook is an HTTP POST carrying an Event object (a
typelikeinvoice.paidand adata.object). Subscriptions need them because the billing lifecycle — renewals, failures, cancellations — happens on Stripe's clock, not in a request your server made. - Verify every event with
stripe.webhooks.constructEvent, and feed it the raw request body. The number-one bug isexpress.json()parsing the body before verification — useexpress.raw()on the webhook route. - The events that matter:
checkout.session.completed,customer.subscription.created/.updated/.deleted, andinvoice.paid/invoice.payment_failed. Drive access off subscription status. - Stripe delivers at-least-once and retries non-2xx responses for up to ~3 days. So: return 2xx fast, process asynchronously, and be idempotent — dedupe on the event
id. - Test locally with the Stripe CLI (
stripe listen+stripe trigger). Map Price IDs to entitlements, and grant access when status isactiveortrialing. - This is a lot of moving parts to get right on one rail. Crossdeck gives you verified subscriptions and entitlements across Stripe, Apple, and Google so you don't hand-wire every webhook and rail yourself.
Definitions used in this guide
An HTTP POST Stripe sends to a URL you registered, whenever a matching event occurs in your account. Your server is the receiver.
The JSON object Stripe posts. It has an id (evt_…), a type (e.g. invoice.paid), and data.object — the resource the event is about.
The signing secret (whsec_…) for a specific webhook endpoint, shown in the Dashboard or printed by the Stripe CLI. Used to verify signatures.
Checking the Stripe-Signature header against the raw body with the endpoint secret, so you only act on events Stripe genuinely sent.
Processing a duplicate delivery has no extra effect. Achieved by deduping on the event id, because Stripe delivers at least once.
The access state your app grants after a purchase — e.g. pro. You map Stripe Price/Product IDs to entitlements and drive them off subscription status.
What are Stripe webhooks, and why do subscriptions need them?
A Stripe webhook is a small HTTP POST request that Stripe makes to a URL you own, every time something noteworthy happens in your account. The request body is an Event: a JSON object with an id, a type such as checkout.session.completed or invoice.payment_failed, and a data.object that holds the resource the event is about — the Checkout Session, the Subscription, the Invoice. Your server verifies the request really came from Stripe, reads the event, and does something: grant access, extend a subscription, record revenue, flag a failed payment.
Subscriptions need webhooks for one structural reason: most of the subscription lifecycle happens when your server is not in the loop. The initial purchase, you can see — the customer is right there, redirected back from Checkout. But the renewal that charges their card a month later, the payment that fails on day 40 because the card expired, the cancellation they trigger from Stripe's billing portal, the trial that converts at 2am — none of those involve a request to your backend. Stripe is the only party that knows they happened. A webhook is how it tells you. Turn webhooks off and your app slowly drifts out of sync with reality: customers who stopped paying keep their access, customers who renewed get locked out, and your revenue numbers quietly diverge from Stripe's.
This guide is the complete, working version of getting that right. We cover how delivery actually works, how to stand up an endpoint in Node and Express, how to verify the signature (and the single most common bug that breaks it), exactly which events matter for subscriptions and what to do on each, how to stay correct under duplicate and out-of-order deliveries, how to test the whole thing locally with the Stripe CLI, and how to map Stripe products to the entitlements your app checks. Every code sample is copy-paste Node, verified against Stripe's public documentation. If you want the higher-level version aimed at founders rather than engineers, we wrote a companion piece on Stripe webhooks for app subscriptions: what founders need to know — this page is the deep reference it points up to.
How Stripe webhook delivery actually works
The mechanics are simple once you see the whole loop. You register one or more webhook endpoints — public HTTPS URLs — either in the Stripe Dashboard or with the API, and you subscribe each endpoint to the event types it cares about. When one of those events occurs, Stripe sends an HTTP POST to your URL with the Event as the JSON body and a Stripe-Signature header. Your endpoint verifies the signature, does its work, and returns a status code. A 2xx tells Stripe the event was received; anything else (or a timeout) tells Stripe it failed, and Stripe schedules a retry.
A few properties of this delivery model shape everything you build on top of it, and they are all documented Stripe behaviour:
- At-least-once delivery. Stripe guarantees it will deliver each event at least once, which means it may deliver the same event more than once. Your handler has to tolerate duplicates — this is why idempotency is not optional.
- Retries on non-2xx. If your endpoint does not return a 2xx, Stripe retries with exponential backoff — in live mode, for up to roughly three days — then marks the event as failed and can disable the endpoint if failures persist.
- No ordering guarantee. Events are not guaranteed to arrive in the order they occurred. A
customer.subscription.updatedcan land before thecreated, or an older event can arrive after a newer one. - Signed, not authenticated by you. Anyone can POST to your public URL. The
Stripe-Signatureheader, verified with your endpoint secret, is what proves an event is genuinely from Stripe and was not tampered with. - Test and live are separate. Test-mode events come from test-mode keys and secrets; live events from live ones. The secrets are different, and mixing them is a common cause of verification failures.
Hold those five in mind and most webhook bugs become predictable. The rest of this guide is, in effect, the correct response to each one: verify the signature, respond 2xx fast, dedupe on the event id, and don't trust delivery order.
Setting up a Stripe webhook endpoint in Node
Here is the smallest endpoint that is actually correct — an Express route that verifies the signature and acknowledges the event. Everything else in this guide builds on this shape. Install the SDK first (npm install stripe express), then:
const express = require("express");
const Stripe = require("stripe");
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_...
const app = express();
// IMPORTANT: express.raw, not express.json — Stripe signs the raw bytes.
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event.
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
console.log(`Checkout completed for customer ${session.customer}`);
break;
}
case "invoice.paid": {
const invoice = event.data.object;
console.log(`Invoice paid: ${invoice.id}`);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
console.log(`Payment failed for customer ${invoice.customer}`);
break;
}
default:
console.log(`Unhandled event type ${event.type}`);
}
// Acknowledge receipt so Stripe stops retrying.
res.json({ received: true });
}
);
app.listen(4242, () => console.log("Listening for webhooks on :4242"));
Three details in that snippet are load-bearing, and each is a common source of a broken webhook:
express.raw({ type: "application/json" })— the request body reaches your handler as aBufferof the exact bytes Stripe signed. This is the crux of the next section.- The
Stripe-Signatureheader — read fromreq.headers["stripe-signature"](Node lower-cases header names). It carries the timestamp and signaturesconstructEventchecks. - Return
400on a verification failure,2xxon success. A 400 tells Stripe the event was rejected (a bad or forged signature should be rejected); a 2xx tells Stripe you have it and to stop retrying.
Note that you never construct the event yourself from req.body. You always let constructEvent parse and verify it in one step, and use its return value. Parsing the JSON yourself and trusting it is the insecure shortcut this whole design exists to prevent.
Signature verification and the raw-body gotcha
Signature verification is the one part of webhooks you cannot skip, because your endpoint is a public URL and anyone can POST to it. The mechanism: Stripe signs each request using your endpoint secret and puts the signature, plus a timestamp, in the Stripe-Signature header. You recompute the signature over the raw body with the same secret and check it matches. If it does, the event is genuinely from Stripe and was not altered in transit. The SDK does all of this for you in a single call:
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // the RAW body (Buffer or string), not parsed JSON
req.headers["stripe-signature"],
endpointSecret // whsec_... for THIS endpoint
);
} catch (err) {
// Invalid signature, altered payload, or a stale timestamp.
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// From here, `event` is trusted.
The raw-body gotcha — the number-one webhook bug
constructEvent must be given the exact bytes Stripe signed. The moment a body parser touches the request, those bytes are gone: express.json() turns the body into a JavaScript object, and re-serializing that object produces different bytes (key order, whitespace, number formatting), so the signature will never match. The symptom is a relentless No signatures found matching the expected signature for payload error even though your secret is correct.
The fix is to make sure the webhook route sees the raw body. Two safe patterns:
// Pattern A — mount raw ONLY on the webhook route, before global json.
app.post("/webhook", express.raw({ type: "application/json" }), webhookHandler);
// Then parse JSON for the rest of your API.
app.use(express.json());
// Pattern B — if json() is already global, exclude the webhook path.
app.use((req, res, next) => {
if (req.originalUrl === "/webhook") return next();
return express.json()(req, res, next);
});
The rule of thumb: register the raw webhook route before any global JSON middleware, or explicitly skip JSON parsing for the webhook path. If you use a framework other than Express, the same principle holds — you need access to the unmodified request body. In Next.js API routes, disable the built-in body parser (export const config = { api: { bodyParser: false } } in the Pages Router, or read await req.text() in a Route Handler) and pass the raw text to constructEvent.
Replay protection is built in
constructEvent also checks the timestamp in the signature header against the current time, within a default tolerance (300 seconds). This is what stops an attacker from capturing a valid request and replaying it hours later — a stale timestamp fails verification. You get replay protection for free as long as you use constructEvent rather than rolling your own check.
Async verification on edge runtimes
On runtimes where cryptography is asynchronous — Deno, Cloudflare Workers, some edge functions — use await stripe.webhooks.constructEventAsync(...) instead of constructEvent. It takes the same arguments and returns a Promise. In standard Node, the synchronous constructEvent is correct.
The Stripe subscription events that matter
Stripe emits dozens of event types, but a subscription app only needs a handful. Subscribe your endpoint to the ones you actually handle — fewer events means less noise and fewer things to keep idempotent. This table is the core set, what each one means, and the action to take. It is the single most useful thing to keep open while you write your handler.
| Event | What it means | What to do |
|---|---|---|
checkout.session.completed | A Checkout Session finished — the customer paid or started a subscription/trial | Provision access; store the customer and subscription ids against your user |
customer.subscription.created | A subscription now exists for the customer | Map its Price to an entitlement; grant if status is active/trialing |
customer.subscription.updated | The subscription changed — plan, status, cancel-at-period-end, etc. | Re-sync the entitlement from the new status and Price |
customer.subscription.deleted | The subscription ended (canceled and no longer active) | Revoke the entitlement |
invoice.paid | An invoice (usually a renewal) was paid successfully | Extend access; record recognised revenue for the period |
invoice.payment_failed | A charge failed; the subscription typically goes past_due | Flag the account at-risk; prompt a card update; let dunning run |
Two honourable mentions you may want, depending on your product: customer.subscription.trial_will_end (fires ~3 days before a trial converts — a good moment to nudge the customer) and invoice.payment_succeeded (closely related to invoice.paid; for most flows, handling invoice.paid is enough — pick one and be consistent). Now the detail on each core event.
checkout.session.completed — the purchase finished
This fires when a Stripe Checkout Session completes. For a subscription Checkout (mode: "subscription"), it is your signal to provision access and record the mapping between your user and Stripe's customer and subscription. The critical fields:
case "checkout.session.completed": {
const session = event.data.object;
// Only act on subscription-mode sessions here.
if (session.mode !== "subscription") break;
// You set client_reference_id (or metadata) when creating the session,
// so you can tie Stripe's customer back to YOUR user.
const appUserId = session.client_reference_id; // e.g. "user_123"
const stripeCustomerId = session.customer; // "cus_..."
const stripeSubscriptionId = session.subscription; // "sub_..."
await db.users.linkStripe(appUserId, {
stripeCustomerId,
stripeSubscriptionId,
});
// Grant provisional access now; subscription.* events keep it in sync.
await grantAccess(appUserId, { reason: "checkout_completed" });
break;
}
The linchpin is client_reference_id (or metadata): you set it when you create the Checkout Session, and it is the only reliable way to connect the anonymous-to-you Stripe customer back to a specific user in your database. Set it, or you will be reverse-engineering the mapping from email addresses later. Note that checkout.session.completed confirms the session finished — for the authoritative subscription state you still lean on the customer.subscription.* events, which carry the status.
customer.subscription.created / updated / deleted — the lifecycle
These three are the heart of keeping access correct, because they carry the subscription's status — and status is what you should drive access off. Handle created and updated the same way (re-sync from the current state), and deleted as a revoke:
case "customer.subscription.created":
case "customer.subscription.updated": {
const subscription = event.data.object;
const status = subscription.status; // see the status table below
const priceId = subscription.items.data[0].price.id;
const entitlement = entitlementForPrice(priceId); // your mapping
// Access is on when the customer is actively paying or in trial.
const active = status === "active" || status === "trialing";
await setEntitlement(subscription.customer, {
entitlement,
active,
status,
currentPeriodEnd: subscription.current_period_end, // unix seconds
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object;
await revokeEntitlement(subscription.customer, {
reason: `subscription_${subscription.status}`, // usually "canceled"
});
break;
}
A subscription can be in one of several statuses, and mapping them to "has access / does not" is a product decision you should make deliberately. Here is the full set and a sensible default:
| Status | Meaning | Default access |
|---|---|---|
trialing | In a free trial before the first charge | Grant |
active | Paid and current | Grant |
past_due | A payment failed; Stripe is retrying (dunning) | Grant during grace, then decide |
unpaid | Retries exhausted; still not paid | Revoke |
canceled | Ended | Revoke |
incomplete | First payment not yet confirmed | Withhold until active |
incomplete_expired | First payment never confirmed; expired | Revoke |
paused | Paused (e.g. after a trial with no payment method) | Withhold |
The updated event also carries data.previous_attributes, an object of just the fields that changed and their old values — useful when you need to react to a specific transition (for example, a plan upgrade) rather than re-syncing everything. The general rule holds, though: prefer computing your entitlement from the current state, not from the delta, so you stay correct even if you miss or reorder an event.
invoice.paid and invoice.payment_failed — the money
invoice.paid is your renewal signal: a subscription invoice was paid, so the customer's access should extend into the next period and you can record the revenue. invoice.payment_failed is the opposite: a charge did not go through, the subscription usually flips to past_due, and Stripe starts its retry schedule. Handle both:
case "invoice.paid": {
const invoice = event.data.object;
// A subscription renewal (billing_reason: "subscription_cycle") or the
// first invoice. Extend access and record revenue for the period.
await recordPayment({
customerId: invoice.customer,
subscriptionId: invoice.subscription,
amountPaid: invoice.amount_paid, // in the smallest currency unit
currency: invoice.currency,
periodEnd: invoice.lines.data[0]?.period?.end,
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
// Card declined / expired. Subscription typically now past_due.
await flagAtRisk(invoice.customer, {
subscriptionId: invoice.subscription,
attemptCount: invoice.attempt_count,
nextAttempt: invoice.next_payment_attempt, // unix seconds, or null
});
// Do NOT revoke access here by reflex — let dunning run and drive
// access off customer.subscription.updated status transitions.
break;
}
The discipline worth internalising: money events tell you what happened; the subscription's status tells you what to do about access. Record the payment on invoice.paid, flag the risk on invoice.payment_failed, but let customer.subscription.updated — with its authoritative status — be the thing that grants or revokes the entitlement. We go deeper on the failure path in how to monitor failed payments and billing retry in your app.
Reading an event: type and data.object
Every event Stripe sends has the same envelope, and knowing it by heart makes handler code obvious. The fields you use constantly:
event.id— a unique id (evt_…). This is your idempotency key; more on that next.event.type— the string you switch on (invoice.paid,customer.subscription.updated, …).event.data.object— the resource the event concerns. Its shape depends on the type: a Checkout Session, a Subscription, an Invoice. This is wherecustomer,status,items, and the rest live.event.data.previous_attributes— present on*.updatedevents; the fields that changed, with their prior values.event.livemode—truefor live events,falsefor test-mode. Handy for sanity checks.
Because data.object changes shape by type, the safe pattern is to narrow on event.type first and only then reach into the object — which is exactly what the switch in every example above does. If you use TypeScript, the stripe package ships types, and narrowing on event.type lets the compiler infer the correct object type for event.data.object.
Idempotency: handling duplicate webhook deliveries
Stripe delivers each event at least once, which is a precise way of saying you will sometimes get the same event twice. A retry after a timeout, a network blip, an occasional double-send in normal operation — any of these can replay an event you have already processed. If your handler is not idempotent, the consequences are real: a re-delivered invoice.paid double-counts revenue, a repeated checkout.session.completed sends a second welcome email or provisions twice.
The fix is to treat the event id as a unique key and refuse to process the same id twice. The cleanest version uses a database uniqueness constraint so the dedupe is atomic even under concurrent deliveries:
async function handleEvent(event) {
// Try to claim this event id. The processed_events table has a
// UNIQUE constraint on event_id, so a duplicate insert throws.
try {
await db.query(
"INSERT INTO processed_events (event_id, type) VALUES ($1, $2)",
[event.id, event.type]
);
} catch (err) {
if (isUniqueViolation(err)) {
// Already handled this event — a duplicate delivery. No-op.
return;
}
throw err;
}
// First time we have seen this id — safe to do the real work.
await processEvent(event);
}
Two refinements worth knowing. First, claim-then-process vs process-then-record: recording the id before the work (as above) prevents duplicates but risks marking an event done if the work then crashes — so make processEvent safe to retry, or move the insert into the same transaction as the work so both commit together. Second, Stripe's built-in idempotency keys are a related but different tool: they make your outbound API calls safe to retry. Webhook dedupe is about inbound events, and the event id is the key for that.
Beyond exact-duplicate protection, designing each handler to be naturally idempotent is even better: "set this customer's entitlement to pro, active" produces the same result whether it runs once or five times, whereas "increment a counter" does not. Where you can express the work as a set-to-a-known-state operation rather than an increment, out-of-order and duplicate deliveries stop mattering almost entirely.
Retries and the 2xx rule
The contract between your endpoint and Stripe is a single status code. Return a 2xx and Stripe considers the event delivered. Return anything else — a 3xx, 4xx, 5xx, or no response before the timeout — and Stripe considers it failed and retries, with exponential backoff, in live mode for up to about three days. After that it gives up, and an endpoint that keeps failing can be automatically disabled. (Test mode retries fewer times over a shorter window, which is why local failures don't hammer you.)
Two rules fall out of this, and getting them right is most of what separates a robust webhook from a flaky one:
- Respond 2xx fast, then do the work. Stripe expects a prompt response. If you run provisioning, send emails, or call third-party APIs before responding, you risk exceeding the timeout — Stripe records a failure and retries, replaying the whole event. Verify the signature, acknowledge with 2xx, and push the real work to a background job or queue. Your endpoint becomes: verify → enqueue → 200.
- Only return non-2xx when you genuinely want a retry. A failed signature check should be a
400(reject — never retry a forgery). A transient error in your own processing can be a500so Stripe retries later — but only if your processing is idempotent, or the retry will re-run side effects. If in doubt, accept the event (2xx) and handle failures with your own retry queue, where you control the semantics.
The pattern that stays correct under all of this is verify → 2xx immediately → process asynchronously → idempotent by event id. Fast acknowledgement keeps Stripe from retrying for the wrong reasons; idempotency keeps you safe when it retries for the right ones.
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
endpointSecret
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Persist the raw event and acknowledge immediately.
await queue.enqueue({ id: event.id, type: event.type, payload: event });
res.json({ received: true });
// A worker consumes the queue and runs handleEvent(event) idempotently,
// so slow work never risks a Stripe timeout or a duplicate side effect.
});
Out-of-order delivery, and how to stay correct
Stripe does not guarantee events arrive in the order they occurred. In practice this means a customer.subscription.updated can reach you before the created, or a stale event describing an old state can land after a newer one and, if you naively apply it, roll your entitlement backwards. This is a subtle bug: everything looks fine until a customer who upgraded briefly shows as downgraded because two events raced.
There are two reliable defences, and you can use either or both:
- Re-fetch the object when precise state matters. Instead of trusting the object embedded in the event, call the API by id —
await stripe.subscriptions.retrieve(subscription.id)— to read the current, authoritative state at the moment you process. An old event then simply re-reads the true current state, and order stops mattering. - Compare timestamps and refuse to go backwards. Store the timestamp of the last event you applied per subscription, and ignore any event older than it. Stripe events carry a
createdtime; the object often carries its own updated markers too.
Combined with idempotency, this makes your handler robust to the two things distributed delivery guarantees will eventually throw at you: the same event twice, and events in the wrong order. Designing handlers to set state to a computed target rather than apply a delta, as noted earlier, is what makes both defences cheap.
Testing Stripe webhooks locally with the Stripe CLI
You do not need to deploy, and you do not need ngrok, to test webhooks. The Stripe CLI tunnels events from your account straight to a local server and lets you fire specific events on demand. It is the fastest correct feedback loop, and it exercises real signature verification, not a mock.
# 1. Authenticate the CLI with your Stripe account.
stripe login
# 2. Forward events to your local endpoint. This prints a signing secret.
stripe listen --forward-to localhost:4242/webhook
# > Ready! Your webhook signing secret is whsec_abc123... (copy this)
The secret stripe listen prints is the endpoint secret for this tunnel — set it as STRIPE_WEBHOOK_SECRET in your local environment so constructEvent can verify the forwarded events. Now, in a second terminal, trigger the events you want to test:
# Simulate a completed subscription checkout.
stripe trigger checkout.session.completed
# Simulate a renewal and a failed renewal.
stripe trigger invoice.paid
stripe trigger invoice.payment_failed
# Simulate the lifecycle.
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
Each trigger creates the necessary underlying objects in test mode and sends your endpoint a real, signed event, so you can watch your handler verify, dedupe, and act. A few practices that make local testing trustworthy:
- Test the duplicate path deliberately. Run the same
triggertwice and confirm the second is a no-op — that proves your idempotency actually works, rather than assuming it. - Test the failure path. Return a 500 on purpose and watch the CLI report the non-2xx; confirm your queue or Stripe's retry does the right thing.
- Keep test and live strictly apart. The forwarded events are test-mode; never point test tooling at live data. Sandbox hygiene is a topic in its own right — see sandbox vs production for app subscriptions: how to test safely.
When you move to production, register the real endpoint in the Dashboard (or via the API), subscribe it to the same event types, and use that endpoint's signing secret — not the CLI's. The CLI secret only works for the CLI tunnel.
Mapping Stripe products to entitlements
Webhooks tell you a subscription exists and what state it is in. The remaining job is translating "this customer is on price_123, status active" into "this user has the pro entitlement" — because your app checks entitlements, not Stripe Price IDs. That translation is a mapping you own, and keeping it explicit is what stops your access logic from turning into a tangle of hard-coded Price IDs scattered across the codebase.
Define the mapping once, from Stripe Price (or Product) IDs to your entitlements:
// One place that maps Stripe Prices to your app's entitlements.
const PRICE_TO_ENTITLEMENT = {
price_monthly_pro: "pro",
price_annual_pro: "pro",
price_monthly_team: "team",
price_annual_team: "team",
};
function entitlementForPrice(priceId) {
const entitlement = PRICE_TO_ENTITLEMENT[priceId];
if (!entitlement) {
// A price with no mapping is a config error worth alerting on,
// not a silent grant of nothing.
console.warn(`No entitlement mapped for price ${priceId}`);
}
return entitlement ?? null;
}
Then, on every customer.subscription.created and customer.subscription.updated, compute the entitlement and store it in your own database keyed by the Stripe customer id, alongside the status and the period end. Your app reads that record to decide access — it never calls Stripe on the hot path. This is what "products vs entitlements" means in practice, and it is worth understanding as a concept, not just a code pattern; we unpack it in what is an entitlement in app subscriptions and the mobile-specific mechanics in how to map Stripe products to mobile app entitlements.
The reason to keep this mapping in one file is change management. When you launch a new plan, add a Price and one line to the map. When you rename an entitlement, you change it in one place. When a customer disputes access, you can read the mapping and the stored record and explain exactly why they have or don't have pro — which is the difference between a support ticket you can answer and one you can't.
Connecting Stripe web subscriptions to app access
For a web-only product, the entitlement record you build from webhooks is the access check — the same server that receives the webhook serves the app. It gets more interesting when the subscription is bought on the web but consumed in a native app, because now the entitlement has to travel across surfaces: a customer who subscribes on your website should unlock pro in your iOS and Android apps too.
The mechanism is the same entitlement record, exposed to every client. The webhook updates the record; each app checks it (through your API, with the user authenticated) rather than talking to Stripe directly. The subtleties — how to authenticate the app user against the Stripe customer, how to handle a subscriber who has the app but never signed in on the web, how the App Store's rules interact with a web purchase — are exactly the kind of thing that turns a "quick integration" into a fortnight. We wrote a focused walkthrough on the iOS case in how to connect Stripe web subscriptions to iOS app access, and the broader architecture of a cross-rail stack in how to create a subscription app stack: Stripe, App Store, analytics, entitlements.
The deeper point is that Stripe is one payment rail, and a real app usually has more than one — the App Store, Google Play, sometimes all three. Each rail has its own webhooks (Apple's App Store Server Notifications, Google's Real-time Developer Notifications) with their own formats, their own verification, and their own lifecycle quirks. If any of that is new, what is a payment rail in app monetization is the primer. The engineering reality is that "handle Stripe webhooks" is one third of "keep entitlements correct across every rail," and the other two thirds look similar but are not identical.
Failed payments and billing retry (dunning)
Failed payments are where a naive webhook integration quietly loses money, because the instinct — "payment failed, revoke access" — is usually wrong. When a renewal charge fails, Stripe does not immediately end the subscription. It moves it to past_due and runs billing retries (dunning): it re-attempts the charge on a schedule you configure, because most failed cards are temporary — an expired card, a hit limit, a bank decline that clears on the next try. Cutting access on the first failure churns customers who were about to pay anyway.
The correct flow, driven entirely by events:
invoice.payment_failedarrives. Flag the account at-risk, and prompt the customer to update their payment method (Stripe can email them, or you can, or you can surface it in-app). Decide your grace policy: many products keep access on duringpast_dueand only restrict it if retries exhaust.- Retries run. Each retry can succeed or fail. On success you receive
invoice.paidand acustomer.subscription.updatedback toactive— restore full standing. On continued failure you keep getting failure signals. - Retries exhaust. Per your Stripe settings, the subscription becomes
canceledorunpaid, andcustomer.subscription.updated(ordeleted) tells you. Now revoke the entitlement.
The rule from earlier applies with full force here: drive access off customer.subscription.updated status, not off the raw failure event. The failure event is a prompt to act on the relationship (nudge the customer); the status transition is what changes access. Getting this loop right is one of the highest-leverage things a subscription business can do, because recovered involuntary churn is revenue you already earned — we go deep on instrumenting it in how to monitor failed payments and billing retry in your app.
Webhook security: the non-negotiables
Your webhook endpoint is a public URL that grants access and moves money, so it is worth being deliberate about security. None of this is exotic — it is a short list of things that must always be true:
- Always verify the signature. Never act on an event you have not passed through
constructEvent(orconstructEventAsync). An unverified body is attacker-controlled input — treating it as trusted lets anyone grant themselvesproby POSTing a fakecheckout.session.completed. - Serve the endpoint over HTTPS. Stripe requires
https://for live endpoints. Plain HTTP exposes the payload and signature in transit. - Keep the endpoint secret out of source control. The
whsec_…secret belongs in an environment variable or secret manager, never in a committed file. Rotate it if it leaks (the Dashboard lets you roll it). - Rely on the built-in timestamp tolerance for replay protection.
constructEvent's default 300-second tolerance rejects captured-and-replayed requests. Don't disable it without reason. - Return 400 on verification failure. A forged or altered request should be rejected, not retried and not processed. Only legitimate transient errors deserve a 5xx.
- Don't over-trust the event payload for security-critical state. For high-stakes decisions, re-fetch the object from the API by id so you act on Stripe's authoritative record rather than a payload that could be stale or reordered.
- Scope what the endpoint can do. The handler should update entitlements and records — not be a general-purpose command runner. The narrower its powers, the less a bug or a bypass can cost you.
Do these seven and the endpoint is bank-grade in the ways that matter: only real Stripe events get through, they can't be replayed, and the secret that makes it all work stays secret. If you are building this so you don't have to trust a third party with purchase validation at all, that trade-off is worth reading about in how to validate app store purchases without building subscription infrastructure.
A complete copy-paste Stripe webhook example
Here is a single file that puts the whole guide together: raw-body verification, idempotency on the event id, the subscription events that matter, Price-to-entitlement mapping, and a fast 2xx acknowledgement. It is deliberately dependency-light (Express plus the Stripe SDK) so you can read every line. In production you would move processEvent behind a queue, as shown in the retries section, and back seen with a database uniqueness constraint rather than an in-memory set.
const express = require("express");
const Stripe = require("stripe");
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const app = express();
// --- Price -> entitlement mapping (your single source of truth) ---
const PRICE_TO_ENTITLEMENT = {
price_monthly_pro: "pro",
price_annual_pro: "pro",
price_monthly_team: "team",
};
const entitlementForPrice = (id) => PRICE_TO_ENTITLEMENT[id] ?? null;
const grants = (status) => status === "active" || status === "trialing";
// --- Idempotency (use a DB unique constraint in production) ---
const seen = new Set();
async function processEvent(event) {
switch (event.type) {
case "checkout.session.completed": {
const s = event.data.object;
if (s.mode !== "subscription") return;
await linkStripeToUser(s.client_reference_id, s.customer, s.subscription);
break;
}
case "customer.subscription.created":
case "customer.subscription.updated": {
const sub = event.data.object;
const entitlement = entitlementForPrice(sub.items.data[0].price.id);
await setEntitlement(sub.customer, {
entitlement,
active: grants(sub.status),
status: sub.status,
currentPeriodEnd: sub.current_period_end,
});
break;
}
case "customer.subscription.deleted": {
await revokeEntitlement(event.data.object.customer);
break;
}
case "invoice.paid": {
const inv = event.data.object;
await recordPayment(inv.customer, inv.amount_paid, inv.currency);
break;
}
case "invoice.payment_failed": {
await flagAtRisk(event.data.object.customer);
break;
}
default:
// Ignore events you didn't subscribe to handling.
break;
}
}
app.post(
"/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
endpointSecret
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Acknowledge FAST so Stripe never retries for a timeout.
res.json({ received: true });
// Dedupe on the event id, then process. (Move behind a queue at scale.)
if (seen.has(event.id)) return;
seen.add(event.id);
try {
await processEvent(event);
} catch (err) {
console.error(`Failed to process ${event.id} (${event.type})`, err);
// Re-queue for your own retry; Stripe already got its 2xx.
}
}
);
// Parse JSON for the rest of the API AFTER the raw webhook route.
app.use(express.json());
app.listen(4242, () => console.log("Webhooks on :4242"));
That is a genuinely production-shaped skeleton: it verifies, acknowledges fast, dedupes, and drives entitlements off subscription status. Swap the placeholder functions (setEntitlement, recordPayment, and so on) for your own, back the dedupe set with a table, and move processEvent behind a worker, and you have a webhook pipeline that stays correct under retries, duplicates, and reordering.
Where Stripe webhooks get hard at scale
Everything above is the correct single-rail integration, and for a web-only product on Stripe alone, it is genuinely enough. The difficulty is not any one webhook — it is that a real subscription business accumulates rails and surfaces, and each one multiplies the same work with just enough difference to prevent reuse. This is worth naming honestly, because it is where teams underestimate the ongoing cost.
- More rails, more webhooks. Add the App Store and Google Play and you now maintain three webhook receivers — Stripe events, Apple's App Store Server Notifications, Google's Real-time Developer Notifications — each with a different payload, a different verification scheme, and a different lifecycle vocabulary for the same concepts (renewal, grace, refund, cancellation).
- One entitlement, many sources. A customer might have
profrom Stripe on the web and a lapsed App Store subscription on their phone. Deciding the single true entitlement across rails — and keeping it correct as events arrive out of order, from different systems, at different times — is its own hard problem. - Reconciliation. A missed or dropped webhook leaves access or revenue wrong until something notices. At scale you need a reconcile pass that periodically checks your entitlements against each rail's source of truth, because "we handle the webhook" is not the same as "we are never out of sync."
- Observability. When a paying customer says they don't have access, you need to see the subscription, the events, the entitlement, and any error on one timeline — not spelunk three dashboards.
This is the problem Crossdeck exists to remove. Crossdeck gives you verified subscriptions and entitlements across Stripe, Apple, and Google — one customer timeline, one entitlement model, reconciled across every rail — so you don't hand-wire and maintain every webhook and every rail yourself. The webhook handling in this guide is exactly the kind of thing it does once, correctly, for all three rails, with idempotency and reconciliation built in rather than reimplemented per team. If you are on Stripe today and can see the App Store and Play Store coming, that is the point to stop building rail plumbing and start building your product. You can wire the Stripe rail in the Stripe rail guide or start free and keep your existing checkout.
The Stripe webhook checklist
Everything above, compressed to a checklist you can run against your own integration today:
- Register the webhook route with
express.raw({ type: "application/json" })— before any globalexpress.json(). - Verify every event with
stripe.webhooks.constructEvent(orconstructEventAsyncon edge runtimes); return400on failure. - Read the signature from the
Stripe-Signatureheader; keep thewhsec_…secret in an env var, not in code. - Subscribe your endpoint to only the events you handle:
checkout.session.completed,customer.subscription.*,invoice.paid,invoice.payment_failed. - Set
client_reference_id(ormetadata) on the Checkout Session so you can map Stripe's customer to your user. - Drive access off subscription
status(active/trialinggrant), not off raw money events. - Maintain one Price-ID → entitlement mapping; store the entitlement in your own database keyed by customer id.
- Return
2xxquickly; move real work to a queue or background job. - Dedupe on the event
idwith a unique constraint so duplicate deliveries are no-ops. - Don't trust delivery order — re-fetch the object by id, or compare timestamps, for state-critical decisions.
- On
invoice.payment_failed, flag at-risk and let dunning run; revoke only when status becomescanceled/unpaid. - Test locally with
stripe listen+stripe trigger, including the duplicate and failure paths. - Serve over HTTPS; keep test and live secrets strictly separate.
Frequently asked questions
What is a Stripe webhook?
A Stripe webhook is an HTTP POST that Stripe sends to a URL you own whenever something happens in your account — a checkout completes, a subscription renews, an invoice fails. The body is an Event object with a type (like invoice.paid) and a data.object (the subscription, invoice, or session it concerns). Your server verifies the signature, reads the event, and updates access or records. Webhooks are how your backend learns about asynchronous billing events it did not directly cause.
Why do subscriptions need webhooks?
Because subscription lifecycle events happen on Stripe's clock, not yours — a renewal charges monthly, a card fails on day 40, a customer cancels from the billing portal. Your server is not in the request path for any of these, so the only way to keep app access in sync with what the customer has actually paid for is to listen for the webhook Stripe sends when each event occurs. Without webhooks, a customer whose payment failed keeps access, and a customer who renewed can be locked out.
How do I verify a Stripe webhook signature in Node?
Call stripe.webhooks.constructEvent(payload, signature, endpointSecret). The payload must be the raw, unparsed request body — the exact bytes Stripe signed — so mount express.raw({ type: "application/json" }) on the webhook route instead of express.json(). The signature comes from the Stripe-Signature header, and the endpoint secret (whsec_…) is shown in the Dashboard or printed by the Stripe CLI. If the signature is invalid or the payload was altered, constructEvent throws and you return HTTP 400.
Why does my Stripe webhook signature verification fail?
Almost always because the body was parsed before verification. If express.json() (or any body parser) runs first, req.body is a JavaScript object, not the raw bytes Stripe signed, and the signature will never match. Fix it by registering the webhook route with express.raw({ type: "application/json" }) before any global JSON middleware. Other causes: using the wrong endpoint secret (test vs live, or the CLI secret against a Dashboard endpoint), or a proxy that rewrites the body.
Which Stripe events matter for subscriptions?
The core set is: checkout.session.completed (a purchase finished — provision access), customer.subscription.created and customer.subscription.updated (the subscription's status changed — sync the entitlement), customer.subscription.deleted (it ended — revoke access), invoice.paid (a renewal succeeded — extend access and record revenue), and invoice.payment_failed (a charge failed — flag the account and start dunning). Subscribe your endpoint to only the events you handle.
What is idempotency in Stripe webhooks and why does it matter?
Stripe guarantees at-least-once delivery, which means the same event can arrive more than once — on a retry, or occasionally in normal operation. Idempotency means processing a duplicate has no additional effect. Achieve it by treating the event id (evt_…) as a unique key: record each id as you process it, and skip any id you have already seen. Without this, a re-delivered invoice.paid could double-count revenue or send a second welcome email.
Does Stripe retry failed webhooks?
Yes. If your endpoint does not return a 2xx status, Stripe retries delivery with exponential backoff — in live mode for up to about three days. This is why your handler must return a 2xx as soon as it has safely received the event, and do slow work asynchronously: if you fail or time out, Stripe keeps re-sending, and every retry replays the same event. Return 2xx to acknowledge; return a non-2xx only when you genuinely want a retry.
Why should a Stripe webhook return 2xx quickly?
Stripe expects a prompt response and treats a slow or failed endpoint as undelivered, then retries. If you run heavy work — provisioning, emails, third-party calls — before responding, you risk timing out and triggering duplicate deliveries. Best practice: verify the signature, acknowledge with a 2xx immediately, and process the event in a background job or after the response. Fast acknowledgement plus idempotent processing is the pattern that stays correct under retries.
How do I test Stripe webhooks locally?
Use the Stripe CLI. Run stripe login, then stripe listen --forward-to localhost:4242/webhook to tunnel account events to your local server; the CLI prints a webhook signing secret (whsec_…) to use as your endpoint secret. Then fire test events with stripe trigger checkout.session.completed or stripe trigger invoice.payment_failed. This exercises real signature verification and your handler without deploying or using ngrok.
How do I map Stripe products to app access?
Keep a mapping from Stripe Price (or Product) IDs to your app's entitlements — for example price_123 unlocks pro. When a customer.subscription.created or customer.subscription.updated event arrives, read subscription.items.data[0].price.id and subscription.status, translate the price to an entitlement, and grant it when the status is active or trialing. Store the mapping in your own database keyed by the Stripe customer id so any surface — web, iOS, Android — can check the same entitlement.
Can Stripe webhooks arrive out of order?
Yes. Stripe does not guarantee that events are delivered in the order they occurred, so a customer.subscription.updated can arrive before the created event, or a stale event can land after a newer one. Do not treat the event as the final word on state. When precise current state matters, re-fetch the object from the Stripe API by id, or compare timestamps, so an out-of-order delivery cannot leave your entitlement wrong.
How do I keep app access in sync when a payment fails?
Listen for invoice.payment_failed. When it arrives, the subscription typically moves to past_due and Stripe begins its billing retry (dunning) schedule. Flag the account as at-risk and prompt the customer to update their card, but decide deliberately whether to revoke access immediately or during the grace period. If retries succeed you receive invoice.paid; if they exhaust, the subscription becomes canceled or unpaid and you revoke the entitlement. Drive access off customer.subscription.updated status rather than guessing.
Stop hand-wiring every webhook and rail
Crossdeck gives you verified subscriptions and entitlements across Stripe, Apple, and Google — one customer timeline, one entitlement model, reconciled across every rail — so the webhook handling in this guide is done once, correctly, for all three. Keep your Stripe checkout; add the rails when you're ready.