pragma.vision Your technology observatory

The press · Developer & Security Deep-Dives · filed 2026-06-01 · updated 2026-07-10

API Idempotency Patterns for Financial Operations

Long-form article — composite idempotency keys, KV cache-aside layer, race-window strategies, Stripe webhook HMAC verification, and the production middleware that survives agent retry storms.

#idempotency #api-design #payments #cloudflare-workers #webhooks

The problem

Your checkout endpoint takes a POST. The client sends payment details. Stripe captures the payment. Your server starts composing the 200 response. At T+201ms the connection drops — Wi-Fi switch, load-balancer timeout, mobile-network handoff. The client never sees the 200. From the client’s perspective the request failed. The client retries. The server processes the “new” request, calls Stripe a second time, and Stripe — which does not know the second request is a retry — captures the payment again. The customer sees two charges on their statement and the support ticket lands on Monday morning.

Payment processors report that between 1.5% and 4% of all transaction requests are retries. In AI-agent commerce the rate climbs higher: agents have automated retry loops with exponential backoff and aggressive resubmission. Orchestration layers re-submit entire tasks on timeout. A shopping agent on a $200 purchase with five resubmissions produces a $1,200 charge on the customer’s card, and the agent did nothing wrong — the payment endpoint was unprotected.

Idempotency is not a performance optimization. For financial APIs it is a correctness requirement. This walks through the complete stack: key design, KV cache layer, HMAC webhook verification, nonce replay protection, and the production middleware that survives both human double-clicks and agent retry storms.

Free sample

See the structure and voice before you buy.

Download the sample (PDF)

What most people get wrong

Mistake one: confusing HTTP-method idempotency with financial idempotency. HTTP defines PUT and DELETE as idempotent by specification. Some teams reach for PUT semantics on payment operations, reasoning that the protocol will protect them. PUT idempotency means “replacing the same resource with the same representation produces the same state.” It does not mean “the server will detect and deduplicate payment captures.” The HTTP-layer guarantee is about resource state. Payment idempotency is about side-effect deduplication. They are different problems. A PUT request to /payments/{id} that triggers a Stripe capture will still create duplicate charges if the capture is not guarded by an application-level idempotency mechanism. The protocol gives you nothing here. The application has to do the work.

Mistake two: generating a new idempotency key on every retry. This is the most common implementation bug. The client code calls crypto.randomUUID() at request-send time, which is fine — but then the retry path calls randomUUID() again, generating a fresh key. The server sees two different keys, treats them as two different operations, and processes both. The idempotency layer is bypassed entirely. The fix is structural: the key has to be generated before the retry loop and reused across retries. Either store it in the request object that the retry loop reads, or derive it deterministically from the operation parameters so the same logical operation always produces the same key.

A third pitfall worth noting — failing to verify webhook HMAC signatures, then trusting webhook payloads to drive idempotent state changes. If your payment_succeeded webhook handler updates the order status without verifying the Stripe-Signature header, anyone who knows your endpoint URL can send forged webhooks and flip orders to paid for free. The HMAC verification is not optional — it is the only thing standing between your idempotency layer and an attacker who can replay or fabricate webhook events.

This article is the short version — API Idempotency Patterns for Financial Operations is the full playbook.

Get the ebook — $14

A working approach

The composite key strategy. UUID v4 is collision-resistant but has no semantic meaning — the retry bug above bypasses it. A deterministic hash of (userId, cartId, amount, currency) solves the retry bug but blocks legitimate repeat purchases. The combination is what works:

interface IdempotencyKeyParams {
  clientSessionId: string;  // Stable across retries
  operationType: string;    // "checkout", "refund", etc.
  operationId: string;      // Cart ID, order ID, etc.
  timestamp?: number;       // Optional: unix epoch (hour)
}

function generateCompositeKey(
  params: IdempotencyKeyParams
): string {
  const hourBucket = params.timestamp
    ? Math.floor(params.timestamp / 3600000)
    : Math.floor(Date.now() / 3600000);

  return [
    params.clientSessionId,
    params.operationType,
    params.operationId,
    hourBucket,
  ].join(':');
}

// Example output:
// "sess_abc123:checkout:cart_xyz789:487291"
//
// On retry: same session, same cart, same hour
//   => same key => cached response returned
//
// New checkout next hour: same session, same cart, new hour
//   => different key => processed normally

The composite key solves the two hardest problems simultaneously. A retry with the same parameters generates the same key — duplicate charges prevented. A legitimate repeat operation (new checkout for the same cart after a cancellation) generates a different key because the hour bucket has rolled or the session has rotated. The hour boundary aligns naturally with a 24-hour KV TTL.

Cloudflare KV is the storage layer for the idempotency cache. It is eventually consistent globally but strongly consistent within a region — which is exactly the property you want for a “did I already process this key?” check. The cache-aside pattern:

async function handleIdempotentRequest(
  request: Request,
  env: Env
): Promise<Response> {
  const key = request.headers.get('Idempotency-Key');
  if (!key) {
    return new Response(
      JSON.stringify({ error: 'Idempotency-Key required' }),
      { status: 400 }
    );
  }

  // Check cache
  const cached = await env.IDEMPOTENCY.get(key, 'json');
  if (cached) {
    return new Response(
      JSON.stringify(cached.response),
      {
        status: cached.status,
        headers: { 'Idempotent-Replay': 'true' }
      }
    );
  }

  // Process request
  const result = await processCheckout(request, env);

  // Store result with 24h TTL
  await env.IDEMPOTENCY.put(
    key,
    JSON.stringify({
      response: result.body,
      status: result.status,
      createdAt: Date.now()
    }),
    { expirationTtl: 86400 }
  );

  return new Response(
    JSON.stringify(result.body),
    { status: result.status }
  );
}

The race window between “check cache” and “store result” is the hard part. Two concurrent retries can both miss the cache, both call Stripe, and both write. The book covers three solutions in detail: Stripe’s built-in idempotency keys (which Stripe deduplicates server-side; pass the same key on the second call and Stripe returns the original result), optimistic locking via KV metadata (write a pending record first, fail if another write wins, then promote to completed), and Cloudflare Durable Objects for strong-consistency requirements where the race window must be zero.

HMAC verification on Stripe webhooks. The Stripe-Signature header carries a timestamp and an HMAC-SHA256 over timestamp.payload. Verification from scratch, in a Worker:

async function verifyStripeSignature(
  payload: string,
  signature: string,
  secret: string,
  toleranceSeconds = 300
): Promise<boolean> {
  // Parse "t=<timestamp>,v1=<signature>"
  const parts = Object.fromEntries(
    signature.split(',').map(p => p.split('='))
  );
  const timestamp = parseInt(parts.t, 10);
  const expectedSig = parts.v1;

  // Replay-window check
  const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
  if (ageSeconds > toleranceSeconds) return false;

  // Compute HMAC
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    enc.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const signed = await crypto.subtle.sign(
    'HMAC',
    key,
    enc.encode(`${timestamp}.${payload}`)
  );
  const computedSig = Array.from(new Uint8Array(signed))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  // Timing-safe comparison
  return timingSafeEqual(computedSig, expectedSig);
}

The timingSafeEqual is mandatory. A naive === comparison leaks information through timing differences — an attacker can iterate signatures and observe the comparison time to brute-force the HMAC byte by byte. The book walks through the timing-safe implementation, the replay-window selection (300 seconds is Stripe’s recommendation), and the constant-time comparison on the bit level for completeness.

This article is the short version — API Idempotency Patterns for Financial Operations is the full playbook.

Get the ebook — $14

Where this scales

The article above is the spine. The full book covers the layers most teams discover after the first duplicate-charge incident:

  • Nonce-based replay protection — single-use nonce generation, sliding-window validation, and the combination pattern that pairs nonces with idempotency keys so a leaked key cannot be replayed beyond the nonce window.
  • Database-backed idempotency — the SQL tracking table, constraint-based deduplication (the unique index that makes duplicate writes structurally impossible), the transaction audit trail required for regulatory compliance, and the two-layer pattern that uses KV for hot-path latency and Postgres for durability and audit.
  • Testing idempotency — unit tests for the idempotency layer, concurrent-request testing that reproduces the race window, network-failure simulation that injects connection drops, and the chaos-testing pattern that runs in production with synthetic transactions.
  • The complete middleware pipeline — authentication, nonce validation, idempotency check, request processing, response capture, cache store. Real TypeScript for Cloudflare Workers with monitoring dashboard metrics and the operational runbook for on-call.
  • Incident response when an idempotency miss does ship — duplicate-charge detection from the audit table, automated reconciliation against the payment processor, the automated-refund pipeline, and the customer-communication playbook.

Included with the book

  • idempotency-middleware.ts — Cloudflare Workers middleware with KV-backed idempotency storage, configurable TTL per operation type, key generators for common resource types, and request fingerprinting for implicit idempotency. Drop into your Worker, wire the KV namespace, deploy. MIT licensed.

Get the full picture

The full playbook

API Idempotency Patterns for Financial Operations — everything this article compresses, worked through end to end.

Get the ebook — $14

Readers of this also chose

Questions readers ask

Doesn't Stripe handle idempotency already? Why do I need my own layer?

Stripe handles idempotency at the Stripe API boundary — pass the same Idempotency-Key header to POST /v1/payment_intents and Stripe will return the original result on the retry. That covers the network leg between your server and Stripe. It does not cover the leg between your client and your server, which is where most duplicate charges actually originate. Your idempotency layer protects the leg Stripe cannot see; you should pass Stripe's idempotency keys through as well.

How long should the idempotency cache TTL be?

24 hours is the standard. Stripe uses 24 hours. The reasoning: a legitimate user retry happens within minutes; an agent retry storm happens within seconds; the window only needs to cover the longest plausible retry tail. Going longer accumulates state for no benefit. Going shorter risks missing a delayed retry from a queued message system.

What if two requests with the same idempotency key arrive at the same time?

That is the race window. Three strategies: Stripe's built-in idempotency handles it server-side at Stripe (use the same key, get the original response); KV with optimistic locking handles it via a pending-then-completed state machine that fails fast on the loser; Durable Objects guarantee strong consistency at the cost of single-region routing. The book walks through all three with full code and the trade-off matrix.

Do I need nonces if I already have idempotency keys?

For replay-protected webhook handling, yes. Idempotency keys protect against duplicate client requests. Nonces protect against an attacker capturing a valid signed request and replaying it later. They are complementary defenses. The chapter on combining them walks through the merge pattern.

What's the refund policy?

Lemon Squeezy's standard refund window applies. The refund link is in the receipt email.

Your opinion

Tell us anything.

What works, what doesn't, what's missing — especially about our watches, lenses, and the register itself. Anonymous is fine; leave an email if you'd like a reply.