Errors
Every error Amy returns follows the same shape, has a stable code, and links here. This page is the catalog: every code, its HTTP status, when it fires, and how to recover.
The error contract is part of the API. Codes don't change once they
ship, new codes are additive. If you key off error.code in your
client (which you should), the API will not break you.
Quick navigation
- The response shape
- Retry guidance
- Status code summary
- Codes by category
- Auth:
missing_authorization·invalid_token·forbidden - Validation:
invalid_request·invalid_field·unprocessable - Not found:
not_found·turn_not_found·lab_not_found·source_not_found·memory_not_found - Conflict:
already_exists·idempotency_key_mismatch - Rate limiting:
rate_limit_exceeded·concurrency_limit_exceeded - Server:
internal_error·upstream_unavailable - Webhook:
webhook_signature_invalid
- Auth:
The response shape
Every error, at every endpoint, with no exceptions, has the same JSON body:
{
"error": {
"code": "turn_not_found",
"message": "No turn exists with id turn_abc123.",
"request_id": "req_01HX2K3M4N5P6Q7R8S9T0V1W2X",
"docs_url": "https://docs.heyamy.xyz/docs/concepts/errors#turn_not_found"
}
}| Field | Type | Notes |
|---|---|---|
error.code | string | Stable identifier. Listed below. Key off this in code, never on message. |
error.message | string | Human-readable. Subject to copy-edits. Don't parse. |
error.request_id | string | The per-request ID, also returned as X-Request-Id. Include when reporting bugs. |
error.docs_url | string | Deep link to the relevant heading on this page (https://docs.heyamy.xyz/docs/concepts/errors#<code>). |
error.details | object? | Optional structured detail; shape varies per code. |
For SSE turn failures, the error arrives wrapped in a turn.failed
event, but in a slimmer shape — { code, message, step? } — without
request_id / docs_url (Streaming: turn.failed).
The TypeScript SDK
Every non-2xx response is surfaced as a single AmyApiError; switch on
its .code:
import { Amy, AmyApiError } from "@amy/sdk";
try {
await amy.turns.retrieve("turn_does_not_exist");
} catch (err) {
if (err instanceof AmyApiError && err.code === "turn_not_found") {
// err.code === "turn_not_found"
// err.status === 404
// err.requestId === "req_…"
// err.docsUrl === "https://docs.heyamy.xyz/docs/concepts/errors#turn_not_found"
}
}AmyApiError also carries transport-failure codes (network_error,
timeout, aborted, malformed_response, stream_closed_unexpectedly)
and an .isRetryable() helper. See SDK: TypeScript, Errors.
Retry guidance
| Category | Retry? | Strategy |
|---|---|---|
Auth (401, 403) | No | Fix the token. Retrying with the same bad token will hit rate limits. |
Validation (400, 422) | No | Fix the request. The same body will fail the same way. |
Not found (404) | No | The resource doesn't exist. Retry only after creating it. |
Conflict (409) | Conditional | already_exists, no retry needed. idempotency_key_mismatch, fix the key or wait 24h for the cached response to expire. |
Rate limit (429) | Yes, with backoff | Honour Retry-After header. Exponential backoff with jitter, max 5 attempts. |
Internal (500) | Yes, idempotent only | Linear backoff (1s, 2s, 4s). Always retry with the same Idempotency-Key to avoid duplicate side effects. Report with request_id if it persists. |
Upstream (502/503/504) | Yes | Exponential backoff with jitter, max 5 attempts. Most transient (Anthropic 5xx, Terra timeout). |
The SDK does this automatically for 429 and 5xx on idempotent calls.
For non-idempotent calls (POSTs without an explicit Idempotency-Key),
the SDK auto-generates a UUID4 key so the retry is safe.
Status code summary
| HTTP | Meaning | Codes |
|---|---|---|
| 400 | Client-side validation failure. | invalid_request, invalid_field |
| 401 | Authentication missing or invalid. | missing_authorization, invalid_token, webhook_signature_invalid |
| 403 | Authenticated but not allowed. | forbidden |
| 404 | Resource doesn't exist (or unknown route). | not_found, turn_not_found, lab_not_found, source_not_found, memory_not_found |
| 409 | Conflict. | already_exists, idempotency_key_mismatch |
| 422 | Semantic validation failure (body is well-formed but business rules reject it). | unprocessable |
| 429 | Rate limited. | rate_limit_exceeded, concurrency_limit_exceeded |
| 500 | Server bug. | internal_error |
| 502 / 503 / 504 | Upstream provider failure. | upstream_unavailable |
Codes by category
Auth
missing_authorization
HTTP: 401 Unauthorized
When it fires: The request didn't include an Authorization
header. Every endpoint except /healthz, /openapi.json,
/llms.txt, /llms-full.txt, /reference, the Terra webhook paths,
and the public CLI sign-in page /cli/login requires one.
Recovery:
- Add
Authorization: Bearer <token>to the request, where<token>is either a Clerk session JWT (web/mobile) or the Amy CLI JWT (saved in~/.amy/credentials.jsonafteramy login). - For the CLI browser-login flow's public endpoints, omit the header (they're anonymous).
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{ "error": { "code": "missing_authorization",
"message": "Missing Authorization: Bearer <token> header.",
"request_id": "req_…",
"docs_url": "https://docs.heyamy.xyz/docs/concepts/errors#missing_authorization" } }invalid_token
HTTP: 401 Unauthorized
When it fires: The Authorization header is present but the token
is unrecognised, expired, or revoked.
Recovery:
- Re-issue an API key. From the CLI:
amy whoami --print-key. From the mobile app: Settings → API keys. - If you just rotated the secret on the user, the previous key was invalidated immediately, fetch the new one.
Note: don't retry. The same bad token will keep failing and may
trigger rate-limit penalties on the 401 path.
forbidden
HTTP: 403 Forbidden
When it fires: Authentication succeeded but the token doesn't have scope for the requested operation. The two cases in v1:
- A user API key called an
/admin/*endpoint. - A token tried to read or mutate another user's resource.
Recovery:
- For admin endpoints, set the
x-admin-key: <AMY_ADMIN_KEY>header instead of a bearer token. - For cross-user access: there's no such thing in v1. Each token is scoped to its owner.
Validation
invalid_request
HTTP: 400 Bad Request
When it fires: The request body is malformed JSON, has the wrong content type, or violates a top-level schema constraint that isn't attributable to a single field.
Examples:
messagesis empty inPOST /v1/turns.- The last message in
messagesisn't fromrole: "user". - A multipart upload to
POST /v1/labs/uploadhas nofilepart.
Recovery: Fix the request body. The message will name the violated constraint.
{ "error": { "code": "invalid_request",
"message": "messages must contain at least one entry; last must be role=user.",
... } }invalid_field
HTTP: 400 Bad Request
When it fires: A specific field failed schema validation. The message names the field and the constraint.
The error body includes a field extension:
{
"error": {
"code": "invalid_field",
"message": "kind must be one of: goal, barrier, preference, insight, hypothesis, decision, value, tested_hypothesis.",
"request_id": "req_…",
"docs_url": "https://docs.heyamy.xyz/docs/concepts/errors#invalid_field",
"details": { "field": "kind" }
}
}Recovery: Fix the named field. The full schema for every endpoint
is in API reference and at /openapi.json.
unprocessable
HTTP: 422 Unprocessable Entity
When it fires: The request body is well-formed and passes schema validation, but a business rule rejects it. Examples:
- Uploading a lab PDF >10MB.
- Connecting a Terra provider the user is already connected to.
- Trying to write a memory entry with
text.length > 500.
Recovery: Read the message. It names the rule that fired. These are intentionally not field-level errors, they reflect cross-field or stateful constraints.
Not found
not_found
HTTP: 404 Not Found
When it fires: The request didn't match any route — a typo'd path,
a wrong method on a real path, or a /v1/... resource that doesn't
exist. This is the generic fallback; resource-specific 404s use the
typed codes below.
Recovery:
- Check the method + path against the API reference
or
/openapi.json. (Unlike the other codes here, this one'sdocs_urlpoints at the API reference rather than this page.)
{ "error": { "code": "not_found",
"message": "No route matches GET /v1/nonexistent.",
"request_id": "req_…",
"docs_url": "https://docs.heyamy.xyz/docs/api-reference" } }turn_not_found
HTTP: 404 Not Found
When it fires: No turn exists with the given ID, or the turn exists but belongs to a different user.
Recovery:
- Double-check the ID. Turn IDs are typed (
turn_…), if yours doesn't start with that prefix, you've passed the wrong value. - Confirm the API key belongs to the user who created the turn. Two users cannot see each other's turns.
- The Turn row is permanent (never expires), if you created it and
it's gone, file a bug with the
request_id.
A related but distinct case: the turn exists but its SSE event buffer
has expired (1 hour past completed_at). GET /v1/turns/:id/events
still returns 200, but there are no buffered events left to replay —
the stream simply opens and closes. Read the terminal state from
GET /v1/turns/:id (the Turn row is permanent). See Streaming:
Reconnects and replay.
lab_not_found
HTTP: 404 Not Found
When it fires: No lab exists with the given ID, or the lab belongs to a different user.
Recovery:
- Verify the ID starts with
lab_. - Verify the API key owns the lab.
- Note that uploads are eventually consistent: immediately after
POST /v1/labs/uploadreturns200, the lab row exists and is queryable — there's no race window where it would return 404 for an ID you just received.
source_not_found
HTTP: 404 Not Found
When it fires: No source connection exists with the given ID, or it belongs to a different user.
Recovery:
- Source IDs are
src_….DELETE /v1/sources/:idrequires thesrc_…id (fromGET /v1/sources); a bare provider slug is not accepted. If the id doesn't match an active connection you own, you get this error.
memory_not_found
HTTP: 404 Not Found
When it fires: No memory entry exists with the given ID. Most
often hit on DELETE /v1/memory/:id after a prior delete (deletes
are immediate and hard, no tombstone).
Recovery:
- Memory IDs are
mem_…. - Don't retry a delete after it succeeds; the second call returns
404. The SDK treats404onDELETEas success by default ({ throwIfNotFound: false }).
Conflict
already_exists
HTTP: 409 Conflict
When it fires: The operation would create a duplicate of an existing resource that's required to be unique. The main case in v1:
POST /v1/sources/terra/connectfor a provider the user is already connected to.
(The CLI sign-in flow does not raise this — POST /v1/auth/cli-approve
mints a fresh 30-day token on every call; there's no single-use device
code to collide with.)
Recovery:
- For connect: delete the existing connection first, then re-connect. Or just leave the existing connection in place.
This is not the same as idempotency_key_mismatch. That fires
when the same Idempotency-Key is reused with a different body;
already_exists fires when business uniqueness is violated regardless
of idempotency keys.
idempotency_key_mismatch
HTTP: 409 Conflict
When it fires: The same Idempotency-Key was sent twice within
24 hours with different request bodies. Amy refuses to silently
overwrite the cached response.
Recovery:
- Use a new
Idempotency-Key. The SDK generates one per call by default, if you're seeing this with the SDK, you've overridden the key explicitly. - If you genuinely want to overwrite, wait 24h for the cached response to expire, then retry with the same key.
- If you want both calls to succeed, use distinct keys (one per intended write).
Diagnostic: the error message includes the byte hash of the cached body and the new body, so you can confirm which fields drifted.
{ "error": { "code": "idempotency_key_mismatch",
"message": "Key 6e8b…1c was used 12m ago with a different body.",
"request_id": "req_…",
"docs_url": "..." } }Rate limiting
rate_limit_exceeded
HTTP: 429 Too Many Requests
When it fires: The user's per-minute or per-day request quota has been exceeded. v1 quotas (subject to change):
| Window | Limit |
|---|---|
| Per minute | 60 requests |
| Per day | 5,000 requests |
Quotas are per user, not per token. Multiple tokens for the same user share one bucket.
Response includes:
HTTP/1.1 429 Too Many Requests
Retry-After: 23Recovery:
- Sleep for
Retry-Afterseconds, then retry. - The SDK does this automatically with up to 5 attempts.
- For batch workloads, add a token-bucket limiter client-side that caps at ~50 req/min to leave headroom.
concurrency_limit_exceeded
HTTP: 429 Too Many Requests
When it fires: The user already has 20 turns in flight (queued or
running). The 21st POST /v1/turns is rejected.
The cap exists because turns are expensive (multi-agent, multi-minute,
LLM-charged). The queue absorbs realistic bursts at
max_concurrency: 1, so this cap is a gentle guard against a stuck
client flooding the queue with hundreds of stale requests — not a
billing rate-limit.
Response includes:
HTTP/1.1 429 Too Many Requests
X-Concurrency-Limit: 20
X-Concurrency-In-Flight: 20Recovery:
- Wait for one of the in-flight turns to reach a terminal state
(
completedorfailed). - Stream their events; the user usually cares about the current turn anyway, not a queued backlog.
- If you legitimately need higher concurrency, contact the team (per-user caps can be raised for known users).
Unlike rate_limit_exceeded, there's no Retry-After, the wait
depends on the turns finishing, not a fixed window.
Server
internal_error
HTTP: 500 Internal Server Error
When it fires: Unhandled exception in the Worker, contract violation between layers, schema-validation failure on a response shape, etc. These are bugs.
Recovery:
- Retry once, with the same
Idempotency-Keyso the retry is safe. - If it persists, capture the
request_idand file a bug. The request ID maps to a single line in the backend logs, we can find exactly what blew up.
{ "error": { "code": "internal_error",
"message": "An unexpected error occurred. Please include the request_id when reporting.",
"request_id": "req_01HX...",
"docs_url": "..." } }Note: the message is intentionally non-revealing. We don't leak stack
traces or internal state in error bodies; the detail lives in our
logs, indexed by request_id.
upstream_unavailable
HTTP: 502 Bad Gateway / 503 Service Unavailable / 504 Gateway Timeout
When it fires: A third-party dependency returned an error or timed out. The dependencies:
- Anthropic (or OpenRouter, depending on backend config), for every LLM call.
- Terra, for OAuth widget URLs, sync operations, lab OCR triggering.
- Clerk, for token verification (rare; Clerk is reliable).
The error body includes upstream:
{ "error": { "code": "upstream_unavailable",
"message": "Anthropic API timed out after 3 retries.",
"upstream": "anthropic",
"request_id": "req_…",
"docs_url": "..." } }Recovery:
- Retry with exponential backoff. Most upstream failures are transient (5-60 seconds).
- The SDK retries automatically on
5xx, up to 5 attempts. - If
upstreamisanthropicand the failure persists, check status.anthropic.com. - For turn failures (
turn.failedevent), the turn is already marked terminal, retrying means starting a new turn, not resuming the old one. There's no resume-from-step-N for failed workflows in v1.
Webhook
webhook_signature_invalid
HTTP: 401 Unauthorized
When it fires: Inbound webhook (only Terra, in v1) failed HMAC verification. This is logged but not surfaced to clients in normal operation, clients don't call webhook endpoints. It appears here for completeness and for use during webhook replay testing.
The response body uses the slim webhook error shape (see Webhooks: Response shapes):
{
"error": "invalid_signature",
"reason": "stale",
"request_id": "req_…"
}reason is one of:
reason | Meaning |
|---|---|
missing_header | No terra-signature header. |
malformed_header | Header doesn't parse as t=…,v1=…. |
bad_timestamp | t isn't a valid integer. |
stale | |now - t| > 300s. |
signature_mismatch | Computed HMAC doesn't equal the v1 field. |
Recovery: See Webhooks: HMAC-SHA256 signing
for the full diagnosis matrix. Common causes: wrong TERRA_WEBHOOK_SECRET,
clock skew, replay outside the 5-minute window, or curl -d reformatting
the body (use --data-binary).
Where to next
- API reference: Errors, the abridged status table and one-line code list.
- SDK: TypeScript, Errors, typed error classes and the SDK error hierarchy.
- Webhooks, full diagnosis for
webhook_signature_invalid. - Streaming:
turn.failed, how errors arrive over SSE during a turn. - Turns: The lifecycle, when a turn ends
up
failedvs returning an error directly.
Webhooks
Terra is Amy's single source of wearable and lab data. This page covers the inbound webhook contract: how Terra delivers events, how Amy verifies them, what happens after ingest, and how to replay th…
Getting started
Five minutes from "what's the API?" to a streaming agent answer about your own data.