Never charge twice — idempotency explained
The network drops at the wrong moment, you retry, the customer is charged twice. How one HTTP header settles it.
Your server sends a payment request. The connection drops before the response arrives. You do not know whether the operation went through. What do you do?
If you retry, you risk charging twice. If you do not retry, you risk losing a sale. That dilemma has a solution, and it fits in one HTTP header.
The problem is not rare
It is tempting to think this case is theoretical. It is not. A timeout, a container restart at the wrong moment, a mobile client switching from Wi-Fi to 4G, a task queue replaying a message: each of those incidents produces exactly the same situation.
At a few hundred payments a day, it happens. At a few thousand, it happens every day.
The idempotency key
Every create accepts an Idempotency-Key header. The rule is simple: two calls carrying the same key produce a single resource.
curl -X POST 'https://api-psp.charipay.ma/v1/payment-links' \
-H 'X-CHARI-PAY-API-KEY: chari_sk_live_...' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-2026-1042' \
-d '{ "amount": 249.00, "description": "Order #1042" }'Replay that call as many times as you like: you will always get the same payment link. Not a second one.
The key must be deterministic and specific to the operation. Your order reference is an excellent candidate: you know it, it does not change, and it is unique on your side. A random identifier drawn on every attempt, by contrast, is useless — it defeats the very thing the key exists to do.
Payment links go further
On payment links, the externalId field carries the same guarantee, permanently: it is unique per account and per environment.
Create a link twice with the same externalId, and the API returns the existing link with a 200 instead of a 201. The difference in status code tells you exactly what happened, without you having to keep any intermediate state.
That is particularly useful when your handler is driven by a message queue: the message can be delivered twice, your code can call twice, the result stays correct.
For refunds
The refund reference plays the same role. Replaying a refund with the same reference does not refund twice — which, on a partial refund, avoids an entire class of incidents that are painful to unpick.
What idempotency does not do
It does not replace checking your own state. If your system can create two orders for one cart, no HTTP header will fix that.
Nor does it make your webhooks idempotent. That is separate work, in the same spirit: every delivery carries an event id, store it, and ignore what you have already handled. The same notification can arrive twice — by design, so that none is ever lost.
Generating a good key
An idempotency key is not a random number drawn at call time — that would miss the whole point. A good key is deterministic: the same business operation always produces the same key.
The safest pattern: derive the key from the identifier of the business object that triggers the payment. order-8842-payment for the payment of order 8842; subscription-512-instalment-2026-09 for a subscription due date; refund-tx-77a1 for a refund. If your code comes back through — automatic retry, a message queue delivering twice, an impatient user — the key is identical, and the operation happens once.
Conversely, two genuinely distinct operations must produce two distinct keys: never use the customer identifier alone, or all their orders would share one key.
One limit worth knowing: the key protects the call, not your logic. If your code creates two different orders for the same purchase, each will carry its own key and each will be charged. Idempotency starts in your data model.
Idempotency on the webhook side
The same principle applies in the other direction. A payment notification can be delivered to you twice — that is a reliability guarantee, not a defect. Your webhook handler must therefore be idempotent too: the event identifier plays the role of the key, a table with a uniqueness constraint plays the role of the guard.
The two mechanisms mirror each other: the idempotency key stops your retry from creating two payments; event deduplication stops our retry from making you ship two orders. A robust integration carries both, and the same network incident — the only real culprit in this story — no longer costs anyone anything.
The rule in one sentence
Every create that commits money carries an idempotency key derived from your own reference, and every webhook handler deduplicates by event id. Two habits, ten lines of code, and a whole class of bugs disappears.
Written by ChariPay team.