Webhook receivers under test
Integration-testing the Stripe webhook receiver by driving its exported handler with a real signed Request against real Postgres.
Every test in this chapter so far drove code that you call: createInvoice, createSubscription, a Drizzle query. Your code is the subject, the test is the caller, and you set the inputs.
A webhook receiver inverts that: Stripe calls you. It sits at the edge of your system with no session, no logged-in user, no caller you trust, just raw bytes over HTTP from a server you do not own. Some deliveries are replays, a few may be forgeries. It moves money and has the least context to defend itself.
This lesson tests the Stripe receiver you already shipped. You drive its exported POST with a real, correctly signed Request, run it against the real test Postgres, and assert on two things at once: the Response Stripe would see, and the rows that did or did not land in the database. The result is a six-path test that proves the receiver correct before a real checkout.session.completed arrives carrying real money.
The receiver is a four-step trust boundary
Section titled “The receiver is a four-step trust boundary”You built this route when you wired up Stripe billing, so this is recall: app/api/webhooks/stripe/route.ts does four things, in strict order.
- It reads the raw body and verifies the
Stripe-Signatureheader against it. - It treats the verified bytes as a
Stripe.Event. - It claims the event against the
processed_eventstable, so a redelivery is recognized. - It dispatches the side effect, projecting the subscription into the org’s entitlement row, inside a transaction.
That order forms a trust boundary , and the boundary’s correctness lives in the seams between the steps, not in any one function. A unit test of the claim or the dispatch proves that step in isolation; it cannot prove the boundary. Only one test can: one that drives the whole pipeline the way Stripe does, a real signed Request through the exported handler, minus the network hop. Each of the four steps fails in production its own way, and a function-level mock catches none of them.
Verify the raw body. A body parser that runs before the verifier changes the bytes, so every signature fails: green locally, every webhook 400s in production. Skip the verifier and a forged “upgrade me to pro” event gets through. Verification has to come first, on the raw bytes.
Claim in processed_events. Stripe delivers at-least-once: the same event arrives one or more times. Without a claim, a redelivery runs the side effect again and the customer is provisioned twice.
Dispatch the side effect. If the claim insert and the side effect do not share one transaction, a crash mid-dispatch leaves a claimed-but-not-applied event: permanent partial state no retry can repair, because the claim already says “already handled”.
Respond. The status code tells Stripe what to do: 2xx stop retrying, 4xx terminal never retry, 5xx retry later. The wrong one either drops a real event or keeps redelivering a handled one.
Each failure mode becomes a path in the test later, and each path proves the bug cannot happen.
One distinction before any code. The last two lessons mocked outbound HTTP, your code calling Stripe, with MSW . This lesson is the mirror image: inbound. There is no MSW here, the “network” is a Request object your test builds by hand and passes straight to the function.
Signing a payload the way Stripe does
Section titled “Signing a payload the way Stripe does”To test the valid path, the test must hand the receiver a payload constructEvent will accept: a Stripe-Signature header carrying the t=<timestamp>,v1=<hmac> string, computed against the same STRIPE_WEBHOOK_SECRET the handler verifies with. Get it wrong and the first step rejects everything, so the rest of the test never runs.
You hand-rolled this HMAC in Verify before parse, so the temptation is to reuse that recipe to sign your fixtures. Don’t. That code existed to explain the scheme, not to sign with it, and the moment your copy drifts from Stripe’s, in header format, tolerance window, or encoding, you ship a false negative that passes against bytes Stripe would never send.
Stripe ships stripe.webhooks.generateTestHeaderString for exactly this: give it a payload and a secret, get back a header constructEvent verifies, because it is the precise inverse of constructEvent. Same instinct as the last few lessons: drive a contract you don’t own through its published seam, never by re-deriving its internals.
The two halves are a matched pair, keyed by one secret:
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });const event = stripe.webhooks.constructEvent(payload, header, secret);So the test setup reads STRIPE_WEBHOOK_SECRET from .env.test and fails fast if it is missing, rather than let a whsec_undefined silently 400 every test and send you debugging the wrong thing.
The payload comes from a factory over a captured event
Section titled “The payload comes from a factory over a captured event”A Stripe event is a large, nested JSON object you don’t want to hand-write, so capture a real one once (from stripe trigger or the dashboard’s event log), store it at src/test/fixtures/stripe/customer-subscription-updated.json, and treat that file as the spine.
Then layer a factory over it. buildStripeEvent({ type, data }) takes the captured event as its base and overrides only the fields that vary per test, the event type and the inner object, so each test states just what makes it different. The same factories-over-fixtures discipline you have used all chapter: one realistic base parameterized at the seam that matters.
The factory mints a fresh event.id on every call. The replay tests deliberately re-send the same id to prove the dedupe works, so a shared id would collide two unrelated tests on the same processed_events row and pass the dedupe assertions for the wrong reason.
Last, the timestamp. generateTestHeaderString takes a timestamp in seconds, defaulting to now; the expired-signature path passes a timestamp ten minutes in the past, outside Stripe’s tolerance window. For that to be reproducible “now” must stand still, which is what the frozen clock from Pinning time and IDs gives you: “ten minutes ago” becomes a fixed number rather than a moving target.
Here is the sign-and-build helper, the one piece of new wiring this lesson asks you to write.
const buildSignedRequest = ( event: Stripe.Event, options: { timestamp?: number; secret?: string } = {},): Request => { const secret = options.secret ?? env.STRIPE_WEBHOOK_SECRET; const payload = JSON.stringify(event); const header = stripe.webhooks.generateTestHeaderString({ payload, secret, timestamp: options.timestamp, });
return new Request('https://app.test/api/webhooks/stripe', { method: 'POST', headers: { 'stripe-signature': header }, body: payload, });};Defaults to the .env.test value the handler verifies against; the forged-signature test passes a wrong one here to make verification fail. A deliberate, visible input keeps the “all webhooks 400” bug out of the suite.
const buildSignedRequest = ( event: Stripe.Event, options: { timestamp?: number; secret?: string } = {},): Request => { const secret = options.secret ?? env.STRIPE_WEBHOOK_SECRET; const payload = JSON.stringify(event); const header = stripe.webhooks.generateTestHeaderString({ payload, secret, timestamp: options.timestamp, });
return new Request('https://app.test/api/webhooks/stripe', { method: 'POST', headers: { 'stripe-signature': header }, body: payload, });};Serialize the event to a string once. These exact bytes are both what gets signed and what gets sent.
const buildSignedRequest = ( event: Stripe.Event, options: { timestamp?: number; secret?: string } = {},): Request => { const secret = options.secret ?? env.STRIPE_WEBHOOK_SECRET; const payload = JSON.stringify(event); const header = stripe.webhooks.generateTestHeaderString({ payload, secret, timestamp: options.timestamp, });
return new Request('https://app.test/api/webhooks/stripe', { method: 'POST', headers: { 'stripe-signature': header }, body: payload, });};Produce the Stripe-Signature header from that payload and secret. The timestamp defaults to now; the expired-path test overrides it. This call is the exact inverse of the constructEvent call inside the handler.
const buildSignedRequest = ( event: Stripe.Event, options: { timestamp?: number; secret?: string } = {},): Request => { const secret = options.secret ?? env.STRIPE_WEBHOOK_SECRET; const payload = JSON.stringify(event); const header = stripe.webhooks.generateTestHeaderString({ payload, secret, timestamp: options.timestamp, });
return new Request('https://app.test/api/webhooks/stripe', { method: 'POST', headers: { 'stripe-signature': header }, body: payload, });};Build the inbound Request. The body is the identical string we signed; signing event but sending a re-stringified copy is the classic webhook bug from the sending side.
This is the only signing code in the suite. Every path test now calls buildStripeEvent(...) to shape a payload, buildSignedRequest(...) to sign and wrap it, then drives the handler. That leaves one structural problem to solve.
Reaching the handler’s transaction through AsyncLocalStorage
Section titled “Reaching the handler’s transaction through AsyncLocalStorage”Every integration test in this chapter isolates the same way: open a transaction, run the production code against that tx, and roll back. So far the code under test took the transaction because you passed it, as in createInvoice(input, { db: tx }), the handle explicit and visible at the call site.
A route handler cannot accept that handle. Next.js fixes its signature at POST(request: Request), with no parameter to thread tx through. The handler reaches for the database on its own, so if the test cannot substitute tx there, its writes commit for real and rollback-per-test fails at the one seam that writes money rows.
This is what the AsyncLocalStorage escape hatch from Rollback against real Postgres was built for. The handler never imports db; it calls getDb():
export const getDb = (): DbOrTx => testTxContext.getStore() ?? defaultDb;testTxContext is the AsyncLocalStorage<DbOrTx> instance from src/db/test-tx-context.ts. In production the store is empty, so getDb() returns the real singleton and nothing about the handler changes. A test runs the handler inside the store instead:
const response = await testTxContext.run(tx, () => POST(request));testTxContext.run(tx, fn) enters fn with the store set to tx. Now every getDb() that fires inside POST — in claimEvent, in dispatch, in the entitlement write — returns tx instead of the singleton: one context entry, threaded implicitly through the whole call tree, rolled back by withRollback like every other test. The handler’s source never changes; the test reaches in through a seam production already exposes, not by mocking the database.
Coming from explicit passing, the question that trips people up is “where did tx go?” Nothing in the call tree mentions it, yet every query uses it. Scrub between the two states below: the handler code is identical, only the store differs.
POST(request) getDb() als.getStore() ?? defaultDb tx defaultDb defaultDb.transaction(...) claimEventdispatch
Every query rides defaultDb — commits for real
Production. No test wraps the handler, so getStore() returns undefined and the ?? defaultDb fork falls to the real singleton.
POST(request) getDb() als.getStore() ?? defaultDb tx defaultDb tx.transaction(...) claimEventdispatch
Every query rides tx — rolled back by withRollback
Test. testTxContext.run(tx, () => POST(request)) wraps the handler, so the identical getStore() call now returns tx and the fork resolves to it.
AsyncLocalStorage is what makes this work, and it is the one place to reach for it. Explicit handles win wherever the signature is yours to control: cheaper to read, with the dependency visible at the call site. AsyncLocalStorage trades that visibility for an implicit flow, and the trade only pays off when the framework fixes the signature and leaves you no parameter. A route handler is exactly that case. It is an escape hatch , not a second default: you will not reach for it in the next lesson’s Server Action tests, where the explicit handle is still right.
The alternative it replaces shows why it is the honest choice, not a convenience.
vi.mock('@/db/client', () => ({ db: fakeDb }));
const response = await POST(request);This mocks the database the whole chapter exists to avoid mocking. A fake db replaces real Postgres, so the test no longer exercises the unique constraint on processed_events, the SQL the claim emits, or column nullability — the exact regressions this chapter catches. It proves the handler calls something, not that it talks correctly to a real database.
const response = await testTxContext.run(tx, () => POST(request));The honest reach. The real handler runs against real per-worker Postgres, every write rides the test’s tx, and withRollback undoes it — the unique constraint fires, the real SQL executes, nothing commits, all through the seam getDb() already exposes with production code untouched.
With signing solved and the transaction reachable, everything left is the Arrange-Act-Assert shape you already know, applied once per path.
The six paths a webhook test must cover
Section titled “The six paths a webhook test must cover”A webhook receiver is a small matrix of behaviors, and “it works” means every cell is right. Each path is defined by the production bug it catches and the two assertions that catch it.
Those two assertions are independent axes. One is the HTTP Response Stripe sees: the status code and JSON body. The other is the persisted truth, the rows that did or did not land in the database, read back inside tx. A 200 does not prove a write happened, and a write does not prove the right status came back, so most paths assert on both.
Every path shares one skeleton:
describe('POST /api/webhooks/stripe', () => { it('<outcome> when <condition>', withRollback(async ({ tx }) => { // Arrange: build a payload, sign it, wrap it in a Request const event = buildStripeEvent({ type, data }); const request = buildSignedRequest(event);
// Act: run the handler inside the tx context const response = await testTxContext.run(tx, () => POST(request));
// Assert: the Response Stripe sees, and the rows in tx expect(response.status).toBe(/* ... */); const rows = await tx.select().from(processedEvents); // ... }));});One describe for the receiver, one it per path, AAA in each. The first path is written out in full; the other five are deltas that change one line in Arrange and the assertions and leave everything else identical.
The valid event commits exactly one side effect
Section titled “The valid event commits exactly one side effect”The bug this catches: a correctly signed, genuine event fails to verify, claim, dispatch, or persist, and the receiver silently drops real money.
The worked path runs on customer.subscription.updated, whose dispatch is pure inbound work: it projects the subscription carried in the payload straight onto the org’s entitlement row, with no call back out to Stripe. (checkout.session.completed would fetch the subscription outbound, the path the project chapter composes with MSW.) Because the dispatch updates an existing entitlement row, Arrange first builds one for the org, using a factory inside tx.
it( 'records the event and updates the entitlement when the signature is valid', withRollback(async ({ tx }) => { const org = await seedEntitledOrg(tx, { plan: 'free' }); const event = buildStripeEvent({ type: 'customer.subscription.updated', data: { object: buildSubscription({ orgId: org.id, plan: 'pro' }) }, }); const request = buildSignedRequest(event);
const response = await testTxContext.run(tx, () => POST(request));
expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ received: true, duplicate: false, });
const claimed = await tx .select() .from(processedEvents) .where(eq(processedEvents.eventId, event.id)); expect(claimed).toHaveLength(1); expect(claimed[0]).toMatchObject({ provider: 'stripe', eventType: 'customer.subscription.updated', });
const [entitlement] = await tx .select() .from(planEntitlements) .where(eq(planEntitlements.organizationId, org.id)); expect(entitlement?.plan).toBe('pro'); }),);The precondition: an org with a free entitlement row for the event to update. Built inside tx via a factory, so it rolls back with everything else.
it( 'records the event and updates the entitlement when the signature is valid', withRollback(async ({ tx }) => { const org = await seedEntitledOrg(tx, { plan: 'free' }); const event = buildStripeEvent({ type: 'customer.subscription.updated', data: { object: buildSubscription({ orgId: org.id, plan: 'pro' }) }, }); const request = buildSignedRequest(event);
const response = await testTxContext.run(tx, () => POST(request));
expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ received: true, duplicate: false, });
const claimed = await tx .select() .from(processedEvents) .where(eq(processedEvents.eventId, event.id)); expect(claimed).toHaveLength(1); expect(claimed[0]).toMatchObject({ provider: 'stripe', eventType: 'customer.subscription.updated', });
const [entitlement] = await tx .select() .from(planEntitlements) .where(eq(planEntitlements.organizationId, org.id)); expect(entitlement?.plan).toBe('pro'); }),);The payload. The factory starts from a captured real event and overrides only the type and the inner subscription object, here a pro subscription owned by this org.
it( 'records the event and updates the entitlement when the signature is valid', withRollback(async ({ tx }) => { const org = await seedEntitledOrg(tx, { plan: 'free' }); const event = buildStripeEvent({ type: 'customer.subscription.updated', data: { object: buildSubscription({ orgId: org.id, plan: 'pro' }) }, }); const request = buildSignedRequest(event);
const response = await testTxContext.run(tx, () => POST(request));
expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ received: true, duplicate: false, });
const claimed = await tx .select() .from(processedEvents) .where(eq(processedEvents.eventId, event.id)); expect(claimed).toHaveLength(1); expect(claimed[0]).toMatchObject({ provider: 'stripe', eventType: 'customer.subscription.updated', });
const [entitlement] = await tx .select() .from(planEntitlements) .where(eq(planEntitlements.organizationId, org.id)); expect(entitlement?.plan).toBe('pro'); }),);Sign that exact payload and wrap it in the inbound Request. The earlier helper keeps signature and body consistent by construction.
it( 'records the event and updates the entitlement when the signature is valid', withRollback(async ({ tx }) => { const org = await seedEntitledOrg(tx, { plan: 'free' }); const event = buildStripeEvent({ type: 'customer.subscription.updated', data: { object: buildSubscription({ orgId: org.id, plan: 'pro' }) }, }); const request = buildSignedRequest(event);
const response = await testTxContext.run(tx, () => POST(request));
expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ received: true, duplicate: false, });
const claimed = await tx .select() .from(processedEvents) .where(eq(processedEvents.eventId, event.id)); expect(claimed).toHaveLength(1); expect(claimed[0]).toMatchObject({ provider: 'stripe', eventType: 'customer.subscription.updated', });
const [entitlement] = await tx .select() .from(planEntitlements) .where(eq(planEntitlements.organizationId, org.id)); expect(entitlement?.plan).toBe('pro'); }),);The single Act line runs the real handler inside the tx context, so every write rides tx.
it( 'records the event and updates the entitlement when the signature is valid', withRollback(async ({ tx }) => { const org = await seedEntitledOrg(tx, { plan: 'free' }); const event = buildStripeEvent({ type: 'customer.subscription.updated', data: { object: buildSubscription({ orgId: org.id, plan: 'pro' }) }, }); const request = buildSignedRequest(event);
const response = await testTxContext.run(tx, () => POST(request));
expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ received: true, duplicate: false, });
const claimed = await tx .select() .from(processedEvents) .where(eq(processedEvents.eventId, event.id)); expect(claimed).toHaveLength(1); expect(claimed[0]).toMatchObject({ provider: 'stripe', eventType: 'customer.subscription.updated', });
const [entitlement] = await tx .select() .from(planEntitlements) .where(eq(planEntitlements.organizationId, org.id)); expect(entitlement?.plan).toBe('pro'); }),);First axis, what Stripe sees: status 200, body { received: true, duplicate: false }. The HTTP contract says nothing yet about what persisted.
it( 'records the event and updates the entitlement when the signature is valid', withRollback(async ({ tx }) => { const org = await seedEntitledOrg(tx, { plan: 'free' }); const event = buildStripeEvent({ type: 'customer.subscription.updated', data: { object: buildSubscription({ orgId: org.id, plan: 'pro' }) }, }); const request = buildSignedRequest(event);
const response = await testTxContext.run(tx, () => POST(request));
expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ received: true, duplicate: false, });
const claimed = await tx .select() .from(processedEvents) .where(eq(processedEvents.eventId, event.id)); expect(claimed).toHaveLength(1); expect(claimed[0]).toMatchObject({ provider: 'stripe', eventType: 'customer.subscription.updated', });
const [entitlement] = await tx .select() .from(planEntitlements) .where(eq(planEntitlements.organizationId, org.id)); expect(entitlement?.plan).toBe('pro'); }),);Second axis: exactly one claim row, matched by event.id, the id the test controls. Never match by the auto-increment id, which advances and does not roll back. Assert on shape, not on the sequence.
it( 'records the event and updates the entitlement when the signature is valid', withRollback(async ({ tx }) => { const org = await seedEntitledOrg(tx, { plan: 'free' }); const event = buildStripeEvent({ type: 'customer.subscription.updated', data: { object: buildSubscription({ orgId: org.id, plan: 'pro' }) }, }); const request = buildSignedRequest(event);
const response = await testTxContext.run(tx, () => POST(request));
expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ received: true, duplicate: false, });
const claimed = await tx .select() .from(processedEvents) .where(eq(processedEvents.eventId, event.id)); expect(claimed).toHaveLength(1); expect(claimed[0]).toMatchObject({ provider: 'stripe', eventType: 'customer.subscription.updated', });
const [entitlement] = await tx .select() .from(planEntitlements) .where(eq(planEntitlements.organizationId, org.id)); expect(entitlement?.plan).toBe('pro'); }),);The dispatched side effect landed: the org’s plan is now pro. A 200 alone never proves this; the write is a separate axis with its own assertion.
Every path below reuses this skeleton, changing one line in Arrange and the assertions and nothing else.
A forged signature is refused and writes nothing
Section titled “A forged signature is refused and writes nothing”The bug this catches: an attacker or misconfigured sender posts a body Stripe never signed, and the receiver runs business logic on it anyway. A forged “upgrade me to pro” event must be rejected before any handler sees it.
The delta is one line: sign with the wrong secret, so verification fails.
// Delta on the skeleton: sign with the wrong secretconst request = buildSignedRequest(event, { secret: 'whsec_wrong' });
const response = await testTxContext.run(tx, () => POST(request));
expect(response.status).toBe(400);await expect(response.json()).resolves.toMatchObject({ title: 'invalid_signature',});
const claimed = await tx.select().from(processedEvents);expect(claimed).toHaveLength(0);The 400 with title: 'invalid_signature' is the same answer a missing header gets, so Stripe treats the delivery as terminal and never retries the forgery. But the status is only half the proof: a 400 does not prove the side effect was skipped, only the empty table does. Zero rows in processed_events is verify-before-everything made visible, and it closes the bug a status-only test lets through, “rejected with 400 but wrote anyway.”
A stale but authentic event is rejected on the tolerance window
Section titled “A stale but authentic event is rejected on the tolerance window”The bug this catches: an attacker replays a request captured from a log. The signature is genuine, since Stripe really did sign it once, so the verifier alone cannot tell it is an attack. Only freshness can.
Stripe stamps every signature with the time it was produced and refuses any whose timestamp is more than 300 seconds off. The delta signs with a timestamp ten minutes in the past, under the frozen clock:
// Delta on the skeleton: sign at a timestamp outside the tolerance windowconst tenMinutesAgo = Math.floor(Date.now() / 1000) - 600;const request = buildSignedRequest(event, { timestamp: tenMinutesAgo });
const response = await testTxContext.run(tx, () => POST(request));
expect(response.status).toBe(400);
const claimed = await tx.select().from(processedEvents);expect(claimed).toHaveLength(0);constructEvent throws on the stale timestamp, the handler answers 400, and nothing is written. A mirror assertion belongs in the same suite: a timestamp within tolerance returns 200, pinning the boundary to the 300-second window rather than a vague “old is bad.”
This path is also why the clock seam matters. Date.now() here is the frozen value from the unit-test chapter, so “ten minutes ago” is a fixed, exact number. Against a live clock the subtraction races real time: the test passes at 12:00:00 and flakes when it runs as the tolerance boundary ticks over, one of the flake patterns the next lesson catalogs. The fix is the seam you already have. The tolerance window is testable only because time stands still.
A replayed event is deduped to a single side effect
Section titled “A replayed event is deduped to a single side effect”The bug this catches: Stripe’s at-least-once delivery sends the same event.id twice. That is the delivery contract, not a malfunction, and if the side effect runs on both deliveries the customer is double-provisioned or double-charged.
This path is shaped differently: it acts twice with the same signed request and counts one side effect. The Arrange matches the valid path, a seeded entitlement row and a signed customer.subscription.updated event, so the delta lives entirely in the Act and Assert.
const request = buildSignedRequest(event);
const first = await testTxContext.run(tx, () => POST(request.clone()));expect(first.status).toBe(200);await expect(first.json()).resolves.toMatchObject({ duplicate: false });
// Same signed bytes, delivered again — Stripe's at-least-once contractconst second = await testTxContext.run(tx, () => POST(request.clone()));expect(second.status).toBe(200);await expect(second.json()).resolves.toMatchObject({ duplicate: true });
const claimed = await tx .select() .from(processedEvents) .where(eq(processedEvents.eventId, event.id));expect(claimed).toHaveLength(1);A Request body is a single-use stream, and the handler reads it with request.text(), so each delivery needs its own readable copy via request.clone().
The first delivery claims the event and dispatches: 200, duplicate: false. The second finds the claim taken, short-circuits before dispatch, and returns 200 with duplicate: true. Still one row, still one side effect.
Two choices here are deliberate. A duplicate returns 200, not 4xx or 5xx: a 4xx tells Stripe to stop retrying a real event, a 5xx tells it to retry one you have already applied, and a handled duplicate is a success. The single row holds because the claim insert and the dispatch share one tx per delivery, which makes the receiver idempotent . This is the path that proves the processed_events ledger earns its keep.
What proves the dedupe is the unchanged single row, not the second 200. The replay’s 200 only confirms a clean acknowledgement; the still-single processed_events row proves the side effect did not run again.
A malformed payload is handled without half-committing
Section titled “A malformed payload is handled without half-committing”The bug this catches: a body correctly signed but structurally wrong for dispatch, such as a subscription event missing its data.object. Stripe will not retry a valid signature, so the receiver must neither 500 in a loop nor leave a half-written transaction behind.
This differs from the forged path, and the difference is the point. There the signature was bad and the body never mattered. Here the signature is valid, verification passes, and the content fails: a different seam, a different defense.
The delta builds a payload that signs fine but breaks dispatch:
const event = buildStripeEvent({ type: 'customer.subscription.updated', // Signs fine; structurally broken for dispatch data: { object: undefined },});const request = buildSignedRequest(event);
const response = await testTxContext.run(tx, () => POST(request));
const claimed = await tx.select().from(processedEvents);expect(claimed).toHaveLength(0);Whatever status the handler returns, nothing partial persisted. The dispatch failed inside the transaction, so it rolled back, and the claim inserted moments earlier rolled back with it because claim and dispatch share one tx. That co-rollback is what this path proves: a failed side effect must not strand a claimed-but-unapplied event in the ledger.
An unhandled event type is acknowledged without side effects
Section titled “An unhandled event type is acknowledged without side effects”The bug this catches: your Stripe dashboard is subscribed to more event types than your app acts on, so events you never handle still arrive. An unhandled type must be acknowledged so Stripe stops retrying it, while the receiver does nothing.
The delta sends a type the dispatch does not handle:
const event = buildStripeEvent({ // Real, well-formed event — just not one the app acts on type: 'invoice.payment_succeeded', data: { object: buildInvoiceEvent() },});const request = buildSignedRequest(event);
const response = await testTxContext.run(tx, () => POST(request));
expect(response.status).toBe(200);
const entitlements = await tx.select().from(planEntitlements);expect(entitlements).toHaveLength(0);The empty entitlement table proves the side effect was skipped. The processed_events claim is a separate question: this receiver claims before it dispatches, so an unhandled type is still recorded. Confirm that against the shipped handler and assert what is actually true.
The 200 is a design choice: it is the message that stops Stripe’s retries for an event the app legitimately ignores. That contrast with the 400 paths is the point of the matrix. A forged or stale event is malformed: 400, terminal, “never send this again.” An unhandled-but-valid event is fine, just not ours: 200, acknowledged, “received, no action.” Same family of input, opposite correct answer.
Status and database state are independent axes
Section titled “Status and database state are independent axes”The status Stripe sees and the database state do not move together: a 200 can mean “wrote” or “deliberately wrote nothing,” and “wrote nothing” can pair with a 200 or a 400. Conflate the two axes and you ship a receiver that returns the right status while doing the wrong thing.
Sort each delivery by the two independent axes: the Response status Stripe sees, and whether anything persisted to the database, read back inside tx. Drag each item into the bucket it belongs to, then press Check.
customer.subscription.updatedevent.id — the second delivery of the same signed bytesinvoice.payment_succeeded, validly signedThe malformed-payload path is deliberately absent: its (status, persistence) outcome is handler-defined, so it slots into whichever cell the shipped receiver implements. It is the one cell you cannot fill from the matrix alone, only by reading the code.
The same shape covers every signed webhook
Section titled “The same shape covers every signed webhook”What you built is not Stripe-specific. The matrix you wrote — sign a captured payload with the provider’s own test helper, drive the exported handler with the resulting Request, and assert on both the Response and the database across the valid, forged, stale, replay, malformed, and unhandled paths — holds for any signed webhook. Only the signing primitive and header names change.
The receiver from Resend webhooks makes the point. Resend signs through Svix , so its headers (svix-id, svix-timestamp, svix-signature) and test-signing helper differ. The test does not: swap generateTestHeaderString for Svix’s signer and the three header names, and the same six-path matrix, AsyncLocalStorage reach, and processed_events dedupe assertion transfer untouched.
// Stripe: HMAC, one Stripe-Signature headerstripe.webhooks.generateTestHeaderString({ payload, secret });
// Resend (Svix): different scheme, three svix-* headersnew Webhook(secret).sign(messageId, timestamp, payload);You did not learn to test the Stripe receiver; you learned to test a signed inbound boundary. For the next provider, the only thing to look up is which crypto helper produces the header.
External resources
Section titled “External resources”The constructEvent contract this lesson drives, plus the raw-body and tolerance-window rules its tests pin.
Official reference for run() and getStore() — the seam the test reaches the handler's transaction through.
Capturing real events with stripe trigger and stripe listen — where the fixtures this lesson signs come from.
The svix-* header scheme Resend uses — the same six-path matrix, a different signing primitive.