Errors
Every non-2xx response has exactly one shape. Codes tell your agent what to do next — most of them are not “try again.”
{
"error": {
"code": "insufficient_credits",
"message": "available credits below the maximum cost of this call",
"request_id": "…", // when a request was created
"details": { } // code-specific context
}
}Codes & the retry matrix
| Code | HTTP | Retry? | What to do |
|---|---|---|---|
| unauthorized | 401 | no | Missing or revoked key. Create one in the console. |
| forbidden | 403 | no | Key lacks the required scope, or the workspace needs review for external actions. |
| confirmation_required | 403 | after approval | A human must approve this action in the console. Get a fresh preview, have them approve, retry with the token. |
| insufficient_credits | 402 | after top-up | Top up in the console, then retry. A data call (search, enrichment, verification, research, pictures, LLM) keeps its Idempotency-Key: once the balance covers the call, the same key runs it as a new attempt, with no cooldown. For setup_sending use a new key; quote tokens survive this — no need to re-quote. |
| rate_limited | 429 | yes, backoff | 300 requests/min per key, plus daily caps. Back off (details.retry_after_seconds when set) and retry: a data call with the same Idempotency-Key runs again once that time has passed, a send-class call needs a new key. |
| idempotency_conflict | 409 | no | Same Idempotency-Key, different parameters. Use a new key for a new request. |
| invalid_request | 400 | no | Schema/validation failure — the message says which field. |
| suppressed_recipient | 422 | no | Recipient is suppressed. This is permanent and by design. |
| not_found | 404 | no | Wrong id, or it belongs to another workspace. |
| operation_unknown_state | 409 | poll, don't retry | External outcome is ambiguous and being reconciled. Poll the request/operation; never blind-retry a purchase, launch or reply. |
| reply_already_sent | 409 | no | This inbound reply already has an answer in flight or sent. One reply per inbound message; wait for their next message. |
| reply_content_unavailable | 409 | no | The provider message this reply would answer is not held, so it cannot be threaded. Read it with get_reply first. |
| reply_send_disabled | 403 | no | In-thread reply sending is switched off for this deployment or workspace. |
| mailbox_unavailable | 409 | no | The mailbox that received this reply is no longer an active sending asset in your workspace. Layrcake never substitutes another sender. |
| agent_suspended | 403 | no | This agent's paid plan is suspended for non-payment (seven days after its paid-through date). Every REST/MCP verb refuses; the console still works. Update the card on Billing to restore it. |
| card_required | 402 | after saving a card | A paid item — a third agent, mailboxes, domains — needs a saved card, or the saved card was declined. An owner adds or updates it on the console's Billing page; a pending order is placed automatically once the payment succeeds. |
| payment_required | 402 | after payment | Services are stopped for unpaid billing. The message names what is owed and links to Billing. Campaigns, launches, replies and paid lookups stop; reads, exports, waiting approvals, mailboxes and domains are kept. Paying lifts the billing stop but does not resume campaigns. This behavior ships off (NON_PAYMENT_STOP). |
| payment_action_required | 402 | after confirming | The bank requires the customer to confirm the payment on-session. Layrcake never retries it unattended; the console's Billing page completes it. |
| billing_unknown | 409 | poll, don't retry | A card payment's result cannot be proven yet and is being reconciled with Stripe. Do not resubmit: the order or key is created automatically once the outcome is known. |
| stream_consumed | 409 | yes, new key | Where hosted agents are enabled: the streamed /v1/llm/chat call that used this Idempotency-Key already finished, and a stream is never replayed. details.credits_charged says what it cost. Retry with a new Idempotency-Key; that is a new, separately charged call. A key whose call is still running answers 202 in_progress instead. A stream the gateway had to cut short ends with a chunk whose finish_reason is error and whose error.metadata.retryable is true. |
| agent_paused | 403 | after resume | Where agents are on: this key belongs to an agent a person paused. Nothing it has queued sends until it is resumed on the Agents page. See Pause and resume. |
| run_budget_exceeded | 403 | no | This call would take the agent run named in X-Layrcake-Run over its budget, model spend included. The run is stopped. See The per-run budget. |
| run_unavailable | 403 | no | The agent run has ended, or it belongs to another agent. Start a new run for the next piece of work. |
| run_required | 403 | after starting a run | This key spends only inside a run. Start one with POST /v1/agent/runs and send its id in X-Layrcake-Run. See Runs outside Layrcake. |
| approval_declined | 409 | ask again | Where Layrcake runs approved launches and replies itself: a person said no, so nothing was sent. details.approval.note may say why. Change the request if needed, then call again without confirmation_token to ask again (details.can_ask_again). |
| approval_expired | 409 | ask again | Nobody decided within the approval's lifetime, so nothing was sent. Call again without confirmation_token to ask again. |
| approval_superseded | 409 | no | A newer version of the same request replaced this approval. Use details.superseded_by, the newer approval's token. |
| approval_cancelled | 409 | ask again | The approval was stopped before anything was sent: sending was paused, the key was revoked, or it couldn't start (details.reason). Call again without confirmation_token to ask again once the cause is fixed. |
| approval_changed | 409 | no | This request would replace an approval a person already approved. To replace it, call again with supersedes set to its token (details.approval.id). |
| provider_unavailable | 503 | yes, later | Every route exhausted. Nothing was charged. A data call retried with the same Idempotency-Key runs again as a new attempt with its own request_id once its cooldown has passed: 30 s, or its own longer details.retry_after_seconds. Before that the key returns the failure again with details.replayed: true and the seconds left. If a provider may have done the work (it timed out), the same key never runs it again: the failure comes back with no retry hint; use a new key to try once more. The failed attempt stays readable at GET /v1/requests/{id}. A send-class call (setup_sending, create_campaign, add_leads, launch_campaign, reply_to_thread, set_lead_status) keeps its failure under that key: retry with a new key. details.reason, when present, says why (vendor_account · invalid_request · rate_limited · temporarily_paused · upstream_error) and details.retry_after_seconds is set when known. Vendor names are never exposed. |
| internal | 500 | yes, later | Our fault. The request_id helps support trace it. |
Workspace monthly spend controls are implemented but off (SPEND_CAP_MODE=off). When enforcement is enabled, workspace_spend_cap refuses new paid lookups, models, mailbox/domain purchases and paid agents, with durable refusal/reset details. Free work, running campaigns, replies and approved launches continue; renewals and OAuth connections are never refused. Credit purchases do not count. Refunds reduce their original charge month in UTC. Near the cap, calls settling between the read and admission lock can exceed it by at most those calls’ quotes; renewals and OAuth are explicit exceptions. get_me exposes mode, settled spend, in-flight commitments and remaining room only with usage:read. A key cap is separate and may refuse free calls when already over. Spend emails remain DRY_RUN.
Reserved for the limits rollout: details.refusal distinguishes workspace_spend_cap, key_spend_cap and daily_limit_full from provider failures. When supplied, it and the ISO timestamp details.resets_at are preserved on replay and in request status under error_details. A monthly reset may be more than a day away. These optional fields do not enable new limits.