Idempotency-Key header on a create request and SouthPay will not create a second record if you send the same request twice. That is the whole guarantee. It is narrower than idempotency on most payment APIs, and the differences matter, so this page describes the mechanism rather than the idea.
Where the header applies
Omitting it where it is required returns
422. Agentic endpoints use the code idempotency_key_required; the public endpoints return the plain string {"error": "Idempotency-Key header is required"}.
Everything else ignores the header entirely. Billing (customers, invoices, products, prices, subscriptions), webhook endpoints, platform accounts and payout schedules do not read it. Sending it there is harmless and does nothing. Retrying a billing create after a timeout can produce two customers.
Request
What a replay returns
A replay returns the record the first call created, with the status code the endpoint normally returns. ForPOST /payments and POST /payouts that is 201 Created, not 200 OK.
There is no shared idempotency store and no cached response. The key is a column on the created row, and a replay is a lookup that finds that row and re-serializes it. Consequences:
- Nothing tells you it was a replay. No
Idempotency-Replayedheader, no distinct status. If you need to know, compare the returnedidto what you have stored. - The body reflects the record as it is now, not as it was. Replay a payment create an hour later and you get the same intent with
status: "expired"and, if it was paid, a differentsettlement_status. The response is live, not a snapshot. - A different body with the same key is ignored, silently. SouthPay does not compare request payloads. Send
49.99 USDwith keyK, then999.00 USDwith keyK, and you get the original49.99intent back at201. There is no409, no conflict detection, and no error code for this. Your amount change is dropped without a word.
Keys never expire
There is no TTL, no expiry column, and no sweeper job. A key lives as long as the row it is attached to, which is forever. This is the opposite of the usual 24-hour window, and it changes how you generate keys. A key you used for an order last year will still resolve to last year’s payment intent today. If a customer retries a checkout after the first intent expired, you must use a new key or you will hand them the dead intent.Uniqueness scope, and one sharp edge
Different resources scope the key differently.
The other sharp edge is mode. Payment intent lookup is scoped to
(store, key, livemode), but the database constraint enforcing uniqueness is only (store, key). Reusing the same key in live mode that you already used in test mode does not create a new intent and does not return the old one. It fails. Never share an idempotency key between test and live mode. Prefix your keys with the mode if there is any chance your test and production systems generate the same values.
Choosing a key
- Make it unique per intended operation, not per HTTP request. A retry of the same operation must reuse the key; that is the entire point.
- Do not generate a fresh UUID inside the retry loop. A new value on each attempt means a new payment intent on each attempt.
- Do generate the UUID once, before the first attempt, and persist it alongside the order before you send the request. If your process dies mid-request you can resume with the same key.
- There is no length limit and no character validation. Anything your HTTP client will send as a header value is accepted.
POST times out, you do not know whether it landed. Replaying the stored key resolves that without risking a duplicate.
What idempotency does not cover
- It does not make
5xxresponses safe on endpoints that ignore the header. RetryingPOST /billing/customersafter a timeout can create two customers; deduplicate on your side. - It does not deduplicate webhook deliveries to your endpoint. SouthPay uses its own internal key per
(endpoint, event)pair, but delivery is at-least-once and retries can repeat. Deduplicate on the eventid. - It does not roll anything back. If a request partly succeeded before failing, the replay returns whatever state exists.

