Payments fail in several distinct ways and each demands a different response. This guide is the playbook: which errors to catch, which to retry, which to surface, and how to avoid double-spend when retrying writes.Documentation Index
Fetch the complete documentation index at: https://docs.tallyforagents.com/llms.txt
Use this file to discover all available pages before exploring further.
The failure surface
Atally.payments.create() call can fail at four different layers:
| Layer | Looks like | Retry? |
|---|---|---|
| Network | fetch throws, no HTTP response | Yes, with idempotency key |
| Tally pre-check | 4xx with structured err.code | Depends on err.code |
| Privy enclave | status: "failed" on the returned payment | No — the policy rejected it |
| Chain | Eventually status: "failed" after broadcast | Sometimes — depends on the revert |
Retry decision tree
Idempotency is the retry primitive
Never retry a write without anidempotency_key. A retry without one can produce a duplicate on-chain transfer if the original succeeded but the response was lost.
idempotency_key returns the original payment record, no on-chain duplicate. See Idempotency for the details.
Handling policy violations
The five 403/forbidden codes (amount_too_large, daily_cap_exceeded, recipient_not_allowed, contract_not_allowed, permission_expired) all come back as AuthenticationError from the SDK — the class name is awkward, but the recovery patterns are real:
Waiting for the chain
payments.create() returns once Privy accepts the signed RPC. The chain might still reject the transaction (gas estimate failure, recipient is a contract that reverts on receive, etc.). To know the final outcome:
Poll
payments.get() lazily refreshes from the chain when called on a pending payment, so polling it is the right shape. Don’t poll faster than every 2 seconds — you’ll burn rate-limit budget.
Webhooks
For production, replace the polling loop withpayment.confirmed and payment.failed webhook subscriptions. See Local webhook development for the wire-up.
Stuck pending payments
A payment that’s been pending for more than a couple of minutes is suspect. Possibilities:- The chain is congested (rare on Base, but possible).
- Privy queued the transaction and hasn’t broadcast yet.
- The dashboard’s lazy-refresh hasn’t fired (the background poller catches these within ~2 minutes).
lazily-refresh-from-chain on every load, so a stuck payment usually resolves the moment you open it. If it doesn’t, check the dashboard’s audit log — there’ll be a Privy outage indicator if the underlying signer is having trouble.
Detecting drift
Two failure modes are worth automated alerting:- A payment that’s pending for more than 5 minutes. The background poller (
/api/internal/poll-pending) handles the long tail, but anything beyond five minutes deserves a look — usually a chain or Privy issue. - A spike in
permission_expirederrors. Indicates a user-facing flow where a permission ran out and nothing prompted re-granting.
Where to go from here
- Build a paying agent — how to fold this into an LLM loop.
- Errors (concept) — full class hierarchy and recovery patterns.
- Idempotency — the deeper dive on retry-safe writes.