Idempotency
Every state-changing call carries an spTransactionId that you generate. It is how the platform
guarantees that a retry cannot become a second charge, and it is the single most important field in
this API, because a double charge cannot be apologised away.
How it works
Three steps, in this order, on every write:
- Claim the transaction id.
- Run the operation.
- Store the outcome against that id.
The claim happens before anything is spent, which is what makes the guarantee hold even if your connection drops mid-call or our process dies between steps.
What happens when you send the same id twice
With an identical request body, you get the stored outcome back, verbatim. The operation does not run again. Whatever happened the first time (success or a specific failure) is what you receive.
requestId will differ, and that is correctA replay returns the original outcome under the current call's requestId. requestId
identifies this HTTP call; the outcome belongs to the transaction. So two entries in your log
with different requestIds and the same spTransactionId are one transaction, not two.
With any changed field, you get DUPLICATE_TRANSACTION_ID (409). The platform fingerprints the
validated request body, so a reused id with a different amount, number or service is refused
rather than silently re-run.
This is the behaviour that catches people out. If your first attempt failed because a parameter was wrong, correcting the parameter and retrying under the same id will be refused. A corrected request is a new attempt: give it a new id. The id identifies an attempt, not an intention.
Generating one
- Unique per attempt. A UUID or ULID is ideal.
- Generated by you, stored by you, alongside your own record of the call: you will need it to reconcile.
- Never derived from user input, and never reused across services or endpoints.
When a retry is safe
| Situation | Same id, or new? |
|---|---|
| You got a response and want the same answer again | Same id: you will get the stored outcome |
| Your connection dropped and you do not know the outcome | Same id: this is exactly what it is for |
You got retryable: true and are backing off | Same id: nothing terminal was stored |
| You are correcting a rejected parameter | New id: it is a new attempt |
| You are charging the same subscriber again, deliberately | New id: it is a new transaction |
When you do not know whether a call succeeded, retrying with the same id is always the right move. That is the situation the mechanism exists for: it either replays the answer you missed, or runs the operation that never happened. It cannot do both.
Transient failures leave nothing behind
If a call fails in a way marked retryable: true, no terminal outcome is stored: the claim is
released. Retrying with the same id genuinely re-executes rather than replaying a failure.
This matters most with the anti-fraud check: an unavailable verdict consumes nothing, so your retry is a real second attempt rather than a stored refusal handed back to you.
What it does not protect
Idempotency is per transaction, not per subscriber. It stops one attempt becoming two charges. It does not stop you deliberately charging the same subscriber twice with two different ids: that is a correct use of the API, and the limits on Error codes are what bound it.