Skip to main content
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.

The failure surface

A tally.payments.create() call can fail at four different layers: The first three you see immediately. The fourth requires polling or webhooks (covered below).

Retry decision tree

Idempotency is the retry primitive

Never retry a write without an idempotency_key. A retry without one can produce a duplicate on-chain transfer if the original succeeded but the response was lost.
A retry with the same 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:
For an LLM-driven agent, the right move is usually to surface the error as content in the conversation rather than throwing — the LLM can adapt. For a deterministic worker, throw and let your job system handle it.

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 with payment.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).
Diagnosis: open the Transactions tab in the dashboard. The page calls 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_expired errors. Indicates a user-facing flow where a permission ran out and nothing prompted re-granting.
Both are detectable from the audit log + webhook event stream. The dashboard’s Transactions tab already shows the first; the second is a custom query against your event log.

Where to go from here