Stripe Webhooks: Why the Payment Lands but the Row Doesn't
Stripe says delivered, your database says nothing happened. Six places a directory loses payment state between the webhook and the listing row.

A business owner pays $15 to feature their listing, the card clears, Stripe shows a green Delivered next to the event, and the listing still sits there looking free. Nothing in that chain threw an error. The gap you are looking for is between the moment Stripe stopped retrying and the moment a row in your own database changed, and this walks through six specific places where a directory loses payment state in that gap, with the primary-source rule behind each one.
Payment state in a directory is unusually visible. A failed charge in a SaaS app means a locked dashboard that one person sees. A failed webhook in a directory means a listing that stays unfeatured on a public page, a lead subscription that quietly keeps delivering leads after the card expired, or a promotion slot that shows for a month nobody paid for. The money and the page are the same object. Your customers see both.
"Delivered" only means Stripe stopped asking
Stripe's own troubleshooting table is the fastest diagnostic you have, and most of its rows describe things that happen before your handler runs at all. The one people trip over reads: "The destination server attempted to redirect the request to another location. We consider redirect responses to webhook requests as failures."
That is a 3xx. Your Next.js app produces those constantly and for good reasons. A trailingSlash setting, an apex-to-www rule at your DNS provider, a locale prefix applied by proxy on a multilingual directory — any of them can catch POST /api/webhooks/stripe and turn it into a redirect that Stripe files under failed. The fix is boring. Register the URL that resolves, not the one you typed, and exclude the webhook path from anything that rewrites URLs.
Three more failures from the same table, all of them pre-handler:
4xxon a route that exists. Auth middleware matched the path, found no session, returned a401. Stripe never signs in.5xxfrom body parsing. If your framework touched the raw body, signature verification throws before your code sees an event. Stripe's wording leaves no room: "Stripe requires the raw body of the request to perform signature verification. If you're using a framework, make sure it doesn't manipulate the raw body."- CSRF rejection. Stripe names this explicitly as a thing to exempt the webhook route from, because a POST with no token is exactly what a CSRF guard is built to block.
In an App Router handler, reading await request.text() before you do anything else keeps the payload intact. That is the whole trick, and it stops working the moment a helper further up the stack parses JSON for you.
Worth doing once and forgetting: Stripe publishes the IP ranges it sends webhooks from, and recommends allowlisting them alongside signature verification rather than instead of it. Signature verification is the one that actually proves authorship. The allowlist just keeps noise off the route.
The 200 you return too early
Here is where the standard advice and the standard hosting platform disagree, and almost nobody writing about this says so.
Stripe is unambiguous about speed. Your endpoint "must quickly return a successful status code (2xx) before any complex logic that could cause a timeout." Taken literally on a serverless host, that means: respond 200, then keep working. Next.js gives you the tool, after(), which schedules a callback to run once the response is finished.
Read the duration note on that page carefully. "after will run for the platform's default or configured max duration of your route." And on Vercel, whose waitUntil primitive is what after sits on: "Promises passed to waitUntil() will have the same timeout as the function itself. If the function times out, the promises will be cancelled."
Follow that through. You returned 200, so Stripe marks the event delivered and stops retrying — permanently, because retries only happen on non-2xx. Your deferred write then gets cancelled at the function deadline. Stripe's records say the customer's premium listing is paid and live. Your database never heard about it. No retry is coming.
The shape that survives this puts the cheap durable write inside the request and the slow work after it:
// app/api/webhooks/stripe/route.ts
import { after } from 'next/server'
export async function POST(req: Request) {
const raw = await req.text() // never parse before this
const sig = req.headers.get('stripe-signature')!
let event
try {
event = stripe.webhooks.constructEvent(raw, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('bad signature', { status: 400 }) // 400, not 500: no retry wanted
}
// Durable and fast: one insert. If this throws, we return 5xx and Stripe retries.
const { error } = await db.from('stripe_events')
.insert({ id: event.id, type: event.type, payload: event })
if (error && error.code !== '23505') return new Response('store failed', { status: 500 })
if (error?.code === '23505') return new Response('duplicate', { status: 200 })
after(async () => { await applyEvent(event) }) // emails, Discord, cache purges
return new Response('ok', { status: 200 })
}The insert is one round trip to your own database. Everything that can be slow or flaky — the confirmation email, the Slack ping, the cache invalidation — moves behind after(), where a cancellation costs you a notification instead of a payment record. The 23505 check is Postgres telling you this event ID already exists, which is the answer you want on a retry.
Events arrive in the wrong order, and created will not save you
Stripe states this plainly: "Stripe doesn't guarantee the delivery of events in the order that they're generated." Creating a subscription can emit customer.subscription.created, invoice.created, invoice.paid and charge.created, and you may see them in any sequence.
The obvious workaround is to sort by the event's created timestamp and ignore anything older than what you have processed. Stripe closes that door in the same paragraph: "Snapshot events record created in seconds, so distinct events can share a timestamp. Don't use created to determine event order or whether you've already processed an event." So much for timestamps.
So a handler that treats each event as an instruction — this event says activate, therefore activate — is building a state machine on an unordered stream. On a directory that shows up as the sponsor slot that turns itself back on after cancellation, because customer.subscription.updated landed after customer.subscription.deleted.
Treat the event as a notification that something changed, not as the change itself. On any subscription event, re-read the subscription from the Stripe API and write what it currently says. Stripe suggests exactly this: "You can also use the API to retrieve any missing objects." One extra API call per event buys you an answer that does not depend on arrival order.
The setup guide for payments has you subscribe to four event types: checkout.session.completed, invoice.paid, customer.subscription.updated and customer.subscription.deleted. Only the first is a genuine one-shot fact. The other three are all views of one subscription object, and any of them should end with the same question asked of Stripe: what is the status of this subscription right now, and what does that entitle this listing to?
The ledger table, and the key that ignores every policy you wrote
Deduplication needs somewhere to live, and it needs to be the thing your writes hang off rather than a log you check afterwards.
A table with event_id as primary key does the whole job, because the uniqueness is enforced by Postgres instead of by your code remembering to look. Stripe's guidance is the same: "guard against duplicated event receipts by logging the event IDs you've processed, and then not processing already-logged events." Postgres remembers. Your handler might not. Note what the primary key must be: not the object ID, not a hash of the payload, and not the timestamp.
There is a second kind of duplicate that the event ID misses, and Stripe flags it in one sentence: "In some cases, two separate Event objects are generated and sent. To identify these duplicates, use the ID of the object in data.object along with the event.type." Two different event IDs, same underlying fact. A unique index on (type, object_id) alongside the primary key catches those:
create table stripe_events (
id text primary key, -- evt_… : catches redelivery
type text not null,
object_id text not null, -- data.object.id
payload jsonb not null,
processed_at timestamptz,
received_at timestamptz not null default now()
);
-- catches the two-Event-objects-for-one-fact case
create unique index stripe_events_fact_idx on stripe_events (type, object_id);Now the second problem with that write. Your webhook runs on the server with an elevated key, and on Supabase that key does something people underestimate. Per Supabase's API keys guide: the service_role role "has the BYPASSRLS attribute, so it skips every Row Level Security policy you attach." The careful access model you built for listing owners applies to nothing this route does.
Which is fine for stripe_events. It is not fine when the same client goes on to flip is_featured on a listing, because at that point the only thing standing between a bug and the wrong listing being upgraded is your where clause. Scope the privileged write to exactly the rows the event names, and derive the listing from the Stripe object rather than from anything a request body claims.
Two operational details from that page, both dated 10 September 2026 when we read it. Supabase "is deprecating the anon and service_role keys by the end of 2026" in favour of publishable and secret keys, so a webhook wired up with a long eyJ… string has a migration in its future. And grants are evaluated before RLS, which means "a missing grant returns a permission error, including for service_role." If your ledger insert fails with a permission error on a table you believe the service key owns, check the grant before you go looking at policies.
Run stripe trigger checkout.session.completed, then resend the same event from the Dashboard. If the listing gets featured twice, or a second confirmation email goes out, your ledger is decorative.
You have thirty days to notice, then the evidence is gone
Stripe retries a failed delivery "for up to three days with an exponential back off in live mode." Deployment broke the route on a Friday and you fixed it on Wednesday? Those events are done retrying.
They are still recoverable, on a clock. Stripe's guide to processing undelivered events has you list events with delivery_success=false and an ending_before cursor, then replay them through your own handler logic. The limit sits in one sentence: "Stripe only returns events created in the last 30 days." Manual resend from the Dashboard is tighter still at 15 days, and 30 days through the CLI. After that, nothing.
Two consequences worth building around. First, a monthly reconciliation job that compares Stripe subscriptions against your entitlement rows costs an afternoon and catches drift while it is still fixable. Second, the replay script must consult the same ledger your handler does. Stripe says so: "Stripe still considers your manually processed events as undelivered and continues to automatically retry them." Your backfill and Stripe's retry will race, and the event_id primary key is what makes that race harmless.
Where a webhook is the wrong tool
Some of this you should not be building at all. Three cases.
A one-time purchase with an immediate redirect does not need an event pipeline to feel finished. Reading the Checkout Session on your success page confirms payment for the user right away. You still want the webhook as the durable record, since a customer who closes the tab never loads that page.
Strict ordering is not something to solve in a route handler. If your billing logic genuinely cannot tolerate out-of-order events, Stripe will deliver to Amazon EventBridge or Azure Event Grid instead, and you get a real queue with real semantics. That is a better answer than a processed_at timestamp and hope.
Money moving between your users is a different product. Payouts, escrow and split settlement are years of work that Sharetribe has already done. Buy that, don't build it. Our own quotes and leads flow charges a business for access to leads; it does not settle a transaction between two of your users, and no amount of webhook care changes that.
And the honest note about a boilerplate: it ships the route, the config and the four event subscriptions, which is the part that takes an afternoon. The ledger table, the reconciliation job and the decision about what a cancelled subscription does to a listing that already has traffic are yours, because they depend on what you sell. Anyone claiming otherwise has not run a directory where a sponsor cancelled mid-month.
What to do before Friday
Four steps, in order, none of them longer than an hour:
- Open the event deliveries tab in Workbench and sort by failed. Every
3xxand4xxthere is a routing problem, not a code problem, and it is the cheapest thing on this list to fix. - Add the ledger table with
event_idas primary key, and make your handler's first durable action an insert into it. Return200on the duplicate-key error rather than treating it as a failure. - Resend one already-processed event from the Dashboard and watch what your directory does. A listing that gets featured twice or an email that goes out twice tells you the constraint is not wired to the code path you think it is.
- Write the reconciliation query that lists every active Stripe subscription with no matching entitlement row, and run it monthly. On a directory with a dozen sponsors it returns nothing for a year and then saves you a refund conversation.
If you want a sense of what this block costs before you commit to building it, the engineering estimate template treats payments as an obligation rather than a feature, which is the framing that makes the number survive contact. The same logic applies to handing a listing to its verified owner: both are places where a directory's data model meets somebody else's rules.