Skip to main content
All articles
Technical5 min read

Never charge twice — idempotency explained

Payment idempotency explained: how the Idempotency-Key header and externalId stop you charging a customer twice when the network drops mid-call.

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 Idempotency guide in the API docs sets out the rules. The rule is simple: two calls carrying the same key produce a single resource.

bash
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. To check it yourself, swap in a test key (chari_sk_test_…): Start in test mode opens the sandbox as soon as you sign up, with no approval to wait for.

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.

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. To find the order behind each payment afterwards, see matching a payment to its order.

For refunds

The refund reference — the refundReference field in the refunds API 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, covered in getting your webhook integration right: 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. Finally, it does not tell you what to do with an error: whether to replay or fix depends on the code returned, covered in payment API error codes.

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; client-512-subscription for creating client 512's subscription; 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.

Next step

The most telling test is to replay a call yourself with the same key and compare the responses. Start in test mode asks for a name, a work email and your company name; after the activation link sent by email and choosing your password, you create a test key from the portal — free, with no time limit. For the integration as a whole — keys, links, sessions, signed webhooks — follow the payment API in Morocco guide.

Written by ChariPay team.

Read next

Technical5 min read

Getting your webhook integration right

Getting your webhook integration right: the five mistakes found in almost every integration, and the fifteen-line handler that avoids all of them.

Compliance5 min read

Protecting your API keys

Protecting your payment API keys: five simple rules, planned rotation, and what to do, in order, when a key has already leaked into a repository.

Ready to try it yourself?

Create your account and take test-mode payments today: it is free, with no approval to wait for. A question? Our team supports merchants and developers alike.

  • Free, no commitment
  • Test mode from sign-up
  • No approval to wait for