Errors & idempotency
The error shape, handling errors in the SDK, idempotency, rate limits, and retrying safely.
Every non-2xx response has one small, predictable shape, and every SDK raises one typed error for
it — so error handling is one catch, everywhere.
Error shape
Errors return a non-2xx status and a small JSON body:
{ "code": "rate_limited", "title": "too many requests", "detail": "…" }code— a stable, machine-readable string. Branch on this.title— a human-readable summary. Show or log this; don't parse it.detail— optional extra context.
Every response carries an x-request-id; include it when you report a problem.
Handle errors in the SDK
Non-2xx responses raise AbsolutePayError (Node/Python) / return *absolutepay.Error (Go), carrying
status, code, detail, and the request id, plus isAuth / isRateLimited helpers for the two
branches you'll actually write:
import { AbsolutePayError } from "absolutepay";
try {
await ap.payouts.create({ items: [/* … */] });
} catch (e) {
if (e instanceof AbsolutePayError) {
console.error(e.status, e.code, e.message, e.detail, e.requestId); // message = the title
if (e.isAuth) { /* 401/403 — check key, scope, or signature */ }
if (e.isRateLimited) { /* 429 — back off and retry */ }
}
throw e;
}from absolutepay import AbsolutePayError
try:
ap.payouts.create([...])
except AbsolutePayError as e:
print(e.status, e.code, e.detail, e.request_id) # str(e) is the human-readable title
if e.is_auth: # 401/403 — check key, scope, or signature
...
if e.is_rate_limited: # 429 — back off and retry
...
raise_, err := ap.Payouts.Create(ctx, items)
var apErr *absolutepay.Error
if errors.As(err, &apErr) {
fmt.Println(apErr.Status, apErr.Code, apErr.Title, apErr.Detail, apErr.RequestID)
if apErr.IsAuth() { /* 401/403 — check key, scope, or signature */ }
if apErr.IsRateLimited() { /* 429 — back off and retry */ }
}In Python and Go, status == 0 means the request failed at the network layer before any HTTP
response arrived — treat it like a 5xx (safe to retry reads; retry writes only with the same
idempotency key). Inbound webhook verification has its own error
(WebhookSignatureError / ErrInvalidSignature) — see Webhooks.
Common statuses
| Status | Meaning | What to do |
|---|---|---|
400 | Bad request — a field is missing or malformed. | Fix the request. |
401 | Unauthorized — missing/invalid key or signature. | Check key + signing. |
403 | Forbidden — the key lacks the required scope (or a disallowed IP). | Grant the scope. |
404 | Not found — the resource doesn't exist, or isn't yours. | Check the id. |
409 | Conflict — idempotency_conflict (same key, different body) or idempotency_in_progress (a request with that key is still running). | Use a fresh key, or retry once the in-flight request settles. |
429 | Rate limited. | Back off; honor Retry-After. |
5xx | Server-side. | Retry idempotent calls with backoff. |
The specific code each endpoint can return is listed on that endpoint in the API reference.
Idempotency
Money-moving POSTs — payouts, refunds, conversions,
off-ramp withdrawals, gift cards, and subscription/plan creation — accept a unique
Idempotency-Key header. Generate the key once per operation (e.g. a UUID stored with your order row)
and reuse it across retries. The SDKs pass it for you via an idempotencyKey option on create
(idempotency_key= in Python, an IdempotencyKey field in Go).
The middleware keys on Idempotency-Key plus the request body:
| Case | Result |
|---|---|
| Same key + same body (a retry) | Replays the original response — the operation runs at most once. The replay carries an Idempotent-Replayed: true header. |
| Same key + different body | 409 idempotency_conflict — you reused a key for a different request. Use a fresh key. |
| Same key still in flight (concurrent duplicate) | 409 idempotency_in_progress — a request with that key is already running. Retry after it settles. |
Only successful terminal responses are cached: a 5xx or 429 is not stored, so retrying with the
same key genuinely re-runs the operation (it doesn't replay the error). Crypto settlement is final — a
duplicate payout can't be clawed back — so an idempotency key is not optional on the payout path.
Rate limits & retrying safely
Requests are rate limited per API key (per-IP for unauthenticated traffic), with separate buckets for reads and writes:
| Bucket | Limit | Methods |
|---|---|---|
| Read | 100 requests / second | GET, HEAD |
| Write | 100 requests / second | POST, PUT, PATCH, DELETE |
Every response carries an X-RateLimit-Limit header with the ceiling for that bucket. Exceed it and the
next request gets a 429 with a Retry-After header (whole seconds) — wait that long before retrying.
These are the current ceilings and may change, so treat 429 + Retry-After as the source of truth
rather than hard-coding a fixed send rate.
- Reads and writes are independent buckets — a burst of reads won't throttle your writes.
- The bucket is per key. Workers that share one key share one bucket; for more headroom, spread load across keys or cap your concurrency so the steady rate stays under the ceiling.
- The same limits apply in sandbox, so you can validate your backoff and queueing there before going live. (Sandbox only relaxes the sign-up / verification throttles, never the API rate limit.)
Retry 429 and 5xx with exponential backoff + jitter:
async function withRetry<T>(fn: () => Promise<T>, tries = 5): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e) {
const retryable = e instanceof AbsolutePayError && (e.isRateLimited || e.status >= 500);
if (!retryable || i >= tries - 1) throw e;
const delay = Math.min(1000 * 2 ** i, 30_000) + Math.random() * 250; // jitter
await new Promise((r) => setTimeout(r, delay));
}
}
}import random
import time
from absolutepay import AbsolutePayError
def with_retry(fn, tries=5):
for i in range(tries):
try:
return fn()
except AbsolutePayError as e:
if not (e.is_rate_limited or e.status >= 500) or i >= tries - 1:
raise
time.sleep(min(2 ** i, 30) + random.random() * 0.25) # seconds: exponential backoff + jitterfunc withRetry[T any](fn func() (T, error), tries int) (T, error) {
var v T
var err error
for i := 0; i < tries; i++ {
if v, err = fn(); err == nil {
return v, nil
}
var apErr *absolutepay.Error
if !errors.As(err, &apErr) || !(apErr.IsRateLimited() || apErr.Status >= 500) || i == tries-1 {
return v, err
}
delay := time.Duration(min(1<<i, 30)) * time.Second // exponential
time.Sleep(delay + time.Duration(rand.Intn(250))*time.Millisecond) // + jitter
}
return v, err
}Reads and idempotent writes are safe to retry. For non-idempotent writes, retry only with the same
Idempotency-Key so you never duplicate the effect.
Next
- Webhooks — the delivery-side twin: verify, de-dupe on
event.id, respond fast. - Payouts — where
Idempotency-Keymatters most. - Going live — force failures in the sandbox and prove your handling before launch.
- API reference — the exact
codevalues each endpoint can return.