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.
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 twokey="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 twokey="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 twoimport { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);const idempotencyKey = `order-${Date.now()}`;
const first = await tuppence.payments.create({ amount: 2000, currency: "gbp" }, { idempotencyKey });const again = await tuppence.payments.create({ amount: 2000, currency: "gbp" }, { idempotencyKey });console.log(first.id === again.id); // true: the same payment, not twoimport { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);const idempotencyKey = `order-${Date.now()}`;
const first = await tuppence.payments.create({ amount: 2000, currency: "usd" }, { idempotencyKey });const again = await tuppence.payments.create({ amount: 2000, currency: "usd" }, { idempotencyKey });console.log(first.id === again.id); // true: the same payment, not twoimport { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);const idempotencyKey = `order-${Date.now()}`;
const first = await tuppence.payments.create({ amount: 2000, currency: "eur" }, { idempotencyKey });const again = await tuppence.payments.create({ amount: 2000, currency: "eur" }, { idempotencyKey });console.log(first.id === again.id); // true: the same payment, not twoimport osimport timefrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])key = f"order-{time.time_ns()}"
first = tuppence.payments.create(amount=2000, currency="gbp", idempotency_key=key)again = tuppence.payments.create(amount=2000, currency="gbp", idempotency_key=key)print(first.id == again.id) # True: the same payment, not twoimport osimport timefrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])key = f"order-{time.time_ns()}"
first = tuppence.payments.create(amount=2000, currency="usd", idempotency_key=key)again = tuppence.payments.create(amount=2000, currency="usd", idempotency_key=key)print(first.id == again.id) # True: the same payment, not twoimport osimport timefrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])key = f"order-{time.time_ns()}"
first = tuppence.payments.create(amount=2000, currency="eur", idempotency_key=key)again = tuppence.payments.create(amount=2000, currency="eur", idempotency_key=key)print(first.id == again.id) # True: the same payment, not two| Rule | |
|---|---|
| Key | 1–255 printable ASCII characters, no spaces. Use a UUID per operation |
| Kept for | 24 hours, per account and mode (test and live never collide) |
| Same key, different parameters | 409 idempotency_key_reuse |
| Same key while the first is still running | 409 idempotency_in_progress. Wait, then retry |
| Required on | Payments, refunds, payouts and credit top-ups. Without one: 400 idempotency_key_required |
| SDKs | Send 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 |
Retry these, and only these
Section titled “Retry these, and only these”| Response | Retry? |
|---|---|
| Network error or timeout | Yes, with backoff |
5xx | Yes, with backoff |
429 | Yes, after Retry-After |
409 idempotency_in_progress | Yes, with the same key |
Any other 4xx | Never. It will be refused again |
The SDKs follow these rules, retrying 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" }}| Field | Use it for |
|---|---|
code | Branching. Stable: codes are added, never renamed |
message | People. May change |
param | The field at fault, when there is one |
request_id | Finding the request in your logs and ours. Quote it to support |
type | HTTP | codes |
|---|---|---|
invalid_request_error | 400, 404 | parameter_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_error | 409 | next_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_error | 410 | use_payout_settings |
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, issuing_card_limit, issuing_cardholder_limit, dispute_submission_needs_person, next_day_unavailable, direct_debit_ineligible, direct_debit_not_enabled, direct_debit_paused |
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 |
What the less obvious codes mean:
code | Means |
|---|---|
dispute_not_contestable | Evidence for an indemnity claim on a Direct Debit, which can’t be contested |
payment_method_unavailable | The 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_unusable | A saved bank account whose Direct Debit mandate was cancelled or isn’t active yet: ask the customer to pay again |
next_day_unavailable | 403: 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_settings | Next-day payouts are no longer requested: use POST /v1/payout_settings/next_day |
region_mismatch | The account still lives on another region’s platform, so nothing was charged; Tuppence moves it |
country_mismatch | The 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_open | A Direct Debit application is already waiting for a decision |
direct_debit_paused | Direct 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:
code | Extra fields |
|---|---|
issuing_card_limit, issuing_cardholder_limit | limit (which card limit), used, max, resets_at (Unix seconds, or null), increase_url |
limit_above_account_maximum | limit (the account maximum a card’s own limit went over), max |
dispute_evidence_incomplete | missing_evidence: each missing field and its label (card disputes) |
dispute_window_closed | dispute_by: the date it had to be submitted by |
dispute_not_allowed | available_at, when waiting will let it through (15 days after a cancellation) |
next_day_unavailable | reason (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_open | reason (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_paused | reason (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_exceeded | period (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_unavailable | At 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.
Rate limits
Section titled “Rate limits”Per API key:
| Mode | Steady | Burst |
|---|---|---|
| Live | 100 requests a second | 200 |
| Test | 25 requests a second | 50 |
| CSV and PDF exports | one every two seconds | 5 |
Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a 429
adds Retry-After.
- Webhooks: learn the outcome without polling.
- Versions and deprecation