Skip to main content
Send an 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. For POST /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-Replayed header, no distinct status. If you need to know, compare the returned id to 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 different settlement_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 USD with key K, then 999.00 USD with key K, and you get the original 49.99 intent back at 201. There is no 409, no conflict detection, and no error code for this. Your amount change is dropped without a word.
Because a mismatched payload is dropped rather than rejected, a key derived only from an order id will silently return a stale intent if the cart total changes. Include something that varies with the request contents, or version the key when the payload changes.

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.
Increment the attempt counter whenever you want a genuinely new payment for the same order. Do not reuse the order id alone.

Uniqueness scope, and one sharp edge

Different resources scope the key differently.
Make payout and balance swap keys globally unique. Their uniqueness constraint is not scoped to your store, so a short, guessable key like payout-1 can collide with another merchant’s record. Prefix every payout and swap key with a UUID you generate.
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.
Persisting the key first is what makes the retry safe. If the 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 5xx responses safe on endpoints that ignore the header. Retrying POST /billing/customers after 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 event id.
  • It does not roll anything back. If a request partly succeeded before failing, the replay returns whatever state exists.
See Errors for which status codes are worth retrying at all.
Last modified on August 21, 2026