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:
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.amy.health/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. |
For SSE turn failures, the same shape arrives wrapped in a
turn.failed event (Streaming: turn.failed).
The TypeScript SDK
The SDK throws typed errors keyed off error.code:
import { Amy, errors } from "@amy/sdk";
try {
await amy.turns.retrieve("turn_does_not_exist");
} catch (err) {
if (err instanceof errors.NotFoundError) {
// err.code === "turn_not_found"
// err.requestId === "req_…"
// err.docsUrl === "https://docs.amy.health/concepts/errors#turn_not_found"
}
}See SDK: TypeScript, Errors for the full error hierarchy.
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. | 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, /webhooks/*, and
/v1/auth/cli/start requires one.
Recovery:
- Add
Authorization: Bearer amy_live_…to the request. - For the CLI device flow's
startendpoint, omit the header (it's anonymous).
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{ "error": { "code": "missing_authorization",
"message": "Authorization header is required.",
"request_id": "req_…",
"docs_url": "https://docs.amy.health/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/labshas 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": "category must be one of: goal, insight, preference, history.",
"field": "category",
"request_id": "req_…",
"docs_url": "..."
}
}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
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). That returns
404 turn_events_expired from GET /v1/turns/:id/events, same
status, different code. 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/labsreturns202, 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_…. - For
DELETE /v1/sources/:id, the ID can be either thesrc_…primary key or the provider slug (whoop,oura, etc.) for convenience, both resolve to the same row. If neither matches, 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 two cases in v1:
POST /v1/sources/terra/connectfor a provider the user is already connected to.POST /v1/auth/cli/approvefor a device code that's already been approved.
Recovery:
- For connect: delete the existing connection first, then re-connect. Or just leave the existing connection in place.
- For CLI approve: the device code is single-use. Start a new flow
with
POST /v1/auth/cli/start.
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 3 turns in flight (queued or
running). The 4th POST /v1/turns is rejected.
The cap exists because turns are expensive (multi-agent, multi-minute, LLM-charged). It prevents a runaway client from racking up $50 of model spend in a single second.
Response includes:
HTTP/1.1 429 Too Many Requests
X-Concurrency-Limit: 3
X-Concurrency-In-Flight: 3Recovery:
- 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
Ten minutes from zero to your first turn against a live Amy backend. Pick whichever path matches you.