Skip to content

Idempotency, retries and errors

Send an Idempotency-Key on every POST so a retry never acts twice. Branch on the error’s code and retry only what’s retryable.

Idempotency: a retry is never a second charge

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

A request can succeed on our side and the response still be lost. Retrying with the same key returns the first result, marked Idempotent-Replayed: true, instead of acting again.

Your serverTuppencePOST /v1/payments, key Kcreates pay_123response lostretry: same body, key Kpay_123, Idempotent-Replayed
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
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": "usd"}' | 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": "usd"}' | jq -r .id)
echo "$first $again"
test "$first" = "$again" # the same payment, not two
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": "eur"}' | 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": "eur"}' | jq -r .id)
echo "$first $again"
test "$first" = "$again" # the same payment, not two
Rule
Key1–255 printable ASCII characters, no spaces. Use a UUID per operation
Kept for24 hours, per account and mode (test and live never collide)
Same key, different parameters409 idempotency_key_reuse
Same key while the first is still running409 idempotency_in_progress. Wait, then retry
Required onPayments, refunds, payouts and credit top-ups. Without one: 400 idempotency_key_required
SDKsSend one on every POST and reuse it across that call’s retries. Pass your own (idempotencyKey / idempotency_key, such as an order id) when a retry might come from another process
ResponseRetry?
Network error or timeoutYes, with backoff
5xxYes, with backoff
429Yes, after Retry-After
409 idempotency_in_progressYes, with the same key
Any other 4xxNever. It will be refused again

The SDKs follow these rules, retrying 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"
}
}
FieldUse it for
codeBranching. Stable: codes are added, never renamed
messagePeople. May change
paramThe field at fault, when there is one
request_idFinding the request in your logs and ours. Quote it to support
typeHTTPcodes
invalid_request_error400, 404parameter_missing, parameter_invalid (see param), idempotency_key_required, invalid_id, resource_missing, amount_too_small, amount_too_large, invalid_api_version, invalid_state, limit_above_account_maximum, dispute_evidence_incomplete, dispute_window_closed, dispute_not_allowed, dispute_not_contestable, region_unavailable, country_not_supported, payment_method_unavailable, direct_debit_limit_exceeded, payment_method_unusable
invalid_request_error409next_day_unavailable (reason: stripe_refused), region_mismatch, country_mismatch, direct_debit_application_open, amount_changed (a store’s total changed before paying: nothing was charged)
invalid_request_error410use_payout_settings
authentication_error401invalid_api_key
permission_error403permission_denied (a restricted key without the scope), ip_not_allowed, onboarding_incomplete, live_mode_unavailable, issuing_card_limit, issuing_cardholder_limit, dispute_submission_needs_person, next_day_unavailable, direct_debit_ineligible, direct_debit_not_enabled, direct_debit_paused
card_error402card_declined (see decline_code), authentication_required, own_card_not_allowed, insufficient_credits
idempotency_error409idempotency_in_progress, idempotency_key_reuse
rate_limit_error429rate_limited
api_error5xxinternal_error, upstream_unavailable

What the less obvious codes mean:

codeMeans
dispute_not_contestableEvidence for an indemnity claim on a Direct Debit, which can’t be contested
payment_method_unavailableThe payer chose a method the business doesn’t take or can’t use here (Direct Debit on a booking or at the till, not yet switched on, or in live mode before it opens). Pay by card
payment_method_unusableA saved bank account whose Direct Debit mandate was cancelled or isn’t active yet: ask the customer to pay again
next_day_unavailable403: next-day payouts can’t be switched on now (see reason). 409 with reason: stripe_refused: the card processor didn’t accept the change and nothing changed. Try again
use_payout_settingsNext-day payouts are no longer requested: use POST /v1/payout_settings/next_day
region_mismatchThe account still lives on another region’s platform, so nothing was charged; Tuppence moves it
country_mismatchThe account’s card processor account is in a different country from the business, so nothing was done; Tuppence fixes it. On a location, the address isn’t in your account’s country
direct_debit_application_openA Direct Debit application is already waiting for a decision
direct_debit_pausedDirect Debit is paused while Tuppence reviews it

Some errors carry extra fields so your code can act without parsing message. The SDKs keep them on the error’s raw:

codeExtra fields
issuing_card_limit, issuing_cardholder_limitlimit (which card limit), used, max, resets_at (Unix seconds, or null), increase_url
limit_above_account_maximumlimit (the account maximum a card’s own limit went over), max
dispute_evidence_incompletemissing_evidence: each missing field and its label (card disputes)
dispute_window_closeddispute_by: the date it had to be submitted by
dispute_not_allowedavailable_at, when waiting will let it through (15 days after a cancellation)
next_day_unavailablereason (as next_day.reason on the payout settings, or stripe_refused), available_from (Unix seconds, or null), fix_url
direct_debit_ineligible, direct_debit_application_openreason (region, not_verified, held, declined_recently or open_application) and fix_url, the dashboard page that resolves it (null for region: Direct Debit is for UK businesses taking pounds)
direct_debit_not_enabled, direct_debit_pausedreason (not_approved, off or paused) and fix_url, the dashboard’s Direct Debit page (it opens Apply when not approved). The message is for the payer and never says why
direct_debit_limit_exceededperiod (debit: one debit over the per-debit limit; 30_days: the rolling 30 days over the monthly limit), limit, remaining (the most a debit could be now), both in minor units of currency, and increase_url (Ask for higher limits). A payer on the pay page sees none of these
country_not_supported, region_unavailableAt sign-up: country, country_label, coming_soon (on the waitlist) and waitlist_url. On Connect, country_not_supported means a connected account in a different region from its platform

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

Per API key:

ModeSteadyBurst
Live100 requests a second200
Test25 requests a second50
CSV and PDF exportsone every two seconds5

Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a 429 adds Retry-After.

Docs assistant

Open as a page
Loading the assistant…