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.
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
POSTand 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 these, and only these
Section titled “Retry these, and only these”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.
Errors
Section titled “Errors”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.
Rate limits
Section titled “Rate limits”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.