Idempotency
Make a retry safe, so a timeout never turns one payment into two.
The problem is not that requests fail. It is that some of them fail after the server already did the work. A timeout, a dropped connection, a pod restarted mid flight: your code sees an error, and has no way of knowing whether a payment now exists on the other side.
An idempotency key closes that gap. You attach the same string to the first attempt and to every retry of it, and Wajub answers the retry with the original result instead of doing the work twice.
Mechanics live in the API reference
This page is about how to use keys in a real integration. For the header contract itself, see API Reference, Idempotency.
The header
The header is Idempotency-Key, with no X- prefix. Its value is yours to choose, within a format
Wajub enforces before doing anything else with the request.
| Rule | Value |
|---|---|
| Allowed characters | A-Z, a-z, 0-9, and . _ : - |
| Length | 1 to 128 characters |
| Anything else | 422, with the reason under errors.idempotency_key |
| Scope | The key, your team, the environment and the operation, together |
The scope matters more than it looks. The same key used in sandbox and in live are two unrelated
keys, so replaying a sandbox test cannot collide with production. On a request carrying X-Sync,
the connected account is part of the scope too, which means one key can safely mean "this order"
across several sellers in a marketplace basket.
A space, a slash or a # in the key is rejected outright, so an order number like #4172 or a
UUID with braces will fail before the payment is even considered.
Which endpoints honour it
Idempotency is not limited to payments. Every creation endpoint below reads the header.
| Endpoint | What a replay returns |
|---|---|
POST /payments | The original response, and past 24 hours still the same payment |
POST /payments/{id} | The original processing result |
POST /transfers | The original response, and past 24 hours still the same transfer |
POST /refunds | The original response, and past 24 hours still the same refund |
POST /customers | The original customer |
POST /beneficiaries | The original beneficiary |
POST /invoices | The original invoice |
POST /links | The original payment link |
POST /webhooks | The original endpoint, secret included |
POST /transfers/batch | The lines already sent as they are, and only the missing ones sent |
The Node SDK already sends a key, and that is the trap
If you use @wajub/node and do not pass idempotencyKey, the SDK generates one for you on every
non GET and non DELETE request. It is a fresh random UUID each time.
So the header is always present, and it protects you from nothing: your retry carries a different key from the attempt it is retrying, which is exactly the situation idempotency exists to prevent. The header being there is not the point. The same header being there twice is.
curl https://api.wajub.com/payments \
-H "Authorization: sk_test.xxxxxxxxxxxxxxxx…" \
-H "Idempotency-Key: ORDER-4172" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "XAF",
"email": "[email protected]",
"reference": "ORDER-4172"
}'Choosing what goes in the key
The key must survive your process. Generate it before the first attempt, write it next to the order,
and read it back on every retry. A key built from Date.now() or a fresh UUID at call time is a new
key each attempt, which is the same as having none.
| Strategy | The key | When it fits |
|---|---|---|
| One order, one attempt ever | ORDER-4172 | Most checkouts. Simple, and impossible to get wrong |
| One order, counted attempts | ORDER-4172-A2 | You let a customer deliberately try again after a failure |
| Stored UUID | 5b2c… written on the order row | Your order ids are not safe to expose |
The middle row is the one to reach for when a Mobile Money payment fails and the customer wants another go. Bumping the counter is a deliberate act by a human, and every accidental retry inside one attempt still collapses onto a single payment.
The key belongs to the order, not to the request
Store it on the row you are about to pay for. If your retry logic has to invent the key, it is in the wrong place.
What counts as the same request
On POST /payments, Wajub compares these fields with the first request made with the key.
amount, currency, email, phone, name, customer_id, description, reference,
callback, theming, items, telemetry, accept, deferred.
Everything else, metadata included, is not compared. Replaying the same key with a new metadata
object returns the original payment untouched, so metadata is not the place to put anything you
expect the replay to update.
If one of those fields changed, the key and the payload disagree, and Wajub refuses rather than guessing which one you meant.
That error is a bug in your code, never a transient failure. Either you reused a key you should have rotated, or you changed an amount without changing the key. Do not retry it.
Two layers, and only one of them is permanent
POST /payments is defended in depth, and knowing which layer answered explains what you get back.
| Layer | Lifetime | What it does |
|---|---|---|
| Stored response | 24 hours | The original response is replayed, status included, with Idempotent-Replayed: true. A post arriving while the first is still running gets a 409 |
| Permanent key | Permanent | A second payment is never created with that key, whatever the stored response says |
Past 24 hours the stored response is gone, but the key still resolves to the same payment: a replay
never creates a second one. One difference is worth knowing: a fresh authorization_url and
authorization_token are minted, because a session token is only ever handed out in the response
that creates it. The payment id does not change.
POST /transfers and POST /refunds have both layers too, and so does each line of a batch. The
other endpoints only have the stored response: past 24 hours the key is forgotten, and the same call
creates a new resource.
So a key on a payment, a transfer or a refund is spent for good. Reusing it for something new does
not fail: it answers 201 with the old resource and does nothing. salary-amina sent again next
month pays nobody. Build the pay period, the order line or the attempt into the key, and never plan
on a key coming free again.
`reference` is not an idempotency key
reference is a free string with no uniqueness rule anywhere in the API. Sending the same
reference twice creates two payments. It is there so you can find a payment in your own terms,
with GET /payments?reference=, not so you can deduplicate with it.
Webhooks need their own deduplication
Idempotency keys protect calls you make. They do nothing for calls Wajub makes to you, and those are retried on purpose, with a growing wait that starts at 30 seconds, for up to 72 hours. A handler that is slow to answer, or a deploy landing at the wrong moment, and you will see the same event twice.
The event id is stable across every retry of a delivery. Record it before you act on it, in a table
rather than in memory, because an in-process Set empties on the deploy that caused the backlog you
are about to receive.
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Returns a row the first time, nothing on every replay.
INSERT INTO processed_events (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;Acknowledge the delivery with 200 either way. A replay you have already handled is not an error,
and answering anything else asks Wajub to send it again.
Related pages
- API Reference, IdempotencyThe header contract, conflict behaviour, and retention window.
- Error handlingWhich failures deserve the retry this page makes safe.
- Webhook retriesThe delivery schedule, and what Wajub considers a failed attempt.
- Accept a paymentThe same key, used inside a complete order lifecycle.