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.
Authorization: sk.xxxxxxxxxxxxxxxx...
Idempotency-Key: 8f14e45f-ceea-467a-9bd1-2c2bd7a1e0a4
Content-Type: application/jsonIdempotency-Keystringoptional[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
| Endpoint | Guard |
|---|---|
POST /payments | Permanent |
POST /transfers | Permanent |
POST /transfers/batch | Required, each line keyed on the batch key and its reference, permanent |
POST /refunds | Permanent |
POST /payments/{id} | Stored response, 24 hours |
POST /payments/{id}/splits, /confirm, /close | Stored response, 24 hours |
POST /customers | Stored response, 24 hours |
POST /beneficiaries | Stored response, 24 hours |
POST /invoices | Stored response, 24 hours |
POST /links | Stored response, 24 hours |
POST /webhooks | Stored response, 24 hours |
POST /accounts | Stored response, 24 hours |
POST /customers/{id}/tax_ids | Stored response, 24 hours |
POST /tax/registrations | Stored response, 24 hours |
POST /disputes/{id}/messages | Stored 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.
A key on a payment, a transfer or a refund is spent for good
It never becomes available again. salary-amina sent again next month does not pay Amina a
second time: it answers 201 with last month's transfer and sends nothing. Put what tells two
intents apart in the key itself, the pay period, the order line, the refund attempt.
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.
The PHP SDK invents a key if you do not
The key it adds on its own covers only its own network retries, not yours: calling create()
again mints a fresh key and creates a second resource. Pass an explicit RequestOptions(idempotencyKey: ...) whenever the
call can be retried by your own code, a queue worker, or a user clicking twice.
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:
What is compared, and for how long, depends on the endpoint.
| Endpoint | Compared | For how long |
|---|---|---|
POST /payments | The whole body | The life of the payment |
POST /transfers | amount, currency, beneficiary, description, reason, reference, metadata | 24 hours |
POST /refunds | The whole body for 24 hours, then the payment and the amount | The life of the refund |
POST /payments/{id} and /splits | channel, data, split_count, split_amounts | 24 hours |
| Everything else | The whole body | 24 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.
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.