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, and the environment, 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 transfer |
POST /refunds | The original 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 |
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.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
-H "Idempotency-Key: ORDER-4172" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "XAF",
"email": "buyer@example.com",
"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 hashes a snapshot of your payload and keeps it with the key. These fields
are in the snapshot.
amount, currency, email, phone, name, customer_id, description, reference,
callback, expires.in, theming, items, telemetry.
Everything else, metadata included, is outside it. 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 a field inside the snapshot 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.
Three 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 |
|---|---|---|
| Lock | 10 seconds | Two simultaneous posts of the same key queue instead of racing |
| Cache | 24 hours | The original response is replayed byte for byte, status included |
| Unique index | Permanent | The database refuses a second payment on that key, whatever the cache says |
Past the 24 hour window the cache is gone but the index is not. A replay then still resolves to the
same payment rather than creating a second one, with one difference worth knowing: a fresh
authorization_url and authorization_token are minted, because a checkout session is single use.
The payment id does not change.
The other endpoints have the lock and the cache, not the index. Treat 24 hours as the useful window everywhere, and rely on your own records beyond it.
`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,
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: five attempts, spaced 30 seconds, 1 minute, 5 minutes, 10 minutes and 1 hour. 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.