AbsolutePayDocs

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

StatusMeaningWhat to do
400Bad request — a field is missing or malformed.Fix the request.
401Unauthorized — missing/invalid key or signature.Check key + signing.
403Forbidden — the key lacks the required scope (or a disallowed IP).Grant the scope.
404Not found — the resource doesn't exist, or isn't yours.Check the id.
409Conflict — 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.
429Rate limited.Back off; honor Retry-After.
5xxServer-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:

CaseResult
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 body409 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:

BucketLimitMethods
Read100 requests / secondGET, HEAD
Write100 requests / secondPOST, 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 + jitter
func 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-Key matters most.
  • Going live — force failures in the sandbox and prove your handling before launch.
  • API reference — the exact code values each endpoint can return.

On this page