← Todas las noticias
resilience5 min de lectura

Idempotency keys: the pattern that stops duplicate charges and double-sends

Por Seaworthy · 7 de junio de 2026

A colourful collection of vintage postage stamps arranged in a pile

Photo by Mason @ TRI on Unsplash

Networks lie to you. A request times out, your code throws, and you don't know whether the charge went through or not. So you retry. If the payment provider processed the first attempt, your customer just got billed twice.

This is not a rare edge case. It happens in production, repeatedly, and it is entirely preventable with one pattern: idempotency keys.

What idempotency actually means

An operation is idempotent if calling it multiple times with the same inputs produces the same result as calling it once. DELETE /users/42 is idempotent: after the first call the user is gone, subsequent calls just confirm that. POST /charges is not: each call creates a new charge.

Payment providers and email services operate on at-least-once delivery. They will retry failed requests automatically. Your own retry logic adds another layer. By the time a webhook fires, a job queue retries it, and your app server recovers from a crash, a single logical operation may have been attempted four times.

Idempotency keys let you tell the provider: "these requests are the same operation, give me the same outcome." The provider stores the result of the first successful attempt and replays it for any retry carrying the same key.

Stripe and Paddle

Stripe accepts an Idempotency-Key header on any POST request. Pass a unique string, and Stripe stores the response for 24 hours. Same key, same response, no second charge.

import Stripe from 'stripe';
import { randomUUID } from 'crypto';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

async function createCharge(orderId: string, amount: number): Promise<Stripe.PaymentIntent> {
  // Derive the key from the order ID so retries always produce the same key.
  // Never use Math.random() or Date.now() — those generate a new key on each call.
  const idempotencyKey = `charge-${orderId}`;

  return stripe.paymentIntents.create(
    {
      amount,
      currency: 'gbp',
      confirm: true,
      automatic_payment_methods: { enabled: true },
    },
    { idempotencyKey }
  );
}

The important detail: generate the key from something stable about the operation, not from a random value computed at call time. randomUUID() is fine if you store it before the first attempt and reuse it on retries. Generating a fresh UUID on each retry defeats the purpose entirely.

Paddle's API works the same way: set the Idempotency-Key header. For Paddle's webhook-triggered operations, your handler itself needs to be idempotent, which is a slightly different problem.

Storing idempotency records

For operations you control (sending emails, provisioning licenses), you need your own idempotency layer. The pattern is straightforward: write a record to the database before executing the side effect, and check for it before executing again.

import { db } from './db';

interface IdempotencyRecord {
  key: string;
  result: string; // JSON-serialised response
  created_at: Date;
}

async function sendWelcomeEmail(userId: string, email: string): Promise<void> {
  const key = `welcome-email-${userId}`;
  const expiry = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24-hour window

  const existing = await db
    .selectFrom('idempotency_records')
    .where('key', '=', key)
    .where('created_at', '>', expiry)
    .selectAll()
    .executeTakeFirst();

  if (existing) {
    // Already sent within the window. Safe to return without re-sending.
    return;
  }

  await db.insertInto('idempotency_records').values({ key, result: '{}', created_at: new Date() }).execute();

  await mailer.send({ to: email, template: 'welcome' });
}

The expiry window matters. Stripe holds keys for 24 hours. For your own records, match the window to your retry horizon: how long could a job queue hold a failed job before giving up? That upper bound is your minimum window.

Idempotent webhook handlers

Webhooks are the most common place idempotency breaks down. Stripe, Paddle, and most other providers will retry a webhook if your endpoint returns a non-2xx status or takes too long. They are explicit about this. Your handler will receive the same event multiple times.

Every webhook payload has an event ID. Use it.

import type { Request, Response } from 'express';

export async function handlePaddleWebhook(req: Request, res: Response): Promise<void> {
  const eventId = req.body.event_id as string;

  if (!eventId) {
    res.status(400).json({ error: 'Missing event_id' });
    return;
  }

  const alreadyProcessed = await db
    .selectFrom('processed_webhook_events')
    .where('event_id', '=', eventId)
    .selectAll()
    .executeTakeFirst();

  if (alreadyProcessed) {
    // Return 200 so the provider stops retrying. Do nothing else.
    res.status(200).json({ status: 'already_processed' });
    return;
  }

  // Process the event, then record it.
  await processEvent(req.body);

  await db
    .insertInto('processed_webhook_events')
    .values({ event_id: eventId, processed_at: new Date() })
    .execute();

  res.status(200).json({ status: 'ok' });
}

There is a race condition here if two retries arrive simultaneously. Solve it with a unique constraint on event_id in the database, so the second insert throws and the handler exits cleanly without processing twice.

Testing it

The test worth writing is not the happy path. It is: send the same webhook event twice, assert the side effect ran exactly once.

import { describe, it, expect, vi } from 'vitest';

describe('webhook handler idempotency', () => {
  it('processes an event only once when the same event ID is replayed', async () => {
    const processEvent = vi.fn();
    const event = { event_id: 'evt_test_001', type: 'subscription.activated', data: {} };

    await handleWebhookEvent(event, processEvent);
    await handleWebhookEvent(event, processEvent); // replay

    expect(processEvent).toHaveBeenCalledTimes(1);
  });
});

If this test fails, you have a double-processing bug in production, waiting for a network blip to trigger it.

The omission pattern

I look at a lot of AI-generated integrations. The idempotency key line is almost always missing. The webhook handler almost never checks for duplicate event IDs. It is not that the model doesn't know about the pattern. It is that it generates the optimistic path: one request, one response, everything works. Retries are someone else's problem.

They are not. They are your problem, and your customers notice when it goes wrong.

Seaworthy flags this pattern automatically.

Este artículo fue generado por IA y resume fuentes públicamente disponibles.

De los creadores de Seaworthy

Seaworthy analiza tu repositorio en busca de los problemas que se tratan en artículos como este.

Brechas de seguridad, secretos expuestos y configuraciones erróneas detectados antes de desplegar. Gratis, sin cuenta necesaria.

$ npx seaworthycode