◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-05-03Advanced

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.

antigravity466typescript28idempotency11dedupe-storestripe9temporalcloudflare-kvproduction71

✦ Premium Article

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 a product I run as an indie developer, 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.ts
import { 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.

When the Same Key Arrives with a Different Body, Don't Quietly Replay

There is a hole in the buildIdempotencyKey above that I missed for a long time. The client branch hashes only the key, and never looks at the request body. If a client keeps the same Idempotency-Key but changes the amount or the destination, your server decides "this is a retry" and hands back the previous result. No error, nothing unusual in the logs.

I put the two implementations side by side and captured what each returns when the same key arrives with a different body.

Implementation1st: amount=1200 / acct_A2nd: amount=98000 / acct_B
Hash the key only (as written above)200 charged=1200 / acct_A200 charged=1200 / acct_A (replayed=true)
Key + body fingerprint200 charged=1200 / acct_A409 idempotency_key_reuse

That second request for 98,000 is answered with a stale 1,200 result and recorded nowhere. From the caller's side the response is a 200, so no retry is triggered either. I may be wrong about the ranking, but this failure feels harder to catch than a double charge.

Stripe itself errors in this situation. A request that reuses a key with altered parameters is compared against the original and rejected when they differ, as described in Idempotent requests in the Stripe API reference. Matching Stripe's header name and conventions without matching this rejection leaves you with something that only looks like the same contract.

The fix is to store a fingerprint of the body alongside the result.

// src/lib/idempotency/fingerprint.ts
import { createHash } from "node:crypto";
 
/** Key-order independent stringification */
export function stableStringify(v: unknown): string {
  if (v === null || typeof v !== "object") return JSON.stringify(v);
  if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
  const o = v as Record<string, unknown>;
  const keys = Object.keys(o).sort();
  return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
}
 
export function fingerprint(body: unknown): string {
  return createHash("sha256").update(stableStringify(body)).digest("hex");
}
 
type Saved = { result: unknown; fp: string };
 
export function reserveOrReplay(
  store: Map<string, Saved>,   // swap in KV / Redis / Postgres for real
  key: string,
  body: unknown,
): { status: 200; replayed: boolean; result?: unknown } | { status: 409; code: string } {
  const fp = fingerprint(body);
  const hit = store.get(key);
  if (hit) {
    // Same key, different body = the client is recycling keys
    if (hit.fp !== fp) return { status: 409, code: "idempotency_key_reuse" };
    return { status: 200, replayed: true, result: hit.result };
  }
  return { status: 200, replayed: false };
}

I also measured what the fingerprint costs (Node v22, a 608-byte JSON body, averaged over 200,000 iterations, three runs with the same shape).

OperationPer callThroughput
Key only, sha2561.36 μs~733,000 ops/sec
Fingerprint (JSON.stringify + sha256)5.09 μs~196,000 ops/sec
Fingerprint (recursive stableStringify + sha256)24.64 μs~40,500 ops/sec

The straightforward recursive version costs roughly five times what JSON.stringify does. That surprised me — the stringification dominates, not the sha256 — and even so, 25μs disappears next to a store round trip measured in milliseconds. A fingerprint is judged by whether the same content always produces the same digest, not by how fast it computes. If two bodies that differ only in key order hash differently, you start rejecting legitimate retries with 409. Running the same content with its keys reordered through the code above produced an identical digest.

For TTL, what has worked for me is staying close to Stripe's own 24 hours. When the upstream drops a key after a day and treats anything later as a fresh request, holding yours forever means rejecting as a replay something the upstream already considers new.

✦

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
✦Reproduce the race locally with a zero-install script that shows how many of 50 concurrent requests slip through, then copy a dedupe pattern that survives Stripe retries straight into your handler
✦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, including the lease design that recovers events whose worker died mid-reservation
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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

▣ App Dev2026-05-01
Zero-Downtime Database Migrations with Antigravity: The Expand-Contract Pattern in Production
Run PostgreSQL type swaps, column renames, and table splits without stopping traffic: the Expand-Contract pattern with lock_timeout, chunked backfills, and primary-key swaps, checked with Antigravity's AI assistance.
▣ App Dev2026-04-30
Antigravity × Better Auth: Building a Modern TypeScript Auth Stack End-to-End
End the NextAuth fatigue with Better Auth. A practical Antigravity-driven guide that ships schema generation, OAuth, RBAC, and Passkeys with production-ready patterns.
▣ App Dev2026-04-29
Composing Event-Driven AI Workflows with Antigravity and Inngest — A Production-Ready Implementation Guide
A hands-on production guide to wiring Antigravity's AI agents with Inngest. Cover idempotency, concurrency control, human-in-the-loop, retry classification, and observability with copy-pasteable code.