Building Idempotency Keys and Dedupe Stores in TypeScript with Antigravity
A production guide to designing idempotency keys and dedupe stores in TypeScript with Antigravity — covering Stripe webhook retries, Temporal replays, and the Cloudflare KV / Redis / Postgres trade-offs you actually need to choose between.
One morning, an email lands in my inbox: "I was charged twice for the same order." I open the Stripe dashboard, and sure enough, the same payment_intent.succeeded event has been processed twice. Digging through logs, I find that my first webhook handler took just a hair too long to respond, the response went out at the timeout boundary, and Stripe's retry queue had already fired by the time the 200 arrived. From my server's perspective, both deliveries looked like fresh, valid events. This is a true story from one of my own side projects, and the root cause was embarrassingly simple — I had no idempotency layer rejecting events that had already been processed.
When you build features like payments, inventory, point grants, or user signup that integrate with external systems like Stripe, Temporal, or Inngest, idempotency and dedupe stores are the single most important piece of infrastructure between you and a 3 AM incident channel. This guide walks through how I design that layer in TypeScript, with Antigravity's AI agents as a co-pilot. The examples target Cloudflare Workers + Hono, but the patterns apply equally well to Node.js, Bun, or Vercel.
Four Real Scenarios Where Idempotency Breaks
Talking about idempotency in the abstract makes it feel optional. So before any code, here are four concrete failure modes I have either lived through or had to debug for someone else. As you read, picture which one of these is currently lurking in your own codebase.
1. Webhook retries duplicating events. Stripe, GitHub, Slack, and most webhook senders keep retrying until they receive a 2xx. If your handler is slow, momentarily errors out, or response timing crosses the timeout boundary, the same event.id will arrive multiple times. Stripe in particular retries with exponential backoff for up to three days.
2. Workflow engines replaying activities. Durable execution engines like Temporal and Inngest replay event history when a worker crashes. Activity functions are explicitly expected to handle being called more than once with the same input. Side effects like Stripe charges or transactional emails will fire twice unless you make them idempotent yourself. The same idea is explored in Building Event-Driven AI Workflows with Antigravity and Inngest.
3. Client double-submission. Users tap "Buy" twice in a flaky cellular connection. The browser auto-retries a hung POST. Two identical requests reach your API. Stripe's API protects callers via the Idempotency-Key header — when you build your own API, you have to recreate that contract for your clients.
4. Concurrent racing logic. Imagine a "first-time signup bonus" flow. Two parallel requests from the same user both check "has this user been awarded yet?" and both see false before either of them writes. They both grant the bonus. Strictly speaking this is a Time-of-Check-to-Time-of-Use (TOCTOU) race, but the cure is the same: a centralized dedupe store.
The shared remedy across all four is a remarkably simple shape: assign every operation a unique idempotency key, then use an external dedupe store to atomically answer "is this key already taken?" The rest of the guide is about doing each half of that well.
Designing the Key — What You Hash Matters
The most common mistake I see is choosing the wrong granularity for the key itself. Make it too coarse and you reject legitimate operations as duplicates; make it too fine and duplicates slip through. Sketch the design before you reach for the keyboard.
Three Sources for the Key
Idempotency keys come from one of three places, and choosing the right source is half the battle.
Sender-supplied keys. Stripe's event.id, GitHub's X-GitHub-Delivery, Slack's X-Slack-Request-Timestamp plus signature — many vendors already mint a unique ID per event. Trust this when it exists. It is by definition unique and stable.
Client-supplied keys. For your own APIs, ask the client to mint a UUIDv4 and pass it via an Idempotency-Key header. Mirroring Stripe's contract reduces the cognitive load for anyone who has integrated with Stripe before.
Business-derived keys. Synthesize a key from the operation's natural uniqueness, e.g. order_${userId}_${productId}_${date}, then SHA-256 hash it. Use this when you cannot trust the client and the upstream lacks an event ID.
Granularity in Three Dimensions
I find it useful to think about granularity along the subject × object × time axes:
Subject — Per-user? Per-tenant? Per-session?
Object — Order, charge, email send, message post?
Time — Forever? Within 24 hours? Within the same business day?
For example, "award a campaign bonus" usually wants subject=user × object=campaignId × time=forever, while "send a welcome email" is more realistic at subject=user × object=templateId × time=24 hours. Start strict (forever) and loosen as concrete requirements appear; the failure mode of going strict-to-lax is recoverable, while lax-to-strict often is not.
A TypeScript Implementation
Translating that design into code:
// src/lib/idempotency/key.tsimport { createHash } from "node:crypto";export type KeyScope = | { type: "external"; eventId: string } // e.g. Stripe.event.id | { type: "client"; key: string } // X-Idempotency-Key header | { type: "business"; parts: readonly string[] }; // ["order", userId, productId]const NS = "antigravity-app"; // namespace — never collide with another app/** * Normalize an idempotency key. * external -> trust upstream uniqueness * client -> sha256-hash to fix length * business -> deterministic hash of the parts */export function buildIdempotencyKey(scope: KeyScope): string { switch (scope.type) { case "external": return `${NS}:ext:${scope.eventId}`; case "client": { const h = createHash("sha256").update(scope.key).digest("hex"); return `${NS}:cli:${h.slice(0, 32)}`; } case "business": { const joined = scope.parts.join("|"); const h = createHash("sha256").update(joined).digest("hex"); return `${NS}:biz:${h.slice(0, 32)}`; } }}const k1 = buildIdempotencyKey({ type: "external", eventId: "evt_1NXXX..." });const k2 = buildIdempotencyKey({ type: "business", parts: ["order", "user_123", "product_456", "2026-05-03"],});
Three things matter here. First, always namespace the key — sharing a Redis instance across apps without a namespace is how you accidentally overwrite another app's keys. Second, hash to a fixed length so that arbitrary client strings cannot blow past your store's size limits. Third, prefix the source (ext / cli / biz) so that when something goes sideways, your logs let you tell exactly which kind of duplication you are looking at.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Stop double-charging customers when Stripe retries the same webhook event — copy a working dedupe pattern straight into your handler today
✦Choose the right backing store (Cloudflare KV / Upstash Redis / PostgreSQL) for your workload, with concrete trade-offs for QPS, consistency, and cost
✦Wire idempotency from Temporal and Inngest workflows down through your application layer so the same key protects every layer of your system
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
With a key strategy in place, you need an atomic "is this taken?" check. There are four mainstream stores, each with different consistency and cost trade-offs. Pick the one that matches your workload, not the one that sounds coolest.
Pattern 1: Cloudflare KV — Edge-Native, Eventually Consistent
If you are running Cloudflare Workers with Hono, KV is by far the easiest store to wire up. The catch: KV is eventually consistent, with up to a 60-second window before writes propagate globally. That is fine for "two requests from the same region back-to-back" but inadequate for "thousands of clients hitting different POPs concurrently." For practical webhook handlers see Building Webhook Endpoints That Never Drop a Message in Antigravity.
// src/lib/idempotency/kv-store.tsimport type { KVNamespace } from "@cloudflare/workers-types";export class KvDedupeStore { constructor(private readonly kv: KVNamespace, private readonly ttlSec = 86400) {} /** * Returns true if we acquired the slot (=process this request), * false if the key already exists (=duplicate). * KV has no compare-and-swap; this is best-effort. Use Postgres or * Durable Objects when you need strict exactly-once. */ async tryAcquire(key: string, payload: { startedAt: number }): Promise<boolean> { const existing = await this.kv.get(key); if (existing !== null) return false; await this.kv.put(key, JSON.stringify(payload), { expirationTtl: this.ttlSec }); return true; } async markCompleted(key: string, result: unknown): Promise<void> { const value = JSON.stringify({ status: "completed", result, completedAt: Date.now() }); await this.kv.put(key, value, { expirationTtl: this.ttlSec }); } async getRecord<T>(key: string): Promise<T | null> { const v = await this.kv.get(key, "json"); return (v as T) ?? null; }}
The fatal weakness of KV is the gap between get and put in tryAcquire. Two concurrent callers can both read null and both decide they are first. For low-concurrency cases (a personal SaaS receiving a few webhooks per minute) this is fine. Above three-figure QPS, do not ship this.
Pattern 2: Cloudflare Durable Objects — Strong Consistency at the Edge
Durable Objects sidestep KV's race entirely: each key maps to a single DO instance, which serializes all reads and writes atomically. There is no race window, period.
// src/lib/idempotency/durable-object.tsexport class DedupeDO { private state: DurableObjectState; constructor(state: DurableObjectState) { this.state = state; } async fetch(request: Request): Promise<Response> { const url = new URL(request.url); const key = url.searchParams.get("k"); if (!key) return new Response("missing key", { status: 400 }); if (request.method === "POST" && url.pathname === "/acquire") { // Storage inside a DO is always strongly consistent. const existing = await this.state.storage.get(key); if (existing) return Response.json({ acquired: false }); await this.state.storage.put(key, { status: "in_progress", at: Date.now() }); return Response.json({ acquired: true }); } if (request.method === "POST" && url.pathname === "/complete") { const body = (await request.json()) as { result: unknown }; await this.state.storage.put(key, { status: "completed", result: body.result, at: Date.now(), }); return Response.json({ ok: true }); } return new Response("not found", { status: 404 }); }}async function withDedupe(env: Env, key: string, fn: () => Promise<unknown>) { const id = env.DEDUPE_DO.idFromName(key); // same key -> same instance const stub = env.DEDUPE_DO.get(id); const acq = await stub.fetch(`https://internal/acquire?k=${encodeURIComponent(key)}`, { method: "POST", }); const { acquired } = (await acq.json()) as { acquired: boolean }; if (!acquired) return { skipped: true }; const result = await fn(); await stub.fetch(`https://internal/complete?k=${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify({ result }), }); return { skipped: false, result };}
DOs are pinned to a region per key, so they are not as latency-friendly globally as KV, and they cost more per call. The pragmatic split I usually recommend: use DOs for must-not-double money paths, use KV for low-stakes deduplication.
Pattern 3: Upstash Redis — SET NX EX Atomicity
Upstash exposes Redis over HTTP, so it is reachable from Edge Runtime as well as Node. SET key value NX EX 86400 is a single atomic command that means "create only if absent, expire in 86,400 seconds." That single command makes most race concerns disappear.
// src/lib/idempotency/redis-store.tsimport { Redis } from "@upstash/redis";export class RedisDedupeStore { constructor(private readonly redis: Redis, private readonly ttlSec = 86400) {} /** * Atomic reservation via SET NX EX. * "acquired" -> first writer; run the work * "in_progress" -> another worker is processing; poll or 200-fast * "completed" -> already done; return the cached response */ async tryAcquire(key: string): Promise<"acquired" | "in_progress" | "completed"> { const set = await this.redis.set( key, JSON.stringify({ status: "in_progress", at: Date.now() }), { nx: true, ex: this.ttlSec }, ); if (set === "OK") return "acquired"; const existing = await this.redis.get<{ status: string }>(key); if (existing?.status === "completed") return "completed"; return "in_progress"; } async markCompleted(key: string, result: unknown): Promise<void> { await this.redis.set( key, JSON.stringify({ status: "completed", result, at: Date.now() }), { ex: this.ttlSec }, ); } async getResult<T>(key: string): Promise<T | null> { const v = await this.redis.get<{ status: string; result?: T }>(key); return v?.status === "completed" ? (v.result ?? null) : null; }}
This is my default. The reasons: latency is better than KV (5–30 ms even over HTTP), it is strongly consistent, and you get pub/sub, sorted sets, and a hundred other primitives if you ever need them. It runs in both Edge and Node runtimes, so the code travels with you when you migrate from Cloudflare to Vercel and back.
Pattern 4: PostgreSQL — INSERT ... ON CONFLICT DO NOTHING
The most boring choice, and often the right one. With Postgres you can wrap the dedupe check and your business logic in a single transaction, eliminating the "I reserved the slot but the business logic blew up" twilight zone.
-- migrations/idempotency.sqlCREATE TABLE idempotency_records ( key TEXT PRIMARY KEY, status TEXT NOT NULL CHECK (status IN ('in_progress','completed','failed')), request_hash TEXT NOT NULL, response_body JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '24 hours'));CREATE INDEX idx_idem_expires ON idempotency_records(expires_at);
// src/lib/idempotency/postgres-store.tsimport type { Pool } from "pg";export class PgDedupeStore { constructor(private readonly pool: Pool) {} async tryAcquire(key: string, requestHash: string) { // Only the row that wins INSERT is "the reserver". Others are no-ops. const inserted = await this.pool.query( `INSERT INTO idempotency_records (key, status, request_hash) VALUES ($1, 'in_progress', $2) ON CONFLICT (key) DO NOTHING RETURNING key`, [key, requestHash], ); if (inserted.rowCount === 1) return { acquired: true } as const; const { rows } = await this.pool.query( `SELECT status, response_body, request_hash FROM idempotency_records WHERE key = $1`, [key], ); const r = rows[0]; if (r.request_hash !== requestHash) { // Same key + different body -> client bug; surface it loudly. throw new Error("Idempotency-Key reused with different request body"); } return { acquired: false, status: r.status as "in_progress" | "completed" | "failed", response: r.response_body, } as const; } async markCompleted(key: string, response: unknown) { await this.pool.query( `UPDATE idempotency_records SET status = 'completed', response_body = $2, updated_at = now() WHERE key = $1`, [key, response], ); }}
Storing request_hash and comparing on retries is a Stripe-grade trick. "Same key, different body" almost certainly indicates a client bug, and surfacing it early saves hours of incident-response time.
Choosing Between Them — Practical Heuristics
Listing the patterns is not enough. Here are the rules of thumb I use when picking:
Personal SaaS, < 10 QPS — Cloudflare KV. Free tier handles it, deployment is trivial.
Stripe webhooks and payment paths — Postgres or Durable Objects. You want the dedupe check inside the same transaction as the business write.
Job queues and background workflows — Upstash Redis. Atomic SET NX, fast, runs everywhere.
Multi-tenant SaaS at three-digit QPS or above — Postgres with partitioning, or Redis Cluster. KV scales fine for reads but bottlenecks under bursty global writes.
A Test-Driven Build with Antigravity
Now to actually write the Postgres store with Antigravity in the loop. If you have ever felt nervous shipping AI-generated code, this flow is for you. With tests in place first, the AI's output gets evaluated by a program every time, not by a tired human at midnight.
Step 1: Have Antigravity write failing tests
Open the agent panel and prompt:
Create src/lib/idempotency/postgres-store.test.ts.Test the PgDedupeStore class methods tryAcquire and markCompleted usingpg-mem (in-memory Postgres). Cover:1. First call returns acquired: true2. Second call with same key + same request_hash returns acquired: false, status: "in_progress"3. Third call after completion returns status: "completed", response_body matches4. Same key + different request_hash throws "Idempotency-Key reused..."5. 10 concurrent calls with the same key result in exactly one acquired: trueEach test should have // arrange, // act, // assert comments.
Antigravity's agent will typically scaffold pg-mem setup as well. If your dependencies are stale, ask it to bump @types/pg in package.json first; otherwise the type errors will bury the actual logic you care about reviewing.
Step 2: Run the tests first to confirm Red
$ npm test -- src/lib/idempotency/postgres-store.test.ts FAIL src/lib/idempotency/postgres-store.test.ts PgDedupeStore × tryAcquire returns acquired on first call × tryAcquire returns in_progress on duplicate ... Tests: 5 failed, 5 total
Confirm Red before moving on. If tests pass at this stage, the mocks are wrong or the implementation is already there — either way, you cannot trust the next step until you fix it.
Step 3: Have Antigravity make them Green
Create src/lib/idempotency/postgres-store.ts.Write the minimal implementation that passes@/src/lib/idempotency/postgres-store.test.ts. No transactions —use INSERT ... ON CONFLICT DO NOTHING. Add a one-line comment foreach line explaining "why".
In recent Antigravity releases, the "implement against existing tests" mode has gotten reliable. Most of the time I get a passing implementation in 3–5 round trips. The most common stumble is markCompleted reaching for INSERT again rather than UPDATE; specifying "transition in_progress -> completed via UPDATE" up front avoids that.
Step 4: Concurrency integration test
You can simulate concurrency in unit tests with Promise.all, but if you really want to trust production, spin up a real Postgres in a separate integration suite.
// tests/integration/concurrent-acquire.test.tsimport { describe, expect, it } from "vitest";import { Pool } from "pg";import { PgDedupeStore } from "../../src/lib/idempotency/postgres-store";describe("PgDedupeStore (integration)", () => { it("100 parallel callers acquire exactly once", async () => { const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const store = new PgDedupeStore(pool); const key = `test:${Date.now()}`; const hash = "abc"; const results = await Promise.all( Array.from({ length: 100 }, () => store.tryAcquire(key, hash)), ); const acquired = results.filter((r) => r.acquired).length; expect(acquired).toBe(1); await pool.end(); });});
Once that test goes green, you have earned the right to sleep through a webhook stampede.
Wiring It into Temporal and Inngest
The application layer is necessary but not sufficient. Pair it with workflow-engine-aware idempotency.
Idempotent Temporal activities
Temporal is a durable execution engine, but activities are explicitly designed to be re-invoked. Pass the idempotency key as an explicit argument so it is part of the activity's contract. For deeper architectural patterns see Antigravity × Temporal.io Production Implementation Master Guide.
// activities/charge-payment.tsimport { PgDedupeStore } from "../src/lib/idempotency/postgres-store";export async function chargePayment(input: { idempotencyKey: string; // workflow ID + activity name + retry count userId: string; amount: number;}): Promise<{ chargeId: string }> { const store = new PgDedupeStore(getPool()); const hash = sha256(`${input.userId}|${input.amount}`); const acq = await store.tryAcquire(input.idempotencyKey, hash); if (!acq.acquired && acq.status === "completed") { return acq.response as { chargeId: string }; } const charge = await stripe.charges.create( { amount: input.amount, currency: "jpy", customer: input.userId }, { idempotencyKey: input.idempotencyKey }, // Stripe gets the same key ); await store.markCompleted(input.idempotencyKey, { chargeId: charge.id }); return { chargeId: charge.id };}
The trick: use the same key for your store and Stripe's Idempotency-Key. Whichever side replays, the final state is identical. This pairs naturally with self-recovery patterns like the ones described in Designing Self-Healing Agents in Antigravity.
Inngest step-level idempotency
Inngest deduplicates by step name and arguments natively, but it is wise to belt-and-braces external side effects in your application layer too.
Observability — Knowing the Layer Is Doing Its Job
A dedupe layer that silently misbehaves is worse than no dedupe layer at all. The day you actually need it — the day Stripe sends 50 retries because of a timeout — is also the day you find out whether your instrumentation captured anything useful. Three pieces of telemetry let me sleep better at night.
1. A Counter for Every Outcome
Every call to tryAcquire resolves to one of acquired, in_progress, or completed. Emit a counter for each outcome and tag it with the source (ext / cli / biz). When duplication spikes, you immediately see which sender is to blame.
In a Grafana dashboard I keep two panels next to each other: total calls and the percentage that returned completed. A sudden jump in the second number is almost always a misbehaving upstream — and you want to know within minutes, not the next morning.
2. Latency Histograms per Store
The atomicity gain from Postgres is worthless if every tryAcquire adds 200 ms to your hot path. Wrap the call in a histogram and treat the p99 as a first-class SLI.
A useful target on Postgres is p99 < 25 ms; on Redis, p99 < 15 ms; on KV, p99 < 60 ms. If you blow past those, look first at connection pooling and second at the network path between your worker region and your store region.
3. Distributed Traces Linking the Three Layers
When a workflow re-runs, you want one trace that shows: webhook receipt → dedupe check → activity execution → Stripe API call. With OpenTelemetry that is essentially free if you are already tracing your application. Tag each span with the idempotency_key attribute so you can search for "every request that touched key X" in a single click.
When you finally get a "what happened to my charge?" support ticket, pasting the idempotency key into your trace explorer should bring back the full story in under ten seconds. If it does not, fix the instrumentation before fixing the bug — the next bug is much harder to diagnose without it.
Four Mistakes That Bite You in Production
Even with the right pieces in place, here are four operational traps I have either tripped over myself or watched colleagues fall into. Burn them into your reviewer checklist.
1. TTL too short. "I'll just expire keys after 24 hours" sounds reasonable until Stripe retries on day three and you double-charge. Different senders have very different retry windows (Stripe: up to 3 days, GitHub: up to 8 hours). When in doubt, set TTL to 7 days — the storage cost difference is invisible, and the safety margin is huge.
2. Leaving in_progress rows after a crash. If your business logic throws after tryAcquire, the row stays in in_progress and every future retry sees "still working, skip" forever. Always wrap the body in try/finally and explicitly mark it failed (or delete it) on exception:
const acq = await store.tryAcquire(key, hash);if (!acq.acquired) return acq.response;try { const result = await doBusinessLogic(); await store.markCompleted(key, result); return result;} catch (err) { await store.markFailed(key, String(err)); // <- do not skip this throw err;}
3. Returning 200 too late from webhooks. Even if your dedupe check is fast, a 10-second business workflow afterwards causes Stripe to time out and retry — yielding even more retries to dedupe. The rule: acknowledge the webhook immediately, queue the work. On Cloudflare Workers, use ctx.waitUntil(). On Vercel, fire-and-forget the promise and return the response right away.
4. Using raw client headers as keys. Putting an Idempotency-Key header value directly into Redis lets a hostile client send a 100KB string and pollute your store. The buildIdempotencyKey helper above hashes everything to a fixed length, but add one more layer of defense: a middleware that rejects headers longer than 256 characters before they reach the keying logic.
A Checklist You Can Apply Today
Idempotency lives in the "we'll get to it" pile until you have your first incident. If money flows through your product, the cheapest insurance is to start small and iterate.
First step: dedupe Stripe webhook handlers using event.id as the key with whichever store you picked above (KV, Redis, or Postgres). That single change moves the probability of double-charge incidents to nearly zero.
Once that ships, walk through every other side-effecting path: outbound emails, bonus grants, inventory decrements, push notifications. Assign each a key strategy and a store, and migrate them one at a time.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.