Claim once, mutate once
The webhook idempotency pattern, how a processed_events ledger and a single transaction make a Stripe handler process each event exactly once despite retries, concurrency, and crashes.
Last lesson ended at a single line: the signature passed, and your handler now trusts the event. But trust isn’t the same as being done. A verified event tells you the payload genuinely came from Stripe. It tells you nothing about whether you’ve already seen this exact event before.
And you will see it again. Stripe’s delivery contract is at-least-once: it keeps re-sending a webhook until it gets a fast 2xx back. So the same event arrives more than once for ordinary reasons. Your handler was a little slow, so Stripe timed out and retried while the first attempt was still running. Or your handler succeeded but the 200 got lost on the wire, so Stripe never heard the acknowledgement and retried. Or your handler crashed halfway through and Stripe retried. Three different stories with one shared ending: a checkout.session.completed lands twice. Process it twice and you’ve provisioned a customer twice, or in a billing-credit handler, credited their account twice. That’s real money walking out the door because of a duplicate you treated as new.
So the question this lesson answers is: how does the handler process each event exactly once, even under retries, concurrent deliveries, and crashes mid-work? The whole answer fits in one sentence, and the rest of the lesson unpacks it. Claim the event in a ledger row and do the business work in the same transaction, so the receipt and the effect commit together, or not at all. By the end you’ll have extended last lesson’s handler with a processed_events ledger, an atomic claim, and the full verify → claim → mutate → 200 scaffold that the rest of this chapter and the project are built on.
At-least-once is the contract, not the exception
Section titled “At-least-once is the contract, not the exception”Before any mechanism, look at the threat model. Every piece of code below earns its place by defending against something specific, so it helps to see the threat first.
Here’s the contract, stated plainly. Stripe guarantees at-least-once delivery , not exactly-once. If your endpoint doesn’t return a 2xx quickly, Stripe assumes the delivery failed and schedules a retry, backing off over hours and days. So the same event.id can reach your handler two, three, more times. This isn’t a rare glitch; it’s the documented, designed behavior of the system.
It’s worth making the three “arrives twice” stories concrete, because it’s tempting to file this under “edge case” and move on, and it isn’t an edge case:
- The slow handler. Your handler takes too long. Stripe gives up waiting, marks the delivery failed, and retries while the first attempt is still running. Now two copies of the same event execute at the same time, on two different server instances.
- The lost acknowledgement. Your handler finishes the work perfectly and returns
200. But the response is lost in transit, perhaps a dropped connection or a load-balancer hiccup. Stripe never hears the200, concludes the delivery failed, and retries an event you’ve already fully processed. - The crash. Your handler starts the work and dies partway, from an unhandled exception or a deploy that kills the instance mid-request. Stripe retries.
Each is a different interleaving in time, but they all demand the same defense. Here’s the idea to carry through the whole lesson: idempotency is a property you engineer, not a guarantee the sender hands you. Stripe promises the message arrives at least once. Turning that into exactly once is your job, and the place you do it is this boundary. (Idempotency is the word for that property, and it’s going to come up a lot.)
This shape, “the same thing could happen twice, defend against it at the boundary,” isn’t unique to webhooks. It generalizes to Server Actions, background jobs, and public APIs, and a couple of lessons from now in One pattern, four surfaces you’ll see it do exactly that. For now we make it concrete for the one case in front of us.
The diagram below shows the whole problem in one picture: a single event fanning out into the handler several times, with all of those arrivals needing to converge on one outcome.
One event, many arrivals (slow handler, lost 200, crash), all hitting the same gate. The input is plural; the desired effect is singular. Closing that gap is the whole lesson.
The dedup ledger: one row per event ever seen
Section titled “The dedup ledger: one row per event ever seen”To recognize a duplicate, you need a memory of what you’ve already handled. That memory is a table, processed_events: one row per event you’ve finished, written the first time you see it and consulted forever after. Build the table first, so the claim has somewhere to land.
Here’s the Drizzle schema. It’s short, but every column is a decision, so let’s walk through them one at a time.
This is production schema, so it follows the course’s casing: 'snake_case' client convention, which means camelCase keys and auto snake-case SQL. It spells out explicit SQL names only where doing so documents an external mapping.
export const processedEvents = pgTable( 'processed_events', { id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(), provider: text().notNull(), eventId: text('event_id').notNull(), eventType: text('event_type').notNull(), receivedAt: timestamp({ withTimezone: true }).defaultNow().notNull(), }, (t) => [ unique('processed_events_provider_event_id_unique').on( t.provider, t.eventId, ), ],);The table and its surrogate key. The identity bigint id exists only so .returning() has a cheap column to hand back later. No outsider ever sees it, so it’s the internal-key case from the previous chapter, not a UUID. It is not the dedup key.
export const processedEvents = pgTable( 'processed_events', { id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(), provider: text().notNull(), eventId: text('event_id').notNull(), eventType: text('event_type').notNull(), receivedAt: timestamp({ withTimezone: true }).defaultNow().notNull(), }, (t) => [ unique('processed_events_provider_event_id_unique').on( t.provider, t.eventId, ), ],);provider. 'stripe', 'resend', any future sender. One table serves all of them.
export const processedEvents = pgTable( 'processed_events', { id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(), provider: text().notNull(), eventId: text('event_id').notNull(), eventType: text('event_type').notNull(), receivedAt: timestamp({ withTimezone: true }).defaultNow().notNull(), }, (t) => [ unique('processed_events_provider_event_id_unique').on( t.provider, t.eventId, ), ],);eventId. The sender’s own ID, event.id for Stripe. The explicit 'event_id' documents the SQL name right at the call site, using the per-column escape hatch.
export const processedEvents = pgTable( 'processed_events', { id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(), provider: text().notNull(), eventId: text('event_id').notNull(), eventType: text('event_type').notNull(), receivedAt: timestamp({ withTimezone: true }).defaultNow().notNull(), }, (t) => [ unique('processed_events_provider_event_id_unique').on( t.provider, t.eventId, ), ],);eventType. Stored for observability only: “what kind of event was this?” It is deliberately not part of the dedup key; only (provider, eventId) decides identity.
export const processedEvents = pgTable( 'processed_events', { id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(), provider: text().notNull(), eventId: text('event_id').notNull(), eventType: text('event_type').notNull(), receivedAt: timestamp({ withTimezone: true }).defaultNow().notNull(), }, (t) => [ unique('processed_events_provider_event_id_unique').on( t.provider, t.eventId, ), ],);receivedAt. When we claimed it, via defaultNow(). This timestamp is what a scheduled retention sweep later prunes against.
export const processedEvents = pgTable( 'processed_events', { id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(), provider: text().notNull(), eventId: text('event_id').notNull(), eventType: text('event_type').notNull(), receivedAt: timestamp({ withTimezone: true }).defaultNow().notNull(), }, (t) => [ unique('processed_events_provider_event_id_unique').on( t.provider, t.eventId, ), ],);The composite unique constraint. This one constraint is the entire dedup guarantee. Postgres will physically refuse a second row with the same (provider, eventId). Everything else in the lesson leans on it.
Two decisions in that schema are worth saying out loud.
Why (provider, eventId) and not eventId alone. Stripe mints event IDs in its own namespace; Resend mints them in a completely separate one. Nothing stops the string evt_123 from existing in both. If your unique key were just eventId, a Resend event could collide with a Stripe event that happens to share an ID, and one would silently mask the other, making you skip an event you never actually processed. The composite unique constraint on (provider, eventId) gives every provider its own collision-free space inside one shared table. This is the same instinct the course applies to unique constraints everywhere: scope the key to the thing that actually defines identity, never a fragment of it.
The table is append-only. There are no in-place updates and no deletes, except a scheduled retention sweep we’ll mention at the end. This isn’t mutable state you reconcile; it’s a log of receipts. Once you’ve written “I handled this event,” that fact is permanent, until it ages out of the retention window. That append-only shape is exactly what lets the constraint be the source of truth: a row’s existence is the answer to “have I seen this?”
Why select-then-insert is broken
Section titled “Why select-then-insert is broken”Here’s the obvious approach, and watch it fail. Seeing exactly where it breaks is the whole point of this section.
If you wanted to skip duplicates, the natural shape is “check, then act”: SELECT from processed_events for this (provider, eventId); if a row already exists, you’ve seen it, so skip; otherwise do the work and INSERT the receipt. It reads like plain English. Here it is next to the version we’re about to build toward. Start on the “Broken” tab.
const existing = await tx.query.processedEvents.findFirst({ where: and( eq(processedEvents.provider, 'stripe'), eq(processedEvents.eventId, event.id), ),});if (existing) return;
await onCheckoutCompleted(tx, event);await tx.insert(processedEvents).values({ provider: 'stripe', eventId: event.id, eventType: event.type,});Reads like plain English, and races. The check and the write are two separate moments. Between “I looked and saw nothing” and “I wrote the receipt,” a concurrent retry runs the same check and also sees nothing. Both proceed.
const claimed = await tx .insert(processedEvents) .values({ provider: 'stripe', eventId: event.id, eventType: event.type }) .onConflictDoNothing({ target: [processedEvents.provider, processedEvents.eventId], }) .returning({ id: processedEvents.id });
if (claimed.length === 0) return;
await onCheckoutCompleted(tx, event);One statement, atomic. Check and claim happen in a single insert the database serializes for you. claimed.length === 0 means someone already holds this event, so you short-circuit. The next section explains exactly why this closes the race.
The broken version looks fine until two copies run at once, so let’s run two copies at once. The timeline below steps through two workers, A and B, both handling the same event.id because Stripe retried while the first was still in flight. Scrub through it.
What you just watched has a name: a time-of-check-to-time-of-use race, TOCTOU for short. The fix is never “check faster,” because no speed eliminates the gap; the gap is structural, not slow. It’s also never “add a lock in application code.” The fix is to collapse the check and the claim into one atomic statement the database serializes for you. That’s the next section.
There’s a natural objection to clear up before we move on: “It’s one database, so surely it can’t actually run both of those at the same time?” It can, and it does. The two SELECTs arrive on different connections, from different requests, possibly on different server instances. And read committed, Postgres’s default isolation level that you met when you learned transactions, means Worker B genuinely cannot see Worker A’s uncommitted INSERT. So B’s check honestly returns “nothing here,” because from B’s vantage point there is nothing there yet. The isolation level isn’t the bug, and cranking it up to serializable isn’t the fix. The unique constraint is.
INSERT … ON CONFLICT DO NOTHING RETURNING: check and claim in one shot
Section titled “INSERT … ON CONFLICT DO NOTHING RETURNING: check and claim in one shot”Here’s the fix in one sentence: collapse “check if seen” and “record as seen” into a single statement, INSERT ... ON CONFLICT (provider, event_id) DO NOTHING RETURNING id. You attempt to insert the receipt unconditionally. The database evaluates the insert against the unique constraint atomically: exactly one of the concurrent inserts wins the row, and every other one hits the conflict and quietly does nothing. There is no gap for a second worker to slip into, because there’s no longer a separate “check” step. The check is the insert.
Now the part that makes this usable: the signal. How does your handler know whether it was the winner or a loser? That’s what RETURNING is for. The insert returns one row if you won the claim (your insert is the one that happened) and zero rows if you lost it (the row already existed, so DO NOTHING did nothing, so there’s nothing to return). That row count is your answer. Keep this as your mental hook:
Won the claim (one row) → I own this event, I do the work. Lost the claim (zero rows) → someone already owns it, I stand down.
You already saw the Drizzle for this on the “Correct” tab above. The shape is onConflictDoNothing with the conflict target set to the two constraint columns, then .returning({ id }), then a branch on claimed.length. When claimed.length === 0, a concurrent or earlier handler beat you to it, so you short-circuit and let the handler return 200 (more on why 200 shortly).
If onConflictDoNothing and .returning() feel familiar, they should: this is the exact idempotent-insert shape you met with upserts back in the Drizzle chapter. The only difference is that here it’s in its production home, the seam where a duplicate event must be detected and discarded. Same tool, higher stakes.
The point worth holding onto runs through this whole chapter: the unique constraint does the concurrency work for you. No application-level lock, no serializable isolation, no SELECT ... FOR UPDATE. You declared a constraint, and the database now enforces “at most one of these wins” across every connection, every request, every instance, atomically. This is the constraint-first reflex from the transactions chapter, and webhook dedup is where it pays off most visibly: reach for a database constraint before you reach for application cleverness.
One nuance on the failure mode, since you’ll hit both forms eventually. With onConflictDoNothing, the loser gets zero rows and no error; the conflict is swallowed into a quiet signal. Contrast a bare insert with no ON CONFLICT clause: that one would raise on a duplicate, a Postgres unique-violation error (SQLSTATE 23505). DO NOTHING is precisely what converts that exception into a silent zero-row result you can branch on. When you can’t pre-empt the conflict with DO NOTHING and have to detect it from a thrown error instead, that’s the isUniqueViolation helper from the transactions chapter. Here, because we’re claiming on purpose, DO NOTHING is the cleaner tool.
The claim and the work are one transaction
Section titled “The claim and the work are one transaction”The atomic claim solves the race. But there’s a second, subtler way to get this wrong, and it’s the one that quietly corrupts data in production, so let’s break the next obvious-looking shape too.
Suppose you claim the event first, return early if you lost the claim, and then do the business work afterward. The control flow looks clean. Now trace the failure: the claim INSERT commits successfully, and then the business mutation fails, from a transient database error, a bug, a crash, anything. What’s the state of the world now? The event is marked processed in your ledger, but its effect never happened. The subscription wasn’t updated, the entitlement wasn’t granted. And here’s the part that makes it permanent: Stripe retries, your handler runs again, the claim sees the existing row, concludes “already handled,” and short-circuits to 200. The retry that should have healed the failure instead skips it, because the receipt is lying. No future retry will ever fix it. You’ve recorded success for work that never completed.
The fix is structural and small: put the claim INSERT and the business mutation inside one db.transaction(async (tx) => …). Now the receipt and the effect share a single commit boundary. Either both land or neither does:
- Everything succeeds → the claim and the effect commit together. Event recorded, event applied. Correct.
- Anything throws → both roll back. The claim row vanishes as if it was never written. Stripe retries, the handler re-claims cleanly, and re-runs the work. Self-healing.
The difference between those two worlds is entirely about where the commit boundary sits. The two panels below put the crash-after-claim timeline side by side; flip between them.
Two disciplines make this work in practice. You already know both, but they’re restated here because this is the seam where they matter most.
Thread tx, never db, through everything inside the closure. The claim insert and every business mutation must use the tx handle the transaction callback hands you, not the outer db. Reach for db inside the block and that statement runs on a different connection, outside the transaction, so it commits independently and your all-or-nothing guarantee silently breaks. This is the db-vs-tx rule from the transactions chapter, and the whole correctness argument above collapses if you violate it.
Let it throw. Inside the transaction, when something genuinely fails, you let the error propagate, because that thrown error is exactly what tells Postgres to roll back. You do not catch it and swallow it here. The outer handler will turn an uncaught throw into a 5xx (next section), which is precisely what you want: a 5xx tells Stripe to retry, and the retry is what heals the rolled-back event. This is the “throw the unexpected” half of the course’s error contract, used deliberately: the throw is the rollback trigger and the retry trigger at once.
What to return to Stripe (and why 200 on a duplicate)
Section titled “What to return to Stripe (and why 200 on a duplicate)”Status codes on a webhook aren’t cosmetic; Stripe reads them to decide whether to retry. So the handler’s response is part of its logic, and there’s one rule here that trips up almost everyone the first time.
The rule is: losing the claim returns 200, not a 4xx or a 5xx. A duplicate is not an error. There is simply nothing left to do, because the event was already handled, and “nothing to do” is a success, not a failure. This feels wrong, because a duplicate feels like something went off the rails. It didn’t. Let’s make the stakes concrete by walking the three responses you might be tempted to send:
5xxon a duplicate tells Stripe “I failed, retry me.” So Stripe retries an event you’ve already handled, gets another5xx, retries again, and keeps going until it exhausts its schedule. You’ve created a retry storm that accomplishes nothing but load. A5xxis the single worst thing to return for a duplicate.4xxon a duplicate tells Stripe “this request is terminally broken, stop.” That does halt the retries, so it’s not actively harmful the way5xxis. But it’s a lie, because the request was perfectly fine. And that lie pollutes your error dashboards and Stripe’s delivery stats with failures that never happened, so when something real breaks you’ll be hunting for it in a haystack of fake4xxs.200is the only honest answer: received, recognized as already-processed, done.
Here’s the complete status surface for the handler, in one place so you can hold all of it at once:
| Status | When |
| --- | --- |
| 200 | Claimed and processed just now: the happy path. |
| 200 | Lost the claim, already processed: the dedup short-circuit, a success, not an error. |
| 400 | Signature verification failed (from the previous lesson; problem+json body). |
| 5xx | A genuine server error only: the database is unreachable, an unhandled throw inside the transaction, a real bug. The only response that should make Stripe retry. |
The underlying rule generalizes, so it’s worth stating as a principle: never use 5xx as a soft “please retry” signal. Retry-worthiness is the dedup ledger’s job, not the status code’s. The status code reports what actually happened: processed, duplicate, malformed, broken. The ledger decides whether work happens at all. Keep those two responsibilities separate and the handler stays honest: a 5xx always means “something is genuinely wrong on my end,” never “I’d like you to send that again.”
One operational note, because webhook handlers are uniquely painful to debug. There’s no user watching, no UI to inspect, no stack trace surfaced to anyone, so when one misbehaves at 2am, the log is all you have. That’s why every path through the handler logs event.id plus its disposition: claimed, duplicate, or error. Route it through the per-seam child logger (logger.child({ seam: 'webhook.stripe' })) so every line is filterable to this one handler. The observability chapter goes deep on this; here it’s enough to know the rule: log the id and what you did with it, on every branch.
A quick check on the status reflexes before we move on, since this is the one that costs real money when it’s wrong.
A teammate sketches the handler’s response logic and asks you to sanity-check it against Stripe’s retry behavior. Select every line that is genuinely correct — for the right reason, not just plausible wording.
claimed.length === 0, the handler still commits and replies 200, treating the lost claim as a finished job rather than a fault.400 with a problem+json body, because the only sane move is to make Stripe stop resending an unforgeable request.5xx for the case where it actually couldn’t finish — an uncaught throw rolled the transaction back — so that the retry it triggers lands on real work to redo.409 Conflict is the clean way to tell Stripe the event is already handled and it can stop.500 for it is a harmless shortcut to that outcome.The three correct lines all keep the status code honest about what happened. A lost claim is a success path: the event was already processed, the transaction commits trivially, and 200 is the truthful “nothing left to do.” A failed signature is a bad request, so 400 (a 4xx) tells Stripe the request is terminally unacceptable and to stop retrying it. And 5xx is the only response that should provoke a retry — so it must be reserved for genuine failures (a throw inside the transaction that rolled everything back), where the retry actually re-claims and heals.
Both wrong lines misread the retry semantics. 409 on a duplicate is a lie dressed as precision — there is no conflict to report, the event was handled cleanly, so the truthful answer is 200, not a 4xx that pollutes your error stats. And 500 does the opposite of “skip”: a 5xx tells Stripe to retry, so returning it for a duplicate starts a retry storm over an event that’s already done.
The timing budget: do the minimum, queue the rest
Section titled “The timing budget: do the minimum, queue the rest”There’s a reason the handler can’t simply do everything a checkout.session.completed implies, like send the welcome email, recompute analytics, and ping a third-party CRM, all inline before returning 200. The reason is time, and it shapes the whole structure of a webhook handler.
Stripe waits only a short, bounded window for your 2xx before treating the delivery as failed and scheduling a retry. Stripe deliberately doesn’t publish that window as a fixed contractual number; the guidance is simply “return 2xx quickly, before any complex logic that could time out.” Treat it as on the order of a couple of seconds, short and not guaranteed. Do heavy work synchronously and you risk blowing past it: the delivery times out, Stripe reads that as failure and retries, the retry runs the same heavy work, and that retry may itself time out. It’s a doom loop built entirely out of doing too much before the 200.
So the discipline is: inside the transaction, do only the minimal database mutation that must be atomic with the claim. Everything else, the email, the analytics, the outbound API call, gets handed to the background job system and runs after the 200 has gone back to Stripe.
Notice that you’ve now arrived at this same rule from two completely different directions:
- Timing (this section): synchronous side-effects blow the short response budget and cause retries.
- Connection pool correctness (the transactions chapter): you must never
awaitexternal IO inside adb.transaction, because holding a pooled connection open across a slow network call starves every other request waiting for one.
Two different problems lead to one identical conclusion: database-only work inside the transaction, side-effects out. When two independent lines of reasoning converge on the same shape, that’s usually a sign the shape is right.
So the structure is: the transaction commits the state change, the 200 goes back, and then you enqueue the consequences. The background-jobs chapter is where you’ll actually build that enqueue step, with its own idempotency key, because jobs can run twice too. The takeaway to carry forward: the webhook’s synchronous job is to record-and-apply the state change; the consequences are someone else’s job.
A closing note on the ledger itself. processed_events only ever grows; every event you’ve ever seen leaves a permanent row. Left alone, it grows without bound. The answer is a scheduled retention sweep: a background job that deletes rows older than the longest retry window any provider could use, something like 30 to 90 days, comfortably past the point where a duplicate could still arrive. It’s a background delete, not a foreground one, and it’s the only delete this append-only table ever sees. The build of that sweep belongs to the background-jobs chapter; document the policy in a schema comment now so the next person knows the rows are meant to be pruned.
The full handler scaffold
Section titled “The full handler scaffold”Time to assemble everything into the reference shape, the one the rest of this chapter and the project extend. We pick up exactly where the previous lesson stopped: the signature has passed, and you’re holding a verified event: Stripe.Event.
The verify block is collapsed to a single call so the focus stays on the new dedup-and-transact surface; the previous lesson’s inline try { constructEvent } catch { 400 } lives behind that verifyStripeEvent(request). The 400 signature-failure path belongs to that verify step; the 5xx path below is for failures inside the transaction.
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => { // verify signature on the raw body (previous lesson) — event: Stripe.Event const event = await verifyStripeEvent(request);
try { await db.transaction(async (tx) => { const claimed = await tx .insert(processedEvents) .values({ provider: 'stripe', eventId: event.id, eventType: event.type, }) .onConflictDoNothing({ target: [processedEvents.provider, processedEvents.eventId], }) .returning({ id: processedEvents.id });
if (claimed.length === 0) return;
switch (event.type) { case 'checkout.session.completed': await onCheckoutCompleted(tx, event); break; // subscription, invoice, and payment events handled here too } }); } catch { return new Response(null, { status: 500 }); }
return new Response(null, { status: 200 });};Start from the verified event. The previous lesson got us here, to a trusted Stripe.Event. Everything below assumes verification already ran.
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => { // verify signature on the raw body (previous lesson) — event: Stripe.Event const event = await verifyStripeEvent(request);
try { await db.transaction(async (tx) => { const claimed = await tx .insert(processedEvents) .values({ provider: 'stripe', eventId: event.id, eventType: event.type, }) .onConflictDoNothing({ target: [processedEvents.provider, processedEvents.eventId], }) .returning({ id: processedEvents.id });
if (claimed.length === 0) return;
switch (event.type) { case 'checkout.session.completed': await onCheckoutCompleted(tx, event); break; // subscription, invoice, and payment events handled here too } }); } catch { return new Response(null, { status: 500 }); }
return new Response(null, { status: 200 });};Open one transaction. The claim and the business work share this one commit boundary. We thread tx, never db, from here down.
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => { // verify signature on the raw body (previous lesson) — event: Stripe.Event const event = await verifyStripeEvent(request);
try { await db.transaction(async (tx) => { const claimed = await tx .insert(processedEvents) .values({ provider: 'stripe', eventId: event.id, eventType: event.type, }) .onConflictDoNothing({ target: [processedEvents.provider, processedEvents.eventId], }) .returning({ id: processedEvents.id });
if (claimed.length === 0) return;
switch (event.type) { case 'checkout.session.completed': await onCheckoutCompleted(tx, event); break; // subscription, invoice, and payment events handled here too } }); } catch { return new Response(null, { status: 500 }); }
return new Response(null, { status: 200 });};The atomic claim. Check-and-claim in one statement; .returning({ id }) hands back one row if we won, zero if we lost.
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => { // verify signature on the raw body (previous lesson) — event: Stripe.Event const event = await verifyStripeEvent(request);
try { await db.transaction(async (tx) => { const claimed = await tx .insert(processedEvents) .values({ provider: 'stripe', eventId: event.id, eventType: event.type, }) .onConflictDoNothing({ target: [processedEvents.provider, processedEvents.eventId], }) .returning({ id: processedEvents.id });
if (claimed.length === 0) return;
switch (event.type) { case 'checkout.session.completed': await onCheckoutCompleted(tx, event); break; // subscription, invoice, and payment events handled here too } }); } catch { return new Response(null, { status: 500 }); }
return new Response(null, { status: 200 });};Duplicate path. We lost the claim, so we return out of the callback. The transaction commits trivially, nothing changed, and the handler will 200.
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => { // verify signature on the raw body (previous lesson) — event: Stripe.Event const event = await verifyStripeEvent(request);
try { await db.transaction(async (tx) => { const claimed = await tx .insert(processedEvents) .values({ provider: 'stripe', eventId: event.id, eventType: event.type, }) .onConflictDoNothing({ target: [processedEvents.provider, processedEvents.eventId], }) .returning({ id: processedEvents.id });
if (claimed.length === 0) return;
switch (event.type) { case 'checkout.session.completed': await onCheckoutCompleted(tx, event); break; // subscription, invoice, and payment events handled here too } }); } catch { return new Response(null, { status: 500 }); }
return new Response(null, { status: 200 });};Dispatch to the typed handler. Route each event type to its handler, all sharing tx. The bodies are stubs here; concrete Stripe handling is the next chapter and the project.
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => { // verify signature on the raw body (previous lesson) — event: Stripe.Event const event = await verifyStripeEvent(request);
try { await db.transaction(async (tx) => { const claimed = await tx .insert(processedEvents) .values({ provider: 'stripe', eventId: event.id, eventType: event.type, }) .onConflictDoNothing({ target: [processedEvents.provider, processedEvents.eventId], }) .returning({ id: processedEvents.id });
if (claimed.length === 0) return;
switch (event.type) { case 'checkout.session.completed': await onCheckoutCompleted(tx, event); break; // subscription, invoice, and payment events handled here too } }); } catch { return new Response(null, { status: 500 }); }
return new Response(null, { status: 200 });};One 200 for both paths. Processed-now and duplicate both land here. A 200 after a clean commit is the truth in both cases.
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => { // verify signature on the raw body (previous lesson) — event: Stripe.Event const event = await verifyStripeEvent(request);
try { await db.transaction(async (tx) => { const claimed = await tx .insert(processedEvents) .values({ provider: 'stripe', eventId: event.id, eventType: event.type, }) .onConflictDoNothing({ target: [processedEvents.provider, processedEvents.eventId], }) .returning({ id: processedEvents.id });
if (claimed.length === 0) return;
switch (event.type) { case 'checkout.session.completed': await onCheckoutCompleted(tx, event); break; // subscription, invoice, and payment events handled here too } }); } catch { return new Response(null, { status: 500 }); }
return new Response(null, { status: 200 });};Genuine errors become 5xx. A throw inside the transaction rolled it back; the 5xx tells Stripe to retry, and the retry re-claims and heals. This is distinct from the verify step’s 400.
Read off the canonical ordering, because this is the takeaway to memorize: it’s the skeleton every remaining lesson in the chapter extends and the project ships:
verify → open transaction → claim → (lost it? return → 200) → mutate → commit → 200; genuine error → 5xx.
Two things are worth extracting as you build this out, so you see the portability coming. First, the claim is the same five lines for any provider, so it’s a natural helper, something like claimEvent(tx, provider, eventId, eventType) returning whether you won. Second, processedEvents and that helper are the exact seam you’ll reuse when this chapter gets to Resend webhooks: same ledger, same claim, different provider string. The pattern you just wrote isn’t Stripe-specific; it’s the dedup boundary for every webhook the app will ever take.
Now write the claim yourself and watch dedup actually work. The exercise below gives you a processedEvents table with one event already seeded: evt_existing, an event that’s already been claimed. Your job is to claim a fresh event (evt_new) with the atomic insert. Because nothing conflicts, your claim returns one row, meaning you won the claim, exactly as the handler does the first time it sees an event.
The seeded row is event evt_existing — already claimed. Claim the fresh event evt_new for provider stripe with an atomic insert that does nothing on conflict, and return the claimed id. Because nothing conflicts, your claim returns one row: you won it. Then try pointing both the id and eventId at the seeded evt_existing and re-run — you'll get zero rows back, the lost-claim path a duplicate webhook should take.
View schema & seed rows
export const processedEvents = pgTable(
'processed_events',
{
id: text('id').primaryKey(),
provider: text('provider').notNull(),
eventId: text('event_id').notNull(),
eventType: text('event_type').notNull(),
},
(t) => [
unique('processed_events_provider_event_id_unique').on(
t.provider,
t.eventId,
),
],
); INSERT INTO processed_events (id, provider, event_id, event_type) VALUES
('row_1', 'stripe', 'evt_existing', 'checkout.session.completed'); - Query returns the 1 expected row (any order)
Now flip it around: change both the id and the eventId to the seeded evt_existing, and run again. This time zero rows come back, meaning you lost the claim, because the unique constraint refused the duplicate and DO NOTHING swallowed it into an empty result. Those are the two outcomes the handler branches on, now in your own hands: a returned row means “I own this, do the work,” an empty result means “already handled, stand down.”
The full claim you just completed:
return await db .insert(processedEvents) .values({ id: 'row_2', provider: 'stripe', eventId: 'evt_new', eventType: 'checkout.session.completed', }) .onConflictDoNothing({ target: [processedEvents.provider, processedEvents.eventId], }) .returning({ id: processedEvents.id });Closing
Section titled “Closing”Hold onto the one idea this whole lesson was built around: the receipt and the effect are a single commit. A duplicate loses the claim and short-circuits to 200; a crash mid-handler rolls back both the claim and the work, and the retry heals itself cleanly. No application locks, no clever bookkeeping: one unique constraint and one transaction carry the entire guarantee.
There’s one thing this doesn’t cover, and it’s the next lesson’s job. Dedup protects you against the same event arriving twice. It says nothing about different events for the same entity arriving in the wrong order, like a subscription.updated from yesterday landing after today’s and overwriting newer state with stale. That’s Newer wins, single writer, and it’s where this skeleton grows its next guard.
External resources
Section titled “External resources”Four sources take the ideas in this lesson straight to their origins: the delivery contract, the idempotency philosophy, the Postgres statement that does the work, and a deep dive on doing all three together.
The delivery contract straight from the source: at-least-once delivery, the retry schedule, and the idempotency and fast-2xx best practices this lesson is built on.
Stripe's own engineering essay on why retries demand idempotency and how they store idempotency keys in Postgres — the philosophy behind the claim ledger.
The authoritative reference for INSERT ... ON CONFLICT DO NOTHING with a conflict target and RETURNING — the exact statement the atomic claim relies on.
A practitioner deep dive on doing exactly-once work in Postgres with transactions and atomic phases, including the foreign-call case this lesson defers to background jobs.