Skip to content
Chapter 91Lesson 4

The idempotency replay test

Write a Vitest integration test that replays a signed Stripe checkout event twice and proves the webhook handler mutates state only once.

Last lesson you drove one signed checkout.session.completed event through the real route handler and proved it wrote the right rows. But Stripe does not promise to deliver that event once, only at least once: a retry after a slow ACK can land the same event on your endpoint a second time. This lesson you write the test that proves the second delivery changes nothing. Same signed event, sent twice, every surface a replay must leave untouched stays exactly as the first send left it.

When it passes, pnpm test:integration reports 2 passed. Under --reporter=verbose the new line reads as the behavior itself:

returns 200 with duplicate=true and does not mutate state on a replayed event

You are proving the webhook handler is idempotent. At-least-once delivery is a guarantee you defend against, not a bug you tolerate: a duplicate is a success. The handler answers 200 and quietly does nothing, never a 4xx that would tell Stripe to retry the same event forever.

The test mirrors the happy-path test you just wrote, almost line for line, on the same harness from the harness-reading lesson. One rule carries over: read inside the transaction with tx, never the global db. The route shares tx through the @/db mock, so the global db would not see the writes the route made inside this transaction.

The one genuinely different part is the input, and you have to construct it. A replay is the same event arriving twice, so the same dedup key must survive both sends. The trap is subtle: if each send minted a fresh event id, the second call would be a brand-new event, the handler would claim it and mutate again, both assertions would still pass, and your test would prove nothing about replays while looking like it does. So build one event, once, and send it twice. The test asserts the absence of mutation across every surface a replay touches.

The first send returns 200 with { received: true, duplicate: false } — the claim-and-dispatch path.
tested
The second send returns 200 with { received: true, duplicate: true } — the dedup-hit path.
tested
The event is claimed exactly once — processed_events rows for the event id stay at 1 across both sends.
tested
The entitlement is not re-written — plan_entitlements.updatedAt is identical before and after the second send.
tested
The audit log is not appended twice — audit_logs rows for the org stay at 1.
tested

Write tests/integration/webhook-idempotency.int.test.ts against the brief and the tests. Try it before opening the solution: the muscle this builds is constructing a failure input on purpose, and you only build it by reaching for it yourself.

Reference solution and walkthrough

The imports and deterministic constants come first. The only one that earns a comment is eventId; everything else is the same cast as the happy-path test.

import { eq } from 'drizzle-orm';
import { describe, expect, it } from 'vitest';
import { auditLogs } from '@/db/audit';
import { planEntitlements, processedEvents } from '@/db/schema';
import { organization } from '@/db/schema/auth';
import { withRollback } from '@/test/db/with-rollback';
import { signedInAs } from '@/test/fixtures/auth';
import { checkoutCompleted } from '@/test/fixtures/stripe-events';
import { fixtureSubscription } from '@/test/fixtures/stripe-subscription';
import { postWebhook } from '@/test/helpers/post-webhook';
import { registerSubscription } from '@/test/stripe-retrieve-registry';
const customerId = 'cus_test_idempotency';
const subscriptionId = 'sub_test_idempotency';
const currentPeriodEnd = 1893456000;
// The pinned eventId is the load-bearing setup: without it each postWebhook mints a
// fresh id and the second call is a NEW event, not a replay. The same id sent twice is
// what exercises claimEvent's onConflictDoNothing dedup and the 200-on-dedup-hit rule.
const eventId = 'evt_test_idempotency_fixed';
describe('replayed checkout event is a no-op', () => {
it(
'returns 200 with duplicate=true and does not mutate state on a replayed event',
withRollback(async ({ tx }) => {
const { org } = await signedInAs({ role: 'admin' }, tx);
await tx
.update(organization)
.set({ stripeCustomerId: customerId })
.where(eq(organization.id, org.id));
const event = checkoutCompleted({
orgId: org.id,
customerId,
subscriptionId,
eventId,
});
registerSubscription(
fixtureSubscription({
id: subscriptionId,
lookupKey: 'course_pro_monthly',
status: 'trialing',
currentPeriodEnd,
orgId: org.id,
}),
);
const first = await postWebhook(event);
expect(first.status).toBe(200);
await expect(first.json()).resolves.toMatchObject({
received: true,
duplicate: false,
});
const afterFirst = await tx.query.planEntitlements.findFirst({
where: eq(planEntitlements.organizationId, org.id),
});
const updatedAtAfterFirst = afterFirst?.updatedAt;
const second = await postWebhook(event);
expect(second.status).toBe(200);
await expect(second.json()).resolves.toMatchObject({
received: true,
duplicate: true,
});
const ledger = await tx.query.processedEvents.findMany({
where: eq(processedEvents.eventId, eventId),
});
expect(ledger).toHaveLength(1);
const afterSecond = await tx.query.planEntitlements.findFirst({
where: eq(planEntitlements.organizationId, org.id),
});
// Equality across two reads reads as "nothing changed in between" — the cleanest
// mutation-free assertion that the replay touched no Stripe-derived column.
expect(afterSecond?.updatedAt).toEqual(updatedAtAfterFirst);
const audits = await tx.query.auditLogs.findMany({
where: eq(auditLogs.organizationId, org.id),
});
expect(audits).toHaveLength(1);
}),
);
});

