Skip to main content
CentraPoint

Idempotency keys in payment APIs: stop double charges for good

Idempotency keys explained for developers: how they prevent duplicate charges and refunds, how to generate and store them, and how to implement them on your own API.

Published
Reading time
7 min read
By
CentraPoint Team
On this page
  1. The problem: retries without idempotency
  2. How idempotency keys work
  3. Generating good idempotency keys
  4. Implementing idempotency on your own API
  5. Idempotency beyond API requests
  6. A quick checklist
  7. How CentraPoint helps
  8. Frequently asked questions

Idempotency keys are unique values a client sends with a request that changes something (like creating a charge or refund) so the server can recognise a retry and return the original result instead of performing the action again. They solve a problem every payment integration eventually meets: a request times out, you don't know whether it worked, and retrying it might charge the customer twice.

This article explains when you need idempotency keys, how to generate them, and how to implement them if you're building your own API.

The problem: retries without idempotency

Picture this sequence:

  1. Your server asks a payment API to charge a saved card R850.00.
  2. The provider processes the charge successfully.
  3. The response is lost because of a network blip, and your HTTP client times out.
  4. Your code retries.
  5. The provider sees a brand-new request and charges another R850.00.

The customer now has two debits, your support team has a complaint, and you owe a refund. The same thing can happen with refunds (paying back twice), payouts, subscription creation and invoice generation.

GET requests don't have this problem because reading data twice is harmless. The danger is in POST requests that create or move money.

How idempotency keys work

The client generates a unique key for each logical operation and sends it with the request. The server:

  1. Checks whether it has seen the key before.
  2. If not, processes the request and stores the key with the response.
  3. If yes, and the request body is the same, returns the stored response without doing anything new.
  4. If yes, but the body is different, rejects the request as a misuse of the key.

Many payment APIs support this pattern, often through a request header. There is also an IETF HTTPAPI working group draft, The Idempotency-Key HTTP Header Field, that aims to standardise it. Always check your provider's API documentation for whether and how it supports idempotency.

Generating good idempotency keys

A key must be unique per operation and stable across retries of that same operation. Two common approaches:

Approach Example Good for
Random UUID stored before the first attempt 3f0c9a4e-... saved on the order row General use; avoids collisions
Deterministic key from business identifiers refund:INV-2031:1 Operations that should only ever happen once per record

The critical rule: generate the key once and persist it before you send the first request. If you generate a new UUID inside your retry loop, every retry is a new operation and you've gained nothing.

const crypto = require("crypto");

// Create the key once, when the operation is first recorded.
async function createRefundRecord(db, invoiceId, amountCents) {
  const idempotencyKey = crypto.randomUUID();
  return db.refunds.insert({ invoiceId, amountCents, idempotencyKey, status: "pending" });
}

// Every retry reuses refund.idempotencyKey from the database.

Implementing idempotency on your own API

If you're building an API that other systems call, or an internal service that handles money, here's a practical design.

Store keys with a unique constraint

Create a table with the key, the client or API key it belongs to, a hash of the request body, the response status and body, a processing state and a created timestamp. Put a unique index on the key plus client. The database, not your application code, then guarantees that two concurrent requests can't both claim the key.

Handle concurrent duplicates

Two identical requests can arrive milliseconds apart. The first inserts the key with state "processing". The second fails the unique insert, reads the row, sees "processing" and returns a 409 Conflict (or waits briefly and returns the stored result). Never let both proceed.

Compare request fingerprints

Store a hash of the request body. If a client reuses a key with a different amount or customer, return an error. Otherwise a bug that reuses keys could silently return the wrong charge.

Decide what to cache

Store successful responses and deterministic client errors (such as validation failures). Think carefully about server errors: if your own processing failed before anything happened, it's usually better to release the key so the client can retry.

Expire keys

Keys don't need to live forever. Keep them long enough to cover realistic retry windows (hours to days, depending on your clients) and clean up after that.

const crypto = require("crypto");

function fingerprint(body) {
  return crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex");
}

async function withIdempotency(db, clientId, key, body, handler) {
  const hash = fingerprint(body);
  const inserted = await db.idempotency.tryInsert({ clientId, key, hash, state: "processing" });
  if (!inserted) {
    const row = await db.idempotency.find({ clientId, key });
    if (row.hash !== hash) return { status: 422, body: { error: "Key reused with different request" } };
    if (row.state === "processing") return { status: 409, body: { error: "Request in progress" } };
    return { status: row.responseStatus, body: row.responseBody };
  }
  const result = await handler();
  await db.idempotency.complete({ clientId, key, responseStatus: result.status, responseBody: result.body });
  return result;
}

Note that JSON.stringify depends on property order, so in production either canonicalise the body or hash the fields that matter.

Idempotency beyond API requests

The same thinking applies to anything that can be delivered twice:

  • Webhooks. Providers retry deliveries, so store event IDs and skip duplicates. See our guide to payment webhooks.
  • Scheduled jobs. A monthly billing run that crashes halfway must be safe to restart without invoicing customers twice. Key each invoice by customer, plan and billing period.
  • Debit order batches. Never submit the same collection twice for the same action date.

A quick checklist

  • Every money-moving POST has an idempotency key or a lookup-by-reference fallback
  • Keys are generated once and persisted before the first attempt
  • Retries reuse the stored key
  • Your own API enforces uniqueness in the database
  • Reused keys with different bodies are rejected
  • Webhook handlers and scheduled jobs are idempotent too

How CentraPoint helps

CentraPoint's billing engine is built around the same principles: inbound gateway webhooks are signature-checked and re-verified server-side before an invoice or subscription is updated, and webhook and audit logs show exactly what was received and applied. Its REST API and outbound webhooks let your systems stay in step. For an end-to-end view, read our payment API integration guide or the developers overview.

Frequently asked questions

What is an idempotency key?

It's a unique value sent with a request so the server can detect retries of the same operation and return the original result instead of repeating the action, such as charging a card twice.

Should I use a new idempotency key for each retry?

No. Use the same key for every retry of the same logical operation. Generate a new key only for a genuinely new operation, such as a second, separate refund.

Are idempotency keys needed for GET requests?

Generally no. Reading data is already safe to repeat. Keys matter for requests that create or change something, especially those involving money.

How long should idempotency keys be stored?

Long enough to cover your clients' realistic retry window, often from 24 hours to a few days. Check your provider's documentation for how long it honours keys.

  • #idempotency
  • #api
  • #developers
  • #reliability