Skip to content

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.

RuleValue
Allowed charactersA-Z, a-z, 0-9, and . _ : -
Length1 to 128 characters
Anything else422, with the reason under errors.idempotency_key
ScopeThe 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.

EndpointWhat a replay returns
POST /paymentsThe original response, and past 24 hours still the same payment
POST /payments/{id}The original processing result
POST /transfersThe original response, and past 24 hours still the same transfer
POST /refundsThe original response, and past 24 hours still the same refund
POST /customersThe original customer
POST /beneficiariesThe original beneficiary
POST /invoicesThe original invoice
POST /linksThe original payment link
POST /webhooksThe original endpoint, secret included
POST /transfers/batchThe 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.

StrategyThe keyWhen it fits
One order, one attempt everORDER-4172Most checkouts. Simple, and impossible to get wrong
One order, counted attemptsORDER-4172-A2You let a customer deliberately try again after a failure
Stored UUID5b2c… written on the order rowYour 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.

422, same key and a different amount
{
"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."
]
}
}

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.

LayerLifetimeWhat it does
Stored response24 hoursThe original response is replayed, status included, with Idempotent-Replayed: true. A post arriving while the first is still running gets a 409
Permanent keyPermanentA 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.

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.

One row per event, and the insert decides
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.

What did you think of this content?