Engineering

Idempotency keys: how to retry payment requests without charging twice

Networks fail. Idempotency keys make retries safe. How they work in the HansaPay API, when to generate them, and the mistakes we see most often.

Marcus OkaforStaff Engineer, Platform3 min read

Here is a failure every payments integration eventually hits. Your server sends a request to create a payment. The request reaches HansaPay, the payment succeeds, and then the connection drops before the response gets back to you. From your side, it looks like a timeout.

What do you do? If you retry, you might charge the customer twice. If you don't, you might lose a sale you actually made.

Idempotency keys solve this.

What an idempotency key is

An idempotency key is a unique string you send with a request. HansaPay stores the key with the result of the first request that used it. If you send the same request again with the same key, we return the stored result instead of doing the work twice.

bash
curl https://api.hansapay.io/v1/payments \
  -u "$HANSAPAY_SECRET_KEY:" \
  -H "Idempotency-Key: ord_1042" \
  -d amount=4900 \
  -d currency=usd

Retry that request as many times as you like. There will only ever be one payment.

Choosing a good key

The key should identify the operation you intend, not the attempt. That's the part people get wrong.

  • Good: your order ID, or a UUID you generate once when the customer clicks pay and store alongside the order.
  • Bad: a new random UUID generated inside your retry loop. Each attempt gets a different key, so each attempt creates a new payment.
ts
// Generate the key once, when the intent is created, and persist it.
const order = await db.orders.create({ total: 4900, idempotencyKey: crypto.randomUUID() });

// Every retry reuses the same key.
await withRetries(() =>
  hansapay.payments.create(
    { amount: order.total, currency: "usd", metadata: { order_id: order.id } },
    { idempotencyKey: order.idempotencyKey },
  ),
);

How long keys last

HansaPay keeps idempotency keys for 24 hours. After that, a request with the same key is treated as new. In practice, retries happen within seconds or minutes, so 24 hours is a comfortable margin.

What about concurrent requests?

If two requests with the same key arrive at the same time, the second one waits for the first to finish and then returns the same result. You never get two payments, and you never get a partially completed one.

A checklist for your integration

  1. Send an idempotency key on every request that creates or changes something.
  2. Generate the key when the user's intent is created, not when the request is sent.
  3. Store the key with the record it belongs to.
  4. Retry network errors and 5xx responses with exponential backoff, reusing the key.
  5. Don't retry 4xx errors. They mean the request itself needs to change.

There is no library to install, so generate a key per logical operation in your own code. The pattern above is all you need.

All articles

Go live by this time tomorrow.

Open an account, share your first payment link, and start taking payments from customers anywhere.

  • Approved in about 24 hours
  • Test mode from day one
  • No long-term contract
  • Settle in 135+ currencies or USDT