Best practices
How to build a payment integration that never loses or double-counts money, scales to thousands of users without hitting rate limits, and stays secure.
The reference guides show you how to call each endpoint. This one shows you how to build an integration that holds up in production — one that never loses or double-counts a payment, scales to thousands of users without tripping the rate limit, and keeps your secrets safe.
Read it once before you point real traffic at AbsolutePay. Each section links to the reference page with the full code.
The mental model: three nets
A payment is real money crossing a network you don't control. Never rely on a single signal. Build on three overlapping nets, weakest failure mode first:
- Webhooks (fast path). Fulfill the moment a
payment.succeededevent arrives. - Polling (fallback). Read the order back by its token when a webhook is missed — a receiver was down, a URL was wrong, the app wasn't approved yet.
- Reconciliation (backstop). A scheduled job that matches the settled feed against your own ledger, catching anything both a missed webhook and a missed poll let slip.
Never fulfill on the create response
POST /v1/checkouts returns status: "OPEN", not a settlement. And never fulfill on the browser
redirectUrl params either — they're untrusted display hints. Only a terminal paid state,
confirmed server-side, means the money arrived.
Poll at scale without hitting the rate limit
This is the section most integrations get wrong. The read limit is 100 requests / second per API
key (Errors → Rate limits), a bucket separate from writes.
A naive "poll every open order every 5 seconds" design blows through it the instant you have a few
thousand open orders — and because the bucket is per key, every worker sharing that key shares the one
ceiling. Exceed it and you get 429s that stall all your reads, including the polls you actually need.
Four rules keep polling cheap:
1. Centralize polling in one worker with a shared limiter
Poll from a single background worker (or a coordinated pool) that runs every request through one shared rate limiter sized below the ceiling — not from web request handlers, and not from a cron fan-out that ignores the global budget. Adding more workers on the same key does not buy more budget; it's one bucket.
2. Back off in tiers, and jitter
An order barely needs polling after the first few seconds. Poll frequently right after creation, then progressively slower, and stop at a terminal state or expiry:
5s → 10s → 30s → 1m → 3m → 5m → 10m → 30m → stopThat's ~8 reads over an order's lifetime, almost all in the slow tiers. Add jitter (± a random fraction of each interval) so a burst of orders created together doesn't fire their polls in lockstep and spike one tier.
3. Poll only the un-settled set, bounded by expiry
Poll only orders still OPEN/pending that you haven't already settled via webhook. The instant an
order reaches a terminal state — or passes its expiry — drop it from the poll set. Orders expire on
their own (hosted checkouts and links don't need an explicit close), so the set you poll is naturally
bounded, and in practice it's just the small tail the webhook missed.
4. Honor Retry-After, never hard-code a rate
On a 429, wait the Retry-After seconds (whole seconds) and resume. Treat that header as the source
of truth — the ceiling may change, so a hard-coded "we can do N/s" silently breaks.
Does it actually fit under 100 rps?
Yes, comfortably. The read ceiling is 100 rps = 6,000 reads/minute. With tiered backoff, an order costs ~8 reads total and spends only its first ~15 seconds in the fast tiers. Even at 10,000 orders/day with peaks of ~5 new orders/sec, the fast tier holds ~25 orders (~5 rps) and the aged backlog adds well under 20 rps — under 30 rps total. Naive flat polling of the same book would be 10×+ over. The biggest lever is that webhooks fulfill first, so you only ever poll the stragglers.
A poller loop, in shape:
// One shared limiter for the whole poller — sized UNDER the 100 req/s read ceiling.
const limiter = new TokenBucket({ ratePerSec: 80 });
const TIERS_MS = [5_000, 10_000, 30_000, 60_000, 180_000, 300_000, 600_000, 1_800_000];
async function pollOnce(order) {
await limiter.acquire(); // stay under the read bucket
let c;
try {
c = await ap.checkouts.get(order.token); // a READ — counts against the read bucket
} catch (e) {
if (e.isRateLimited) return backoff(order, e.retryAfterSeconds); // honor Retry-After
throw e;
}
if (c.status === "PAID") return fulfillOnce(order); // idempotent with the webhook path
if (Date.now() > order.expiresAt) return drop(order); // expired → terminal, stop polling
const next = TIERS_MS[Math.min(order.tier++, TIERS_MS.length - 1)];
scheduleAt(order, Date.now() + next + jitter(next)); // back off + jitter to de-sync bursts
}The helpers (TokenBucket, jitter, scheduleAt, fulfillOnce, drop, backoff) are your own —
the shape is what matters. fulfillOnce shares the idempotent path with your webhook handler.
Need more than one bucket?
For very high volume, split load across multiple API keys — each key is its own read/write bucket. Pair this with one app per integration surface (see Least-privilege scopes) so a poller, a checkout server, and a payout job never contend for the same budget.
Make every handler idempotent
Deliveries are at-least-once, and your webhook and your poll are two code paths that converge on the same settlement. Without act-once semantics you double-fulfill.
- De-dupe on
event.idwith a durable unique index (INSERT … ON CONFLICT DO NOTHING), not an in-memory set. Key on the top-levelevent.id, not on your order reference — many events share one reference. - Make the state transition itself idempotent as a second line of defense: flip
OPEN → PAIDand no-op if it's already paid. A poll and a webhook are different code paths; don't rely on the de-dupe table alone. - Return non-2xx on transient failures. A
5xxor timeout is retried with backoff, so you'll get the event again — that's the safety net. Never swallow an error to force a 2xx.
See Webhooks for the full handler and Errors for retry semantics.
Crypto settlement is final — idempotency is not optional
On-chain settlement is irreversible: no chargeback, no recall. A duplicate payout is lost funds. So
every money-moving POST (payouts, refunds, conversions, off-ramp, gift cards, plan/subscription
create) must carry an Idempotency-Key.
- Generate one key per operation, store it on your order row before the request, and reuse it on
every retry. The SDKs pass it via
idempotencyKey/idempotency_key=/IdempotencyKey. - The server de-dupes on the key plus the request body: same key + same body replays the original
response; same key + different body →
409 idempotency_conflict; same key still running →409 idempotency_in_progress. - Only definitive responses (
2xx, or a non-4294xx) are cached — a transient5xxor429releases the claim, so a same-key retry genuinely re-runs (it won't replay a stale error). Never generate a fresh key on retry; that defeats the mechanism.
Handle errors and rate limits like a good citizen
Retry 429 and 5xx (and network-level failures) with exponential backoff + jitter, capped. Reads
and idempotent writes retry freely; non-idempotent writes retry only with the same idempotency key.
The full retry helper in three languages is in Errors.
- Cap concurrency under the per-key bucket. Steady-state throughput = your concurrency ÷ round-trip time; keep it under 100 rps per key. For more headroom, spread across keys.
- Reads and writes are independent buckets — a read burst won't throttle your writes, so you don't need to co-throttle them.
- A limiter outage fails open. The absence of
429s during an incident is not a green light to hammer — you can mask a real overload. - Sandbox still enforces the API rate limit. It only relaxes the sign-up / verification throttles, so it's the right place to validate your backoff and queueing before going live.
Integrate correctly
Use the official SDK
The Node / Python / Go SDKs compute the timestamp, nonce, and request signature on every call, raise one typed error, and pass idempotency keys. Hand-rolled signing is the single most common integration footgun — the SDK removes the entire "sign exactly what you send" failure class. SDKs are server-side only.
If you must sign raw HTTP (Authentication):
- Keep the host clock NTP-synced — the signature timestamp must be within ±5 minutes of ours, in milliseconds, reused in both the header and the signed string.
- Generate a fresh random nonce per request (single-use). A retry is a new request → new nonce and
timestamp; idempotency is carried by the
Idempotency-Key, never the nonce. - Sign the exact bytes you transmit — the canonical string binds method + full path & query +
body hash. Re-serializing the JSON or appending a query param after signing →
401.
Least-privilege scopes
Issue one app per integration surface (checkout server, reconciliation reader, payout job), each
with the minimum scopes. A :read never needs a :write. account:admin and app-management scopes
are not grantable to a tenant key by design, so a leaked app key can only ever act within its own
workspace and its granted scopes — minimize both. One-app-per-surface also gives each surface its own
rate-limit bucket. See Authentication → Scopes.
Choose the right flow, and reuse deposit addresses
| Flow | Use it when |
|---|---|
Checkout (POST /v1/checkouts) | Hosted link, the payer picks asset + network on the page. |
Invoice (POST /v1/invoices, chain required) | You fix the amount and network and want the deposit address up front. |
Deposit address (POST /v1/deposits/address) | A permanent top-up address for your own workspace balance — not a per-order collection address. |
Deposit addresses are permanent per (workspace, chain): POST /v1/deposits/address is
get-or-create — it returns the stored address if one exists and mints only on first ask. Call it once
and cache the result; don't mint a new address per top-up. See Accept payments,
Payment links, and Deposits.
The order's creator app receives its webhooks
The app that creates a checkout or invoice is the app its payment.succeeded webhook routes to.
If you create the order with app A but listen on app B's webhook endpoint, you'll never get the event.
Create and listen on the same app.
Paginate with cursors, resume from the same cursor
Every list returns { items, nextCursor }. Loop, echoing nextCursor back as before, until it's
null. The cursor is opaque — never build or parse it. On a long scan, a 429 mid-way → back off
and resume from the same cursor (don't restart from page one). See Pagination.
Guard your secrets
You hold three secrets per app, each with a different job. All three are shown once, live server-side only, and should be loaded from a secret manager — never a browser bundle, never git, never a log line.
| Secret | Prefix | Job |
|---|---|---|
| API token | ap_live_ / ap_test_ | Authorization: Bearer — identifies the app |
| Signing secret | apisign_ | HMAC over each outbound request (integrity + anti-replay) |
| Webhook secret | whsec_ | verifies the webhooks we send you |
- Rotate periodically. Rotating the token keeps the app approved; losing a secret means rotate to get fresh ones.
- Separate
ap_test_andap_live_keys per environment, and one app per integration so a leak is contained. - Verify every webhook before trusting it, in this order: recompute the HMAC on the raw bytes,
compare timing-safe, reject stale timestamps (replay), de-dupe on
event.id, durably record the event, then return 2xx — never ack before you've persisted it. The SDK'sconstructEventdoes verify + freshness + parse in one call. - Pin your origin with the optional per-app IP allowlist (CIDRs), and point us at a real public HTTPS endpoint — deliveries are SSRF-guarded, so private/loopback/metadata targets are rejected.
Set the webhook URL + IP allowlist before approval
Editing a live app re-triggers review, and it can't authenticate until re-approved (rotating the token does not re-trigger review). Configure the production webhook URL and IP allowlist in one edit before you go live, so a cutover doesn't lock you out mid-flight.
Activation is asynchronous — handle "setting up"
A workspace can only move money once it's active, which needs both KYC/KYB cleared and its payment account provisioned. Provisioning happens asynchronously after KYC clears, so activation is not instant. The lifecycle is:
draft → verifying → provisioning → active (frozen = held)Before a workspace is active, money and balance calls return 403 (e.g. "not active yet" while
provisioning, or "finish verification" before that). Don't hard-code an assumption that a freshly
created workspace can accept payments.
- Treat a
403during onboarding as "not ready yet" — poll the workspace status and surface a "setting up" state to your own users rather than erroring out. - Complete KYC/KYB and wait for
activebefore pointing production traffic at a workspace. - Know that an abnormal payout burst can freeze the account (
429 velocity_frozen) — design your payout flow around the webhook + poll, not the create response.
Money amounts
- Decimal strings end to end. Represent every amount as a decimal string (≤ 6 fraction digits),
from your database through the request to display. Never rehydrate it as a float — no
parseFloat, no JSNumber, no naivetoFixedon the money path. The API rejects floats, scientific notation, and negatives. - Match currency and chain exactly against the supported options before submitting — an unsupported pair fails the order, and funds sent on the wrong network are unrecoverable.
- Know gross vs net. The fee is a disclosed markup (
fee = network fee + margin), so the settled amount differs from the order amount. Callfees.previewright before creating an order to show the exact split — it's an indicative quote, not a locked rate. - Delivered can differ from requested on a
PARTIALpayout batch — reconcile per suborder, not just per batch. See Payouts.
Before you go live
Everything above rolls up into the Going live checklist — verified workspace, approved
app, secrets from env, request signing, raw-body webhook verification + idempotency, Idempotency-Key
on money moves, decimal-string amounts, backoff on 429/5xx, webhook URL + IP allowlist set before
approval, and scheduled reconciliation with monitoring. Run through it before flipping to live keys.
Next
- Webhooks — verify and handle deliveries reliably.
- Errors — retry, idempotency, and rate-limit semantics.
- Balances & reconciliation — the scheduled backstop.
- Going live — the pre-launch checklist.