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, 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.

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 transfer
POST /refundsThe original refund
POST /customersThe original customer
POST /beneficiariesThe original beneficiary
POST /invoicesThe original invoice
POST /linksThe original payment link
POST /webhooksThe 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.

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 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.

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.

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.

LayerLifetimeWhat it does
Lock10 secondsTwo simultaneous posts of the same key queue instead of racing
Cache24 hoursThe original response is replayed byte for byte, status included
Unique indexPermanentThe 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.

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.

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?