Skip to content
Loading keys…

Idempotency

Replay a creation safely with the Idempotency-Key header.

A network timeout tells you nothing about whether the server acted. Replaying a POST /transfers blind can pay a beneficiary twice. An idempotency key removes the ambiguity: the second call returns the first call's answer instead of doing the work again.

Two pages, two questions

This page is the mechanics: the header, its format, where it works, how long it lasts. For how to choose keys, structure retries and deduplicate webhooks in your own code, see Best practices → Idempotency.

The header

Send Idempotency-Key on the request. No X- prefix.

Request headers
Authorization: sk.xxxxxxxxxxxxxxxx...
Idempotency-Key: 8f14e45f-ceea-467a-9bd1-2c2bd7a1e0a4
Content-Type: application/json
Idempotency-Keystringoptional
Your own identifier, 1 to 128 characters matching [A-Za-z0-9._:-]+. A UUID v4 is the usual choice. Anything outside that alphabet is a 422 on idempotency_key.

Keys are scoped to the account, the environment and the operation, and a publishable key's apart from a secret or restricted key's: on POST /payments, a key one of them used is refused to the other with 422. The same string used in sandbox and in live is two independent keys, the same string on POST /customers and on POST /links too, and no other account can collide with yours.

Where it works

EndpointGuard
POST /paymentsPermanent
POST /transfersPermanent
POST /transfers/batchRequired, each line keyed on the batch key and its reference, permanent
POST /refundsPermanent
POST /payments/{id}Stored response, 24 hours
POST /payments/{id}/splits, /confirm, /closeStored response, 24 hours
POST /customersStored response, 24 hours
POST /beneficiariesStored response, 24 hours
POST /invoicesStored response, 24 hours
POST /linksStored response, 24 hours
POST /webhooksStored response, 24 hours
POST /accountsStored response, 24 hours
POST /customers/{id}/tax_idsStored response, 24 hours
POST /tax/registrationsStored response, 24 hours
POST /disputes/{id}/messagesStored response, 24 hours

The two guards behave the same from outside, and differ in how long they remember.

Payments, transfers and refunds keep the key for good. The protection never expires: replaying that key in a year still returns the original payment, transfer or refund rather than creating a second one. Two concurrent requests with the same key never create two resources.

The first response of these three is stored for 24 hours as well. Inside that window a replay is that response, unchanged, with Idempotent-Replayed: true. After that window the original payment, transfer or refund is returned in a new 201, without the header, and for a payment with a new authorization_url and authorization_token, because a checkout session is single use.

Everything else remembers the first response for 24 hours and has nothing behind it. Past that window the key is forgotten and the same call creates a new resource. That is the right trade for a customer or a payment link, where a duplicate is an annoyance rather than a loss.

What is stored is whatever the operation answered below 500, a refusal included. A charge declined with 402 on POST /payments/{id} replays as declined for 24 hours, so trying again once the customer has topped up takes a new key. A 5xx is never stored, and neither is a request rejected on validation: the key is released and the next attempt runs again.

Replaying a request

Generate the key once, at the moment of business intent, and reuse it for every network attempt of that same intent.

curl https://api.wajub.com/transfers \
-H "Authorization: $WAJUB_API_KEY" \
-H "Idempotency-Key: payout-invoice-4172" \
-H "Content-Type: application/json" \
-d '{
  "amount": 100000,
  "currency": "XAF",
  "beneficiary": {
    "phone": "237670000000",
    "channel": "cm.mtn",
    "name": "Amina Nkeng"
  }
}'

Send it a second time and you get the first response back, with the same transfer id and an Idempotent-Replayed: true header. Nothing was created, no money moved.

Reusing a key with a different body

Replaying is only safe if it is the same request. If you reuse a key with a different payload, Wajub refuses rather than quietly returning a resource you did not ask for:

Response · 422 Unprocessable Content
{
"code": 422,
"status": "Unprocessable Content",
"message": "This Idempotency-Key was already used with a different request payload.",
"errors": {
"idempotency_key": [
"This Idempotency-Key was already used with a different request payload."
]
}
}

What is compared, and for how long, depends on the endpoint.

EndpointComparedFor how long
POST /paymentsThe whole bodyThe life of the payment
POST /transfersamount, currency, beneficiary, description, reason, reference, metadata24 hours
POST /refundsThe whole body for 24 hours, then the payment and the amountThe life of the refund
POST /payments/{id} and /splitschannel, data, split_count, split_amounts24 hours
Everything elseThe whole body24 hours

On POST /transfers, nothing is compared once the 24 hours have passed: the key returns the original transfer whatever the new body says.

On POST /customers/{id}/tax_ids and POST /disputes/{id}/messages, the customer or the dispute of the path is compared too: the same key on another one answers this 422. A message's attachments are compared by their content.

A refund key reused after 24 hours for another payment or another amount is refused with the same 422 and the message This Idempotency-Key was already used with a different refund request.

A malformed header arrives as the same 422 with a different message, The Idempotency-Key header format is invalid.

Concurrency

A request that arrives while the first one with the same key is still running does not wait. It gets a 409 straight away, and the same call a moment later gets the stored response. You never get two in-flight creations for one key, and you never get a partially-written resource.

Response · 409 Conflict
{
"code": 409,
"status": "Conflict",
"message": "A request with this Idempotency-Key is still being processed.",
"errors": {
"idempotency_key": [
"A request with this Idempotency-Key is still being processed."
]
}
}

The server SDKs retry this 409 for you with the same key and return the stored response.

One key per business intent, not per attempt

Derive the key from something in your own domain that is already unique: an order ID, an invoice number, a payroll line. payout-invoice-4172 survives a process restart, a redeploy and a queue retry. A UUID generated inside the retry loop survives none of them.

Spotting a replay

In Konsole → API Logs, a response served from the idempotency guard carries a replayed badge, so you can tell a duplicate call from a duplicate resource.

What did you think of this content?