Reliability

How to poll, how to keep your own history, and how to retry a send without sending twice.
View as MarkdownOpen in Claude

Two things shape any serious integration: you learn a wallet’s state by asking for it, and a /send is only safe to repeat if you repeat it correctly. Design for both.

Detecting deposits

You learn that a user received funds by checking for it: poll /balance or /transactions for that user and asset.

Note the shape of that cost — it is one call per user, per asset. With 16 assets and 10,000 users, a naive full sweep is 160,000 requests. Poll only the assets you actually support, stagger users across the interval, and back off hard on wallets that never change.

Confirming a send

A 200 from /send means the network accepted the transaction, not that it succeeded. Check it by polling /txid with the txHash until status leaves pending:

{ "result": { "hash": "0x…", "type": "send", "amount": "25.5",
"fee": "0.000315", "status": "success", "date": "2026-07-13T09:14:22.000Z" } }

status is pending, success or failed. While pending, date is null.

Sensible intervals, given how fast each chain settles:

ChainPoll everyGive up after
Solana~2s~2 min
BSC, Polygon~5s~10 min
Ethereum~15s~30 min
Tron~5s~10 min
Bitcoin~60shours — a low-fee tx can sit for a long time

Escalate a transfer that stays pending past those windows to your operator — treat it as stuck or dropped, and resolve it outside this API rather than by sending again.

/transactions is a feed, not a ledger

/transactions takes userId and assetId, and returns roughly the last 10 transactions for that pair. The window is set server-side.

Persist transactions on your side as you see them, keyed on hash. Once a transaction falls off the end of that window, you will not be able to read it back from this endpoint — so if you need statements, reconciliation or an audit trail, your own store has to be the record.

Treat /transactions as “what happened recently”, and your own database as the record.

Retrying a /send safely

/send is idempotent on the X-Request-Id header, which it requires you to supply. That header is what separates “retry the transfer I already authorised” from “authorise a second transfer” — and the service cannot tell those apart on your behalf. It only knows what you put in the header.

One id per logical transfer. The same id on every retry of it. A fresh id only for a genuinely new transfer.

Mint the id before the first attempt and store it with your intent record. If you generate a new one when retrying, you have asked for a second transfer, and you will get one — idempotency cannot save you, because you never told the service it was the same transfer.

Repeat a request with the same id and the service resolves it against the original rather than re-broadcasting:

Original attemptWhat a repeat with the same id does
CompletedReplays the stored result — the same txHash, no second transaction.
Still in flight409 — the outcome isn’t known yet. Wait and re-check; do not mint a new id.
Failed before broadcastProceeds normally, as a fresh attempt.
Same id, different assetId / recipient / amount422 — that id is committed to a different transfer.

Which failures are safe to retry

OutcomeWas anything broadcast?Retry with the same id?
400 bad inputNoYes, once fixed
401 wrong passwordNoYes, once fixed
402 insufficient fundsNoYes, once funded
404 no such userNoNever unchanged — fix the userId
409 in flight / wallet busyNo — nothing newYes, after a short backoff
422 amount below the feeNoFix the amount — but that is a new transfer, so use a new id
502 broadcast rejectedNo — the chain refused itYes, after reading the message
503 asset unavailableNoNever — permanent
500, a timeout, a dropped connectionUnknownYes — this is exactly what the id is for

That last row used to be the dangerous one. It no longer is, provided you reuse the id: the transfer stays claimed, so a same-id retry either replays the original result or returns 409 while the outcome is still unknown. It will not double-send.

The claim expires after 48 hours. Within that window a same-id retry is always safe; after it, the ledger row is gone and a repeat is treated as a new transfer.

When the outcome stays unknown

A 409 that persists means the service is holding an ambiguous attempt open — the transaction may be on-chain. Don’t fight it:

  1. Write an intent record before you call /send — user, asset, recipient, amount, and the X-Request-Id you generated. If your process dies mid-call, this row is the only evidence the attempt existed.
  2. Retry with the same id, with backoff. A completed original replays its txHash; an unresolved one keeps returning 409.
  3. If it is still 409 after several minutes, poll /transactions and watch /balance for a matching drop — but read the warning below before you conclude anything from an absence.
  4. Escalate to a human, quoting the requestId, rather than re-sending under a new id.

“Not found in /transactions” does not mean “not sent.” On EVM chains history lags a fresh broadcast, so a transfer sent seconds ago is routinely absent. And a transaction row carries only hash, type, amount, fee, status and date, so you cannot positively match a specific transfer to a specific recipient. Re-sending under a new id because you didn’t find it is still how you send twice.

When a read fails

Failures are per-asset: a read failing for one asset says nothing about the others.

  • A 500 means the read failed on our side. Treat it as transient and retry with backoff.
  • A 503 is not transient. That asset is unavailable on your endpoint, permanently — retrying will never help. Contact your operator.
  • /info degrades rather than failing: it can return the address and balance with transactions: []. An empty list there is not proof the user has no history, so don’t treat it as one.