type and code in the body tell you the specifics.
Envelope
type and message. code is set when there’s a more specific failure mode within a type (most payment policy violations carry one). details carries structured context when relevant — typically the failed Zod issue list for validation errors.
Error types
type | HTTP | Meaning |
|---|---|---|
unauthenticated | 401 | Missing, malformed, or invalid API key. |
forbidden | 403 | The request authenticated, but a policy check rejected it. Inspect code. |
not_found | 404 | The resource doesn’t exist in this (account, mode). |
validation_failed | 400 | The request body or query failed validation. details carries the Zod issues. |
conflict | 409 | The request collides with existing state. Not used by v1 endpoints today but reserved for future use. |
rate_limited | 429 | The account is over its per-second ceiling. See Rate limits. |
internal | 500 | A dependency failed (Privy, the chain). Retryable; see specific code for the cause. |
type and code directly.
Codes on POST /v1/payments
The payments endpoint is the only v1 route with structured code values today. Branch on error.type first, error.code second.
code | type | Meaning |
|---|---|---|
agent_not_found | not_found | No agent with that agent_id in this (account, mode). |
wallet_not_found | not_found | The wallet address isn’t owned by this account, or is in the wrong mode. |
permission_not_found | not_found | The agent has no active grant on that wallet. |
amount_invalid | validation_failed | amount_usdc is malformed (non-decimal, too many decimal places, etc.). |
address_invalid | validation_failed | wallet or to is not a valid EVM address. |
amount_too_large | forbidden | Exceeds the grant’s per-transaction max. |
daily_cap_exceeded | forbidden | Would push the rolling 24-hour spend over the grant’s daily cap. |
insufficient_balance | forbidden | The wallet doesn’t have enough USDC to cover the requested amount. Surfaced when Privy’s enclave (or the chain) rejects the call as ERC20: transfer amount exceeds balance. Recovery is to fund the wallet — distinct from the other forbidden codes, which are policy violations the user can’t unilaterally fix. |
recipient_not_allowed | forbidden | to is not in the grant’s recipient allowlist. |
contract_not_allowed | forbidden | The destination contract is not on the grant’s contract allowlist (USDC is the default). |
permission_expired | forbidden | The grant’s expires_at has passed. |
privy_call_failed | internal | Privy’s API failed during signing for a reason Tally couldn’t classify (the catch path before falling back here pattern-matches the user-fixable cases like insufficient_balance). Retryable. |
forbidden-typed codes are policy violations, not auth failures. Surface them in your UI as “this permission doesn’t permit that,” not “your credentials are bad.”
Recovery patterns
| Type | Should you retry? |
|---|---|
unauthenticated | No. Fix the key or rotate. |
forbidden | No, not the same way. Either fix the request shape (different to, smaller amount_usdc) or wait for a new grant. |
not_found | No. Either the id is wrong or the resource doesn’t exist yet. |
validation_failed | No. Fix the input. |
conflict | Maybe. Depends on the specific endpoint; today no v1 route emits this. |
rate_limited | Yes, after Retry-After. Use exponential backoff. |
internal | Yes, with an idempotency key. The cause is downstream. |
error.message for human context before deciding — the message often points at the specific field or constraint that failed.
Sample handlers
Branching on type and code
Logging unknown errors
When you encounter atype your handler doesn’t branch on, log the entire envelope and surface a generic failure to the user. Don’t drop the body — the details field often has exactly the info you need to diagnose later.