One pattern, four surfaces
The idempotency pattern generalized beyond webhooks, how the same client-chosen key, unique constraint, and atomic claim-and-work make Server Actions, background jobs, and public API routes each run an operation exactly once.
You just spent three lessons making a webhook survive being delivered twice. Stripe retries on any non-2xx, redelivers out of order, and occasionally fires the same event for no reason at all, and your handler now absorbs all of it without flinching. That problem felt webhook-shaped while you were inside it, but it isn’t.
A Server Action fires twice because the user double-clicked submit before the spinner showed up. A background job times out at second 29, so the runtime retries it from the top, even though the first attempt actually finished and was just slow to answer. A public POST to your API gets a connection reset, so the client, having no idea whether you received it, sends it again. Four different surfaces, and underneath each one is the same risk: the operation runs twice, so a card gets charged twice, or two invoices appear, or two welcome emails go out. The question this lesson answers is whether that is four separate problems or one. It is one. You already wrote the solution, in the Claim once, mutate once lesson, and the rest of this lesson teaches you to recognize it everywhere it shows up.
The pattern, stated once
Section titled “The pattern, stated once”Strip the webhook off the thing you built and look at what’s left. Three moves, in this order:
-
Choose a key that identifies this attempt. Not the request and not the row, but the attempt. Two deliveries of the same attempt share the key, and two genuinely different operations get different keys.
-
Store the key under a unique constraint. Now the database refuses duplicates, rather than your code.
-
Do the real work in the same transaction as the insert. The claim and the consequence commit together or roll back together.
On a replay, the insert hits the unique constraint. ON CONFLICT DO NOTHING returns zero rows, or a caught unique-violation throws, and that zero-row answer is the whole signal: this attempt already happened. You short-circuit and return success. You don’t redo the work, because the work is already done and committed alongside the key.
That’s the entire discipline. It makes an operation idempotent , meaning you can apply it many times and get the same end result as applying it once. The reason you need it at all is at-least-once delivery : the transport between two systems can hand the same message over more than once, and the only system that can do anything about that is the receiver. You can’t make the sender promise “exactly once,” because that promise is impossible to keep across an unreliable network. So you make the receiver tolerant instead.
Now map the abstract recipe onto the concrete code you already own. Here is the claim-and-transact skeleton from the Claim once, mutate once lesson, with each of the three moves labeled by its abstract role.
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) return;
await applyEvent(tx, event);});Move 1: the key. The key is event.id. Stripe assigns it, and Stripe sends the same id on every redelivery of that event. That stability is what makes it a usable key, and it is the subject of the whole next section.
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) return;
await applyEvent(tx, event);});Move 2: the unique constraint. The dedup lives in processed_events, under a unique constraint on (provider, eventId). The target names that constraint. DO NOTHING plus RETURNING means a duplicate comes back as zero rows instead of an error, so the conflict becomes a value you can branch on rather than an exception you must catch.
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) return;
await applyEvent(tx, event);});Move 3: the shared transaction. The claim insert and the business work share one tx, so one commit boundary covers both. A crash between them rolls back both, and the retry re-claims cleanly. If the claim returned zero rows, you return early, because the work is already done.
Notice that two separate questions are tangled up in any incoming request, and this lesson is about exactly one of them. One question is who sent this?, answered by a signature or by auth. The other is is this the same attempt I’ve already seen?, answered by the key. Those axes are independent, and we’ll come back to why they don’t substitute for each other near the end. For now, the key is the only thing on the table.
Where the key comes from is the only thing that changes
Section titled “Where the key comes from is the only thing that changes”The three moves never change. Across all four surfaces, webhooks, Server Actions, background jobs, and public routes, the recipe is identical down to the SQL. Only two things vary: where the key is born, and what causes the replay. Get those right and the rest is copy-paste.
Here is the artifact the whole lesson hangs on: four surfaces, side by side, as parallel columns. Read across any row and you’ll find the same idea in four different forms. Read down a column and you’ll find one surface’s complete story. Right now, focus on the first three rows: who owns the attempt, where the key comes from, and what the key actually is. The later rows fill in as we work through each surface, and you’ll see the finished grid at the end.
Now the rule that separates idempotency that works from idempotency that’s secretly a no-op. The key is generated at the source that owns the definition of “this attempt”, and it is re-sent on the replay. The sender mints it once and sends it every time, not the receiver.
This is where beginners reliably break it, so let me show you the break. The instinct is to generate a fresh dedup key inside the handler, server-side, on each request. It feels responsible, since you’re “adding idempotency,” but it does nothing. Compare the two:
export async function POST(request: Request) { const idempotencyKey = crypto.randomUUID();
await db .insert(idempotencyKeys) .values({ idempotencyKey }) .onConflictDoNothing();}The constraint never fires. A retry of the same request enters this handler again and generates a different UUID. The two attempts never share a key, so the unique constraint has nothing to catch on, and the work runs both times. You built the table, the constraint, and the transaction, and protected nothing. Generating the key per request is the bug.
export async function POST(request: Request) { const idempotencyKey = request.headers.get('Idempotency-Key');
await db .insert(idempotencyKeys) .values({ idempotencyKey }) .onConflictDoNothing();}The key comes in over the wire. The first attempt and its retry carry the same value because the client chose it once and re-sends it. The second insert conflicts, returns zero rows, and you take the “already done” path. The fix wasn’t more code; it was moving where the key is born.
That’s the discriminator. Every surface below is a different answer to one question: who is the source, and how does the key reach the receiver. Watch for it.
Server Actions: the form-supplied key
Section titled “Server Actions: the form-supplied key”Back when you learned Server Actions, the form you built shipped something that looked like dead weight: a hidden input holding a crypto.randomUUID(), generated once when the form rendered. That hidden input was a promise to be cashed in later, and this is the lesson that cashes it.
Walk the chain. A Client Component renders the form and mints one UUID at render time, which rides along in a hidden field. When the user submits, React 19 holds that value through the pending transition and through a resubmit, so a double-click sends the same UUID twice. The source that owns “this attempt” is the form render, and the key reaches the server in the FormData. Your job is the action body: read the key, claim it under a unique constraint in the same transaction as the create, and let a conflict mean “the create already happened.”
Here is that action body in the course’s five-seam shape, parse, authorize, mutate, revalidate, return, with the idempotency machinery slotted into the mutate seam.
'use server';
export async function createInvoice(formData: FormData): Promise<Result<null>> { const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the highlighted fields.', flattenError(parsed.error).fieldErrors); }
const { orgId } = await requireOrgUser(); const { idempotencyKey, ...input } = parsed.data;
await db.transaction(async (tx) => { const [claimed] = await tx .insert(actionClaims) .values({ orgId, idempotencyKey }) .onConflictDoNothing({ target: [actionClaims.orgId, actionClaims.idempotencyKey] }) .returning({ id: actionClaims.id });
if (!claimed) return;
await tx.insert(invoices).values({ orgId, ...input }); });
revalidatePath('/invoices'); return ok(null);}Read the key from the form. The key arrives in FormData exactly like every other field, validated by the same safeParse (the schema includes idempotencyKey: z.uuid()). The Client Component generated it once, and the action just reads it. Nothing here mints a key, which is the point.
'use server';
export async function createInvoice(formData: FormData): Promise<Result<null>> { const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the highlighted fields.', flattenError(parsed.error).fieldErrors); }
const { orgId } = await requireOrgUser(); const { idempotencyKey, ...input } = parsed.data;
await db.transaction(async (tx) => { const [claimed] = await tx .insert(actionClaims) .values({ orgId, idempotencyKey }) .onConflictDoNothing({ target: [actionClaims.orgId, actionClaims.idempotencyKey] }) .returning({ id: actionClaims.id });
if (!claimed) return;
await tx.insert(invoices).values({ orgId, ...input }); });
revalidatePath('/invoices'); return ok(null);}Claim it in the same transaction as the create. Same three moves. The claim insert and the invoice insert share one tx. A double-click sends the same idempotencyKey, so the second claim conflicts, claimed is undefined, the transaction returns early, and no second invoice is inserted. The action still returns ok(null), because the replay is a success from the caller’s point of view: the create it asked for did happen.
'use server';
export async function createInvoice(formData: FormData): Promise<Result<null>> { const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the highlighted fields.', flattenError(parsed.error).fieldErrors); }
const { orgId } = await requireOrgUser(); const { idempotencyKey, ...input } = parsed.data;
await db.transaction(async (tx) => { const [claimed] = await tx .insert(actionClaims) .values({ orgId, idempotencyKey }) .onConflictDoNothing({ target: [actionClaims.orgId, actionClaims.idempotencyKey] }) .returning({ id: actionClaims.id });
if (!claimed) return;
await tx.insert(invoices).values({ orgId, ...input }); });
revalidatePath('/invoices'); return ok(null);}The constraint is scoped to the tenant. The unique is composite, (orgId, idempotencyKey), not the key alone. Two different orgs can independently generate the same UUID string. That is rare, but “rare” isn’t “never,” and a security boundary can’t rely on luck. Scoping by orgId means one org’s key can never collide with another’s. This is the same composite-key reasoning behind (provider, eventId) from the dedup lesson.
One deliberate choice is worth a sentence. The key lives in its own idempotencyKey column with a scoped unique constraint, and it is not the row’s primary key. You could make the client-supplied UUID the PK and get dedup for free, but the course keeps them separate on purpose. The primary key is a UUIDv7 the database owns (time-ordered, good for index locality), and the dedup identity is a thing the client owns. Decoupling them lets the row’s identity stay independent of how you happened to deduplicate the request, and lets the table carry other constraints without entangling them with the PK.
Notice that this is the same rule as the broken-versus-correct example, now in a real setting. A server-generated key would be a fresh value on every submit, which is useless. The whole game is stability across the submit and its retry, and the only place that stability can come from is the render that produced the form. The client is the source, React 19 carries the value through, and the action reads it.
Retried background jobs: the stable run ID
Section titled “Retried background jobs: the stable run ID”The third surface is the cleanest instance of the pattern, because you write no key-generation code at all. You’ll build real jobs later, on Trigger.dev, so treat this as a quick sighting rather than a deep dive. The point here is to add the third data point.
When a job runtime retries a run after a timeout or a crash, it gives the retry the same run ID as the original attempt. That ID is your key, handed to you for free: the runtime is the source that owns “this attempt,” and a retry is the replay. The job body inserts its results keyed by that ID, and a retry conflicts exactly like every other surface:
await db .insert(jobResults) .values({ runId: ctx.run.id, output }) .onConflictDoNothing({ target: [jobResults.runId] });That’s the move in two lines. The run ID is the namespace, the same idea you’ll meet again in the naming convention for job idempotency keys (${ctx.run.id}:${step}), where the run ID prefixes each step’s key. We’ll go deep on job retries, queues, and the run lifecycle when you get to background work. For now, notice that nothing about the shape changed: key, unique constraint, conflict means done.
Public route handlers: the Idempotency-Key header
Section titled “Public route handlers: the Idempotency-Key header”Now the richest surface, and the one where the four genuinely diverge in behavior. A public POST endpoint, say /api/invoices, called by external integrations, accepts an Idempotency-Key HTTP header: an opaque, client-chosen token that lets the server recognize a retried request as the same attempt. The client generates it (crypto.randomUUID() is fine, and any re-sendable string works), sends it on the first call, and sends the same one if it has to retry. This is a real, widely adopted convention, not something the course invented; Stripe’s own API works exactly this way.
Everything you’ve seen so far still applies: key from the source, unique constraint, claim-and-work in one transaction. But there’s a twist that only shows up here, and it’s the whole reason this surface gets its own section.
On a replay, the other three surfaces short-circuit to “already done, here’s a success.” They don’t reconstruct what the first attempt returned; they just confirm it happened. A public API can’t get away with that. The client on the other end is waiting for a response body, an invoice id or a created object, and if its first request actually succeeded but the response got lost on the way back, the retry has to return the byte-identical response the first attempt produced. Not a fresh “ok,” but the same bytes, even if the underlying state has moved on since.
So this surface caches the response, not just the claim. You store the status and the response body on the claim row, and you replay them verbatim. The point to hold onto is that idempotency caches the response because the response is the contract the client trusts. The client asked a question once, and it must get the same answer however many times the network makes it ask.
Here’s the shape: the same skeleton as the webhook, with a response cache added to the claim row.
export async function POST(request: Request) { const idempotencyKey = request.headers.get('Idempotency-Key'); if (!idempotencyKey) { return Response.json( { type: 'about:blank', title: 'Idempotency-Key header is required', status: 400 }, { status: 400, headers: { 'content-type': 'application/problem+json' } }, ); }
const { clientId } = await authenticate(request); const parsed = createInvoiceSchema.safeParse(await request.json()); if (!parsed.success) { return Response.json( { type: 'about:blank', title: 'Invalid invoice payload', status: 422 }, { status: 422, headers: { 'content-type': 'application/problem+json' } }, ); }
return db.transaction(async (tx) => { const [claimed] = await tx .insert(idempotencyKeys) .values({ clientId, idempotencyKey }) .onConflictDoNothing({ target: [idempotencyKeys.clientId, idempotencyKeys.idempotencyKey] }) .returning({ id: idempotencyKeys.id });
if (!claimed) { const [prior] = await tx .select({ status: idempotencyKeys.status, responseBody: idempotencyKeys.responseBody }) .from(idempotencyKeys) .where(and(eq(idempotencyKeys.clientId, clientId), eq(idempotencyKeys.idempotencyKey, idempotencyKey))); return Response.json(prior.responseBody, { status: prior.status }); }
const [invoice] = await tx.insert(invoices).values({ clientId, ...parsed.data }).returning(); await tx .update(idempotencyKeys) .set({ status: 201, responseBody: invoice }) .where(eq(idempotencyKeys.id, claimed.id)); return Response.json(invoice, { status: 201 }); });}Require the key, and reject if it’s missing. A missing-but-required key is a 400 in RFC 9457 problem+json, the same error contract you use everywhere else. It is not a silent success, because silently succeeding would let a non-idempotent client double-charge and never know. If the contract says the header is required, enforce it.
export async function POST(request: Request) { const idempotencyKey = request.headers.get('Idempotency-Key'); if (!idempotencyKey) { return Response.json( { type: 'about:blank', title: 'Idempotency-Key header is required', status: 400 }, { status: 400, headers: { 'content-type': 'application/problem+json' } }, ); }
const { clientId } = await authenticate(request); const parsed = createInvoiceSchema.safeParse(await request.json()); if (!parsed.success) { return Response.json( { type: 'about:blank', title: 'Invalid invoice payload', status: 422 }, { status: 422, headers: { 'content-type': 'application/problem+json' } }, ); }
return db.transaction(async (tx) => { const [claimed] = await tx .insert(idempotencyKeys) .values({ clientId, idempotencyKey }) .onConflictDoNothing({ target: [idempotencyKeys.clientId, idempotencyKeys.idempotencyKey] }) .returning({ id: idempotencyKeys.id });
if (!claimed) { const [prior] = await tx .select({ status: idempotencyKeys.status, responseBody: idempotencyKeys.responseBody }) .from(idempotencyKeys) .where(and(eq(idempotencyKeys.clientId, clientId), eq(idempotencyKeys.idempotencyKey, idempotencyKey))); return Response.json(prior.responseBody, { status: prior.status }); }
const [invoice] = await tx.insert(invoices).values({ clientId, ...parsed.data }).returning(); await tx .update(idempotencyKeys) .set({ status: 201, responseBody: invoice }) .where(eq(idempotencyKeys.id, claimed.id)); return Response.json(invoice, { status: 201 }); });}Claim under the scoped unique. Same atomic claim, scoped to (clientId, idempotencyKey) so one customer’s keys can’t collide with another’s. Zero rows back means a replay.
export async function POST(request: Request) { const idempotencyKey = request.headers.get('Idempotency-Key'); if (!idempotencyKey) { return Response.json( { type: 'about:blank', title: 'Idempotency-Key header is required', status: 400 }, { status: 400, headers: { 'content-type': 'application/problem+json' } }, ); }
const { clientId } = await authenticate(request); const parsed = createInvoiceSchema.safeParse(await request.json()); if (!parsed.success) { return Response.json( { type: 'about:blank', title: 'Invalid invoice payload', status: 422 }, { status: 422, headers: { 'content-type': 'application/problem+json' } }, ); }
return db.transaction(async (tx) => { const [claimed] = await tx .insert(idempotencyKeys) .values({ clientId, idempotencyKey }) .onConflictDoNothing({ target: [idempotencyKeys.clientId, idempotencyKeys.idempotencyKey] }) .returning({ id: idempotencyKeys.id });
if (!claimed) { const [prior] = await tx .select({ status: idempotencyKeys.status, responseBody: idempotencyKeys.responseBody }) .from(idempotencyKeys) .where(and(eq(idempotencyKeys.clientId, clientId), eq(idempotencyKeys.idempotencyKey, idempotencyKey))); return Response.json(prior.responseBody, { status: prior.status }); }
const [invoice] = await tx.insert(invoices).values({ clientId, ...parsed.data }).returning(); await tx .update(idempotencyKeys) .set({ status: 201, responseBody: invoice }) .where(eq(idempotencyKeys.id, claimed.id)); return Response.json(invoice, { status: 201 }); });}Replay path: return the cached response. This is the surface’s signature move. On a replay, read the stored status and responseBody off the row and return them verbatim, the exact bytes the first call sent back, even if the world has changed since.
export async function POST(request: Request) { const idempotencyKey = request.headers.get('Idempotency-Key'); if (!idempotencyKey) { return Response.json( { type: 'about:blank', title: 'Idempotency-Key header is required', status: 400 }, { status: 400, headers: { 'content-type': 'application/problem+json' } }, ); }
const { clientId } = await authenticate(request); const parsed = createInvoiceSchema.safeParse(await request.json()); if (!parsed.success) { return Response.json( { type: 'about:blank', title: 'Invalid invoice payload', status: 422 }, { status: 422, headers: { 'content-type': 'application/problem+json' } }, ); }
return db.transaction(async (tx) => { const [claimed] = await tx .insert(idempotencyKeys) .values({ clientId, idempotencyKey }) .onConflictDoNothing({ target: [idempotencyKeys.clientId, idempotencyKeys.idempotencyKey] }) .returning({ id: idempotencyKeys.id });
if (!claimed) { const [prior] = await tx .select({ status: idempotencyKeys.status, responseBody: idempotencyKeys.responseBody }) .from(idempotencyKeys) .where(and(eq(idempotencyKeys.clientId, clientId), eq(idempotencyKeys.idempotencyKey, idempotencyKey))); return Response.json(prior.responseBody, { status: prior.status }); }
const [invoice] = await tx.insert(invoices).values({ clientId, ...parsed.data }).returning(); await tx .update(idempotencyKeys) .set({ status: 201, responseBody: invoice }) .where(eq(idempotencyKeys.id, claimed.id)); return Response.json(invoice, { status: 201 }); });}First-call path: do the work, then cache the answer. The winner does the work, writes the status and body back onto the claim row, and returns. Because it’s all one transaction, the cached response and the invoice commit together, so a replay can never read a half-finished cache.
That’s the worked shape. In production this route would sit inside the authedRoute wrapper you’ll meet later, which lifts the auth and parsing out of the body, but the dedup-and-cache core stays exactly as you see it. Two policy calls go with it, and they’re judgment, not boilerplate.
Which endpoints require the header. Not every POST. The key earns its weight on writes with external side effects, like payments, sends, and creates, the operations that would be genuinely bad to run twice. It’s pointless on operations that are already idempotent by nature: a PUT that replaces a value with the same value, or a DELETE of something already gone. So require it where double-execution hurts, and write that requirement into your API contract. The worst thing you can do is accept the header and ignore it, because that makes the contract a lie, and a client that trusts your “we’re idempotent” docs will retry straight into a double-charge. Implement it or remove it.
How long the cache lives. Bounded, never forever. 24 hours is the common contract: long enough to cover any sane retry window, short enough that you’re not hoarding response bodies indefinitely. Document the horizon, and remember that storing response bodies means storing user-visible data, so the same retention sweep and PII rules you apply to processed_events apply here too. A cache of responses is still a pile of customer data, so treat it like one.
This is also the most concrete instance of the pattern, which makes it the one worth getting your hands on. In the drill below, one request has already been claimed and answered: ('acme', 'req-7f3a') is sitting in the table with its cached 201 response. A new request arrives carrying a fresh key, req-9b2c, and your job is to write the atomic claim for it: the onConflictDoNothing insert targeting the composite unique, with .returning(), and return its result. Because that key is new, nothing conflicts, so your claim returns one row, and the row you win is the green “do the work, then cache the answer” path the handler takes the first time it sees a request.
The seeded row is request req-7f3a for client acme — already claimed and answered. A fresh request arrives carrying key req-9b2c for the same client. Claim it with the atomic insert: ON CONFLICT DO NOTHING on the composite unique (client_id, idempotency_key), then RETURNING the id. Because the key is new, nothing conflicts and your claim returns one row — you won it, so the handler does the work. Then point idempotencyKey back at the seeded req-7f3a and re-run: zero rows come back, the lost-claim path where the handler replays the cached response instead.
View schema & seed rows
export const idempotencyKeys = pgTable(
'idempotency_keys',
{
id: integer('id').primaryKey(),
clientId: text('client_id').notNull(),
idempotencyKey: text('idempotency_key').notNull(),
status: integer('status'),
responseBody: text('response_body'),
},
(t) => [
unique('idempotency_keys_client_key_unique').on(
t.clientId,
t.idempotencyKey,
),
],
); INSERT INTO idempotency_keys (id, client_id, idempotency_key, status, response_body) VALUES
(1, 'acme', 'req-7f3a', 201, '{"id":42}'); - Query returns the 1 expected row (any order)
Now flip it around: change idempotencyKey from req-9b2c to the seeded req-7f3a and run again. This time zero rows come back, because you lost the claim: the unique constraint refused the duplicate and DO NOTHING swallowed it into an empty result. That empty result is precisely the moment the handler stops and replays the prior answer instead of charging the card again.
The full claim you just completed:
return await db .insert(idempotencyKeys) .values({ id: 2, clientId: 'acme', idempotencyKey: 'req-9b2c' }) .onConflictDoNothing({ target: [idempotencyKeys.clientId, idempotencyKeys.idempotencyKey], }) .returning({ id: idempotencyKeys.id });Signatures prove provenance; keys prove sameness
Section titled “Signatures prove provenance; keys prove sameness”I flagged this near the top, and now it gets its own section, because conflating these two is a classic and costly mistake. An incoming request raises two independent questions, and idempotency answers only one of them.
The first question is provenance : who is this from? A webhook answers it with a signature, where the HMAC proves the bytes came from Stripe and nobody else. A public route answers it with auth, where a token proves which client is calling. The second question is attempt identity: is this the same attempt I’ve already processed? That’s the key’s job, and only the key’s job.
These are orthogonal axes. They compose, and neither one covers for the other.
event.id proves it’s not a duplicate. A public route
answers both too — auth proves the client, the header proves sameness.
A real webhook lives at the intersection: the signature says Stripe sent this, and event.id says and I haven’t seen this one before. A valid signature tells you nothing about whether you’ve already processed the event, because Stripe will happily sign the same event five times. A matching idempotency key tells you nothing about whether the sender is who they claim, because anyone can guess or reuse a key string. You need both axes, every time, on any untrusted POST. The trap to avoid is thinking “the signature already makes it safe, so I can skip the dedup.” It doesn’t. Provenance is not deduplication.
Quick check before moving on.
Your handler verifies a webhook’s Stripe signature and it passes. Going on the signature alone, which conclusion are you entitled to draw?
A signature answers exactly one question — provenance: it proves the bytes are genuinely from Stripe and weren’t tampered with in transit. It says nothing about sameness. Stripe re-signs the identical event on every redelivery, so a valid signature is no promise of at-most-once execution or first-time delivery — that is the idempotency key’s job, checked against processed_events. And it carries no notion of recency either; that is a third, separate concern (ordering, decided by an event timestamp). Provenance, dedup, and ordering are three orthogonal axes; a signature only covers the first.
Pick the smallest version that works
Section titled “Pick the smallest version that works”One more piece of judgment before the recap, and it’s the one beginners most often get wrong in the other direction: reaching for the idempotency table reflexively, on every write, forever. Don’t. An explicit idempotencyKey column is overhead the row carries for the rest of its life: a column to store, an index to maintain, and a value the client must remember to send. You add it when it pays for itself, and not before.
Often the dedup is already done for you, by a constraint you needed anyway. If a table already has a natural domain unique, such as (orgId, slug), an email, or (orgId, name), then “this can’t happen twice” is already enforced. A double-submit conflicts on the natural key, with zero extra columns and zero extra thought. The constraint you added for correctness is doing idempotency on the side.
So run this decision before you add a key:
- A natural unique already exists and matches the operation’s identity → use it. No key column. A duplicate conflicts on the natural key for free.
- No natural unique, and the operation would be bad to do twice (charge, send, create-without-natural-key) → add the scoped
idempotencyKeycolumn. - The operation is naturally idempotent (PUT replacing a value, DELETE, setting a field) → nothing needed. Running it twice already lands in the same place.
Now practice the rule until it’s automatic. For each operation below, decide whether a natural unique already covers it, it needs an explicit idempotency key, or it’s naturally idempotent and needs neither.
For each operation, decide whether a natural unique already does the dedup, it needs an explicit idempotency key, or running it twice already lands in the same place. Drag each item into the bucket it belongs to, then press Check.
That judgment is the senior part. The mechanism is easy, and knowing when not to reach for it is what keeps your schema honest.
One pattern, four surfaces
Section titled “One pattern, four surfaces”Here is the grid from the middle of the lesson, now filled in completely. If you remember one image from this lesson, make it this one.
Read the grid one more time and let it collapse into a single sentence, because that sentence is what you actually leave with:
Idempotency is a key, a unique constraint, and atomic claim-and-work. Choose the key that names the attempt, scope it to its owner, and let the database, not your application code, be the thing that enforces “once.”
You wrote it for webhooks three lessons ago. It’s the same move for a double-clicked form, a retried job, and a flaky client’s POST. As a last check, match each surface to where its key is born.
Match each surface to where its idempotency key is born — the source that owns the definition of 'this attempt'. Click an item on the left, then its match on the right. Press Check when done.
event.id, re-sent on every redeliveryrunId, reused on every retry of the runIdempotency-Key header, resent on a timeout retryExternal resources
Section titled “External resources”If you want to see this pattern in the wild, particularly the response-caching route-handler surface, these are the canonical references.
Stripe's production implementation — the header, the response replay, and the 24-hour retention window in practice.
Brandur Leach's canonical deep dive — the atomic claim-and-cache design, in the same Postgres terms this lesson uses.
The IETF standards-track draft that defines the Idempotency-Key request header, widely implemented ahead of ratification.
The API behind the atomic claim — onConflictDoNothing with a target, and why .returning() comes back empty on a conflict.