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.

antigravity435typescript26idempotency11dedupe-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 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.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.

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.

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-06-28
When Streaming Works Locally but Arrives All at Once in Production — Field Notes on Proxy Buffering and Silent Stalls
Stream Gemini through Antigravity over SSE and it flows token-by-token on localhost, then freezes for seconds and dumps the whole answer in production. Field notes on measuring the stall first, then killing proxy buffering, idle disconnects, and reconnect-driven double generation.
App Dev2026-05-01
Drawing the Server/Client Boundary with Antigravity — A Workflow That Stops Next.js 16 RSC From Tripping You Up
Drawing the line between Server and Client Components is still the trickiest part of Next.js 16. Here's the practical workflow I use with Antigravity to stop misplaced `use client` directives and serialization errors before they happen.
App Dev2026-05-01
Zero-Downtime Database Migrations with Antigravity: The Expand-Contract Pattern in Production
A complete production guide to running breaking schema changes—type swaps, column renames, table splits—with zero user-facing downtime, using the Expand-Contract pattern with Antigravity's AI assistance.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →