Idempotency and safe replays

Make queue consumers and retries safe to repeat with operation-derived dedupe keys, an idempotent consumer, provider idempotency keys and the outbox pattern.

  • 40 min
  • Advanced

The first three lessons made your client retry less and stop more. All of that assumed something quietly: that repeating a call is harmless. For a lookup it is. For "charge this card", "send this email" or "insert this row" it is the difference between a reliable system and a support queue full of double charges.

Repeats are not rare edge cases. They come from four ordinary places:

  • Retries after an ambiguous failure. Lesson one showed that a reset or a timeout after the request was sent leaves you not knowing whether the server acted.
  • Queue redelivery. A worker processes a message, crashes before acknowledging it, and the queue hands the message to another worker.
  • Restarts. Lesson three halted a job on credit exhaustion. When it resumes, it will meet work that was half done.
  • Humans. Someone reruns yesterday's job because the dashboard looked wrong.

You cannot remove these. You can make them harmless.

At-least-once is the delivery you actually get

Every mainstream queue (SQS, Pub/Sub, RabbitMQ, Kafka consumer groups, BullMQ) delivers at least once. The reason is structural: the queue can only forget a message after the consumer acknowledges it, and the consumer can only acknowledge after it has done the work. A crash in the gap between "did the work" and "acknowledged" means the work is done and the queue still has the message.

"Exactly once" between two systems that fail independently is not something a queue can sell you. What you can build is effectively once: at-least-once delivery plus a consumer that recognises a repeat and does nothing the second time. That property is called idempotency, and it lives in your code, not in the queue.

An operation is idempotent when doing it twice leaves the world in the same state as doing it once. Some operations are idempotent by nature:

OperationSafe to replay?What a replay costs
A lookup (GET) on APIVerveYesCredits again, and the answer may differ if the data moved
An APIVerve POST transformation (HMAC, HTML to PDF, image conversion)YesCredits again; nothing is created twice
Setting a field to a value (status = 'shipped')YesNothing
Upsert keyed by a stable idYesNothing
Charging a card through a payment providerOnly with an idempotency keyA second charge
Sending an email or SMSNo, unless you dedupeA second message to a customer
Incrementing a counter or appending a rowNoA wrong total

The design job is to move every operation in the bottom half of that table into the top half.

APIVerve calls are on the safe side

APIVerve's POST endpoints are transformations: they take an input and return an output, and they create nothing on the platform that a second call would duplicate. Sending the same body twice gives you a second answer to the same question. That is why there is no idempotency key to send for them and none is needed. The HMAC Generator is a clean example: same message, same secret, same signature, every time.

Try it liveHMAC Generator1 credit
POSThttps://api.apiverve.com/v1/hmac
curl -X POST "https://api.apiverve.com/v1/hmac" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"message":"order-1042:charge","secret":"whsec_demo"}'
Sign in or create a free account to run this with your own key.

Run it twice: data.hmac is identical both times and nothing was created. A replay of a transformation costs credits, never correctness.

Replays of APIVerve calls still cost something: each successful call bills again, and a 10,000-item enrichment job that reruns from the start pays twice. Deduplicating them is a cost decision, not a correctness one. Two details follow:

  • Some answers move over time. Validation, DNS-based checks, current time and live prices can differ between the first call and the replay. If downstream code must see one consistent answer, store the first result under the operation's key and serve the replay from the store instead of calling again.
  • For batches, replay only what failed. Results come back in the order you sent them, so resend just the indices whose item failed with code: 429 (rate window) or code: 402 (credits), or that never got a response. Resending the whole batch re-bills every item that already succeeded.

Dedupe keys come from the operation

An idempotent consumer needs a key that is the same every time the same logical operation is attempted, and different for different operations. The most common mistake is generating it at the wrong moment:

// Wrong: a new key on every attempt makes every retry look like new work
await sendReceipt(order, { dedupeKey: crypto.randomUUID() });

A random UUID is fine if it is created once, when the operation is first decided, and stored with the work (in the queue message, in the database row). It is useless if it is created inside the code that retries. The more robust approach is to derive the key from what the operation is:

op-key.mjs
import { createHash } from 'node:crypto';

// Stable JSON: same object, same string, whatever order the keys were added in.
function canonical(value) {
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
  if (value && typeof value === 'object') {
    return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
  }
  return JSON.stringify(value);
}

/** Key for one logical operation. Never include timestamps, attempt numbers or random values. */
export function opKey(kind, identity) {
  return createHash('sha256').update(`${kind}:${canonical(identity)}`).digest('hex');
}

opKey('receipt-email', { orderId: 1042 });            // same every time for this order
opKey('enrich-company', { domain: 'example.com' });   // same input, same key, one paid call

What goes into identity is a business decision, and it is worth saying out loud for each operation. "Send a receipt for order 1042" is one operation no matter how many times the message arrives, so the key is the order id. "Send a reminder for order 1042" might legitimately happen once per day, so the key includes the date it is for (the business date, not the time of the attempt). If you cannot say what makes two attempts the same operation, you are not ready to dedupe it.

Hashing keeps keys a fixed length and keeps raw identifiers (emails, card fingerprints) out of logs and key tables. You can cross-check a digest against the Hash Generator while testing; sha256 of order-1042:charge should match what node:crypto produces:

Try it liveHash Generator1 credit
GEThttps://api.apiverve.com/v1/hashgenerator
curl "https://api.apiverve.com/v1/hashgenerator?text=order-1042%3Acharge&algorithms=sha256" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own key.

data.hashes.sha256 equals createHash('sha256').update('order-1042:charge').digest('hex') in Node.

Check yourself

A worker sends a receipt email for each order message. It retries on timeouts and the queue redelivers on crashes. Which dedupe key prevents duplicate receipts?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales