Skip to content
Chapter 65Lesson 3

Claim the event inside one transaction

The webhook stops trusting that it sees each event once and starts enforcing it: every verified delivery is claimed and worked inside one transaction, so a replay can never mutate twice and a failed handler leaves no half-claimed row.

Last lesson’s gate lets genuine events through but does nothing with them — a verified delivery returns a bare 200 and the processed_events panel stays empty. By the end of this lesson, firing stripe trigger checkout.session.completed lands exactly one row in the inspector’s processed_events tail (eventId, eventType, receivedAt) and the log shows verified then claimed. Press “Replay last event” and the same event.id returns, but the tail stays at one row, the log records duplicate, and the response is 200 with { received: true, duplicate: true }. The plan_entitlements panel stays free, because the handlers the dispatch switch routes to are still 'not implemented' stubs you fill next lesson. You build the boundary here, not the work inside it.

Stripe delivers the same event more than once. This is not an edge case but the documented contract of at-least-once delivery, and every webhook you write lives under it. The naive defense — “check whether we’ve seen this id, and if not, do the work” — breaks the moment the two steps drift apart. If the claim commits in one transaction and the work fails halfway in another, no retry can fix it: the next delivery sees “already processed” and skips, so the half-finished work is never completed and never undone. The fix is to make claim and work one step. They share a single db.transaction, so either both commit or neither does; a crash mid-handler rolls the claim back with the work, and the replay finds the id unclaimed and does it properly. Idempotency stops being a hope and becomes a guarantee of the transaction boundary.

That decision shapes every constraint that follows. claimEvent(tx, 'stripe', event.id, event.type) is the check-and-claim from chapter 063: it inserts the id under a unique(provider, eventId) constraint, returning true when the row is freshly yours and false when the constraint blocked it. Its first argument is the transaction handle, not the global db — pass tx to every database call in this seam. Route one call to the bare db and you open a sibling transaction that commits on its own and ignores the outer abort: the partial-state bug, reintroduced by one character. A lost claim (claimEvent returns false) is a success, not an error: answer 200 with { received: true, duplicate: true } and do no work. Never a 4xx or 5xx — a 4xx tells Stripe the delivery is terminal, a 5xx tells it to retry an already-handled event forever, and both are wrong answers to “I’ve already got this one.” The dispatch switch is exhaustive over the three subscription events the app acts on, and its default arm logs unhandled and returns a clean 200: a dashboard misconfiguration can send events the app never subscribed to, and refusing them is noise, not a client error. Log every disposition — the recorded outcome of handling a delivery — keyed by event.id: verified, duplicate, claimed, dispatched, and unhandled. That log is your forensic surface when something breaks at 2am.

One number shapes what comes next: Stripe waits about thirty seconds for a 2xx before it retries. Work inside the transaction holds a database connection open, so a network call in a handler ties up that connection while it waits on Stripe. The one allowed reach, a single subscriptions.retrieve, lands next lesson; anything heavier belongs in a background job . That budget is why the handlers stay thin.

Out of scope this lesson: the projection and the entitlement writes. The three handlers still throw 'not implemented' and the plan_entitlements panel will not move — that lands next lesson.

A first-seen verified event opens one db.transaction, then calls claimEvent and dispatch with that same transaction handle.
tested
A replay (claimEvent returns false) answers 200 with { received: true, duplicate: true }, does no work, and never returns a 4xx or 5xx.
tested
A first-seen delivery (claimEvent returns true) answers 200 with { received: true, duplicate: false } and routes through dispatch.
tested
dispatch routes checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted each to its own handler, and an unsubscribed event type hits default and returns without error.
tested
Firing stripe trigger checkout.session.completed once adds exactly one row to the processed_events tail (eventId, eventType, receivedAt), and the log shows verified then claimed.
untested
”Replay last event” re-delivers the same event.id, the tail stays at one row, and the disposition logs duplicate.
untested
Every disposition is logged with its event.id, and plan_entitlements stays free because the handlers are still stubs.
untested

Open src/app/api/webhooks/stripe/route.ts and src/lib/webhooks/stripe.ts, implement the transaction wrapper and the dispatch switch against the brief above and the lesson tests, then read the walkthrough below.

Reference solution and walkthrough

Two files change. The route handler gains a transaction around the post-verify path, and lib/webhooks/stripe.ts turns its placeholder dispatch into a real switch. Everything else — claimEvent, the processed_events table, the db.transaction shape, the Transaction type — ships in the starter, carried in from Claim once, mutate once.

The route: wrap the work in one transaction

Section titled “The route: wrap the work in one transaction”

The verification gate from the last lesson is untouched; only the tail changes. Before, a verified event got a bare 200. Now it is claimed and dispatched inside a single transaction, and the response carries a duplicate flag so the dedup outcome is visible to the caller.

log.info({ eventId: event.id, eventType: event.type }, 'verified');
return Response.json({ received: true }, { status: 200 });
};

A verified event is acknowledged and nothing is written. The gate lets the event through, then the route falls straight to a bare 200 — no claim, no dispatch, no row.

duplicate is declared with let outside the callback because it has to outlive it: the callback sets it, the Response.json after the await reads it. On a lost claim the callback flips it to true and returns early, so dispatch never runs; on a fresh claim it logs claimed and hands tx to dispatch. Either way the route reaches one response, and { received: true, duplicate } tells the caller which path it took.

The dispatch switch: route each event to its handler

Section titled “The dispatch switch: route each event to its handler”

dispatch began as a placeholder that logged unhandled for everything and ignored its transaction argument, named _tx to mark it unused. Two things change: the argument becomes tx, threaded to whichever handler the switch selects, and the body becomes an exhaustive switch over the three event types the app subscribes to.

export const dispatch = async (
tx: Transaction,
event: Stripe.Event,
): Promise<void> => {
switch (event.type) {
case 'checkout.session.completed':
await onCheckoutCompleted(tx, event);
break;
case 'customer.subscription.updated':
await onSubscriptionUpdated(tx, event);
break;
case 'customer.subscription.deleted':
await onSubscriptionDeleted(tx, event);
break;
default:
log.info({ eventId: event.id, eventType: event.type }, 'unhandled');
return;
}
log.info({ eventId: event.id, eventType: event.type }, 'dispatched');
};

The signature names tx, not _tx, and threads it into every handler the switch routes to, so each handler writes on the same transaction the claim used.

export const dispatch = async (
tx: Transaction,
event: Stripe.Event,
): Promise<void> => {
switch (event.type) {
case 'checkout.session.completed':
await onCheckoutCompleted(tx, event);
break;
case 'customer.subscription.updated':
await onSubscriptionUpdated(tx, event);
break;
case 'customer.subscription.deleted':
await onSubscriptionDeleted(tx, event);
break;
default:
log.info({ eventId: event.id, eventType: event.type }, 'unhandled');
return;
}
log.info({ eventId: event.id, eventType: event.type }, 'dispatched');
};

The case arms cover exactly the three subscription events the app acts on. Each awaits its handler, breaks, and falls through to the dispatched log on line 19.

export const dispatch = async (
tx: Transaction,
event: Stripe.Event,
): Promise<void> => {
switch (event.type) {
case 'checkout.session.completed':
await onCheckoutCompleted(tx, event);
break;
case 'customer.subscription.updated':
await onSubscriptionUpdated(tx, event);
break;
case 'customer.subscription.deleted':
await onSubscriptionDeleted(tx, event);
break;
default:
log.info({ eventId: event.id, eventType: event.type }, 'unhandled');
return;
}
log.info({ eventId: event.id, eventType: event.type }, 'dispatched');
};

The default arm catches an unsubscribed event type: it logs unhandled and returns with a clean 200, never reaching the dispatched log. The two log lines separate “we ignored this on purpose” from “we did the work.”

1 / 1

The three handlers below the switch — onCheckoutCompleted, onSubscriptionUpdated, onSubscriptionDeleted — are still exactly as the starter left them:

export const onCheckoutCompleted = async (
_tx: Transaction,
_event: Stripe.Event,
): Promise<void> => {
throw new Error('not implemented');
};

The duplicate flag could be dropped in favor of always returning { received: true }. It earns its place by making a dedup hit observable without a log dive: an operator reading the response, and a test suite in a later testing chapter, can tell a first delivery from a replay by the body alone.

claimEvent only needs (provider, eventId) to dedupe, but processed_events records eventType too. It costs nothing on the insert and lets an analyst count event types straight from the table — how many checkouts, how many cancellations — with no Stripe round-trip.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 3

The suite sends real signed POST requests at your route handler, with the database, claimEvent, and the handlers’ downstream calls swapped for test doubles, so it needs no live Postgres and no network. It checks the orchestration: a fresh claim opens one transaction and dispatches on that same handle, a lost claim answers 200 { received: true, duplicate: true } and does no work, a fresh claim answers 200 { received: true, duplicate: false } and routes through dispatch, and each of the three subscription events reaches its handler while an unsubscribed type takes the default arm.

The tests cover the claim-and-dispatch wiring but not the live Stripe loop or the logged dispositions. With pnpm stripe:listen forwarding and pnpm dev running, confirm the rest by hand:

stripe trigger checkout.session.completed adds exactly one row to the inspector’s processed_events tail (eventId, eventType, receivedAt), and your terminal log shows verified then claimed.
untested
The inspector’s “Replay last event” re-delivers the same event.id, the tail stays at one row, the response is 200 with { received: true, duplicate: true }, and the log records duplicate.
untested
Every disposition is logged keyed by its event.id, and the plan_entitlements panel still reads free — the handlers the switch routes to are still stubs.
untested

With each event landing exactly once and replays deduped, the boundary is in place. Next lesson fills the handlers behind the switch.