Skip to content

Idempotency, retries and errors

Idempotency: a retry is never a second charge

Section titled “Idempotency: a retry is never a second charge”

Networks fail in the worst place: after we acted, before you heard. Send an Idempotency-Key header on every POST — a UUID per operation — and retrying with the same key returns the first result instead of acting again. The replayed answer says Idempotent-Replayed: true.

Terminal window
key="order-$(date +%s)-$RANDOM"
first=$(curl -sS https://api.tuppence.ai/v1/payments \
-H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $key" \
-H "Content-Type: application/json" -d '{"amount": 2000, "currency": "gbp"}' | jq -r .id)
again=$(curl -sS https://api.tuppence.ai/v1/payments \
-H "Authorization: Bearer $TUPPENCE_SECRET_KEY" -H "Idempotency-Key: $key" \
-H "Content-Type: application/json" -d '{"amount": 2000, "currency": "gbp"}' | jq -r .id)
echo "$first $again"
test "$first" = "$again" # the same payment, not two
  • Keys are kept for 24 hours, per account and mode (test and live keys never collide).
  • The same key with different parameters is refused: 409 idempotency_key_reuse.
  • A retry that arrives while the first request is still running gets 409 idempotency_in_progress — wait a moment and retry.
  • Refunds, payouts, credit top-ups and payments require a key. The SDKs send one on every POST and reuse it across that call’s retries, so you rarely need to think about it — pass your own (an order id, say) when a retry might come from a different process.

Retry, with backoff, on: a network error or timeout; 5xx; 429 (honour Retry-After); and 409 idempotency_in_progress. Never retry any other 4xx — the same request will be refused again. The SDKs do exactly this, twice by default.

Every failure has the same shape:

{
"error": {
"type": "invalid_request_error",
"code": "amount_too_large",
"message": "Only 1500 is left to refund on this payment.",
"param": "amount",
"request_id": "req_01J8ZKF8G0H2J4K6M8N0P2Q4R6"
}
}

Branch on code — it is stable; codes are added, never renamed. message is for people and may change. request_id finds the request in your logs and ours: quote it to support.

type HTTP Some codes
invalid_request_error 400, 404 parameter_missing, parameter_invalid (see param), invalid_id, resource_missing, amount_too_small, amount_too_large, invalid_api_version
authentication_error 401 invalid_api_key
permission_error 403 permission_denied (a restricted key without the scope), ip_not_allowed, onboarding_incomplete, live_mode_unavailable
card_error 402 card_declined (see decline_code), authentication_required, own_card_not_allowed, insufficient_credits
idempotency_error 409 idempotency_in_progress, idempotency_key_reuse
rate_limit_error 429 rate_limited
api_error 5xx internal_error, upstream_unavailable

The API reference lists every code, and which responses each operation can give.

Per API key: 100 requests a second in live mode (bursts to 200), 25 in test mode (bursts to 50). Every response says where you are: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset; a 429 adds Retry-After. CSV and PDF exports are slower: one every two seconds, in bursts of five.