The pinned eventId is the load-bearing setup. By default the factory hands you a fresh, unique id on every call; passing one in forces both deliveries to carry the same dedup key. The event is built once and sent twice, never rebuilt.

import { eq } from 'drizzle-orm';
import { describe, expect, it } from 'vitest';
import { auditLogs } from '@/db/audit';
import { planEntitlements, processedEvents } from '@/db/schema';
import { organization } from '@/db/schema/auth';
import { withRollback } from '@/test/db/with-rollback';
import { signedInAs } from '@/test/fixtures/auth';
import { checkoutCompleted } from '@/test/fixtures/stripe-events';
import { fixtureSubscription } from '@/test/fixtures/stripe-subscription';
import { postWebhook } from '@/test/helpers/post-webhook';
import { registerSubscription } from '@/test/stripe-retrieve-registry';
const customerId = 'cus_test_idempotency';
const subscriptionId = 'sub_test_idempotency';
const currentPeriodEnd = 1893456000;
// The pinned eventId is the load-bearing setup: without it each postWebhook mints a
// fresh id and the second call is a NEW event, not a replay. The same id sent twice is
// what exercises claimEvent's onConflictDoNothing dedup and the 200-on-dedup-hit rule.
const eventId = 'evt_test_idempotency_fixed';
describe('replayed checkout event is a no-op', () => {
it(
'returns 200 with duplicate=true and does not mutate state on a replayed event',
withRollback(async ({ tx }) => {
const { org } = await signedInAs({ role: 'admin' }, tx);
await tx
.update(organization)
.set({ stripeCustomerId: customerId })
.where(eq(organization.id, org.id));
const event = checkoutCompleted({
orgId: org.id,
customerId,
subscriptionId,
eventId,
});
registerSubscription(
fixtureSubscription({
id: subscriptionId,
lookupKey: 'course_pro_monthly',
status: 'trialing',
currentPeriodEnd,
orgId: org.id,
}),
);
const first = await postWebhook(event);
expect(first.status).toBe(200);
await expect(first.json()).resolves.toMatchObject({
received: true,
duplicate: false,
});
const afterFirst = await tx.query.planEntitlements.findFirst({
where: eq(planEntitlements.organizationId, org.id),
});
const updatedAtAfterFirst = afterFirst?.updatedAt;
const second = await postWebhook(event);
expect(second.status).toBe(200);
await expect(second.json()).resolves.toMatchObject({
received: true,
duplicate: true,
});
const ledger = await tx.query.processedEvents.findMany({
where: eq(processedEvents.eventId, eventId),
});
expect(ledger).toHaveLength(1);
const afterSecond = await tx.query.planEntitlements.findFirst({
where: eq(planEntitlements.organizationId, org.id),
});
// Equality across two reads reads as "nothing changed in between" — the cleanest
// mutation-free assertion that the replay touched no Stripe-derived column.
expect(afterSecond?.updatedAt).toEqual(updatedAtAfterFirst);
const audits = await tx.query.auditLogs.findMany({
where: eq(auditLogs.organizationId, org.id),
});
expect(audits).toHaveLength(1);
}),
);
});

After the first send lands the entitlement, read plan_entitlements through tx and stash afterFirst?.updatedAt. This snapshot is the “before” the second send is measured against.

import { eq } from 'drizzle-orm';
import { describe, expect, it } from 'vitest';
import { auditLogs } from '@/db/audit';
import { planEntitlements, processedEvents } from '@/db/schema';
import { organization } from '@/db/schema/auth';
import { withRollback } from '@/test/db/with-rollback';
import { signedInAs } from '@/test/fixtures/auth';
import { checkoutCompleted } from '@/test/fixtures/stripe-events';
import { fixtureSubscription } from '@/test/fixtures/stripe-subscription';
import { postWebhook } from '@/test/helpers/post-webhook';
import { registerSubscription } from '@/test/stripe-retrieve-registry';
const customerId = 'cus_test_idempotency';
const subscriptionId = 'sub_test_idempotency';
const currentPeriodEnd = 1893456000;
// The pinned eventId is the load-bearing setup: without it each postWebhook mints a
// fresh id and the second call is a NEW event, not a replay. The same id sent twice is
// what exercises claimEvent's onConflictDoNothing dedup and the 200-on-dedup-hit rule.
const eventId = 'evt_test_idempotency_fixed';
describe('replayed checkout event is a no-op', () => {
it(
'returns 200 with duplicate=true and does not mutate state on a replayed event',
withRollback(async ({ tx }) => {
const { org } = await signedInAs({ role: 'admin' }, tx);
await tx
.update(organization)
.set({ stripeCustomerId: customerId })
.where(eq(organization.id, org.id));
const event = checkoutCompleted({
orgId: org.id,
customerId,
subscriptionId,
eventId,
});
registerSubscription(
fixtureSubscription({
id: subscriptionId,
lookupKey: 'course_pro_monthly',
status: 'trialing',
currentPeriodEnd,
orgId: org.id,
}),
);
const first = await postWebhook(event);
expect(first.status).toBe(200);
await expect(first.json()).resolves.toMatchObject({
received: true,
duplicate: false,
});
const afterFirst = await tx.query.planEntitlements.findFirst({
where: eq(planEntitlements.organizationId, org.id),
});
const updatedAtAfterFirst = afterFirst?.updatedAt;
const second = await postWebhook(event);
expect(second.status).toBe(200);
await expect(second.json()).resolves.toMatchObject({
received: true,
duplicate: true,
});
const ledger = await tx.query.processedEvents.findMany({
where: eq(processedEvents.eventId, eventId),
});
expect(ledger).toHaveLength(1);
const afterSecond = await tx.query.planEntitlements.findFirst({
where: eq(planEntitlements.organizationId, org.id),
});
// Equality across two reads reads as "nothing changed in between" — the cleanest
// mutation-free assertion that the replay touched no Stripe-derived column.
expect(afterSecond?.updatedAt).toEqual(updatedAtAfterFirst);
const audits = await tx.query.auditLogs.findMany({
where: eq(auditLogs.organizationId, org.id),
});
expect(audits).toHaveLength(1);
}),
);
});

Re-read the row after the second send and assert afterSecond?.updatedAt equals the captured value. Equality across two reads says “nothing changed in between,” a cleaner mutation-free proof than hardcoding a timestamp.

1 / 1

Two decisions in this test go beyond what the annotations cover.

The updatedAt you compare is the Stripe-derived field that moves on every projection, so pinning it down pins the whole entitlement. That is why equality across two reads is enough: the column the handler would have rewritten was not rewritten.

Asserting on duplicate: true ties the test to a response-shape contract, on purpose. The route answers { received: true, duplicate: true } on the dedup-hit path, and that flag is what an operator reads in the logs to tell a replay from a fresh claim. Asserting on it makes the test break if the team ever drops the flag, and that break is correct: a change to a contract operators depend on deserves a test change, not a silent regression.

Stripe — Handle duplicate webhook events
docs.stripe.com

The source of truth for the behavior you're asserting: at-least-once delivery, deduping by event id, and returning 200 on a replay.

Run the lesson’s gate:

pnpm test:lesson 4

Then run the suite itself:

pnpm test:integration

Expect 2 passed: last lesson’s happy path plus the replay you just wrote. Now run it again with no reset in between. Still 2 passed, because withRollback discarded every row both tests touched, so the second run starts from the same clean database as the first. A replay test is the one most likely to expose a leak: if a stray row survived the first run, the second send’s dedup would behave differently.

The gate confirms the file sends one event twice with a pinned id and asserts each surface. Two checks it can’t make for you, so tick them off by hand:

Under pnpm test:integration --reporter=verbose, the two it names alone name the two behaviors (happy path, replay), with no need to read the test bodies. This is the read-aloud rule from Lesson 4 of The shape of a test suite.
untested
Swap the route handler for any other implementation that still satisfies verify → claim → mutate → audit, and both tests stay green: the test is anchored to behavior, not internals. Restore the handler afterward.
untested