Amy
Concepts

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

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"
  }
}
FieldTypeNotes
error.codestringStable identifier. Listed below. Key off this in code, never on message.
error.messagestringHuman-readable. Subject to copy-edits. Don't parse.
error.request_idstringThe per-request ID, also returned as X-Request-Id. Include when reporting bugs.
error.docs_urlstringDeep 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

CategoryRetry?Strategy
Auth (401, 403)NoFix the token. Retrying with the same bad token will hit rate limits.
Validation (400, 422)NoFix the request. The same body will fail the same way.
Not found (404)NoThe resource doesn't exist. Retry only after creating it.
Conflict (409)Conditionalalready_exists, no retry needed. idempotency_key_mismatch, fix the key or wait 24h for the cached response to expire.
Rate limit (429)Yes, with backoffHonour Retry-After header. Exponential backoff with jitter, max 5 attempts.
Internal (500)Yes, idempotent onlyLinear 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)YesExponential 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

HTTPMeaningCodes
400Client-side validation failure.invalid_request, invalid_field
401Authentication missing or invalid.missing_authorization, invalid_token, webhook_signature_invalid
403Authenticated but not allowed.forbidden
404Resource doesn't exist.turn_not_found, lab_not_found, source_not_found, memory_not_found
409Conflict.already_exists, idempotency_key_mismatch
422Semantic validation failure (body is well-formed but business rules reject it).unprocessable
429Rate limited.rate_limit_exceeded, concurrency_limit_exceeded
500Server bug.internal_error
502 / 503 / 504Upstream 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 start endpoint, 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:

  1. A user API key called an /admin/* endpoint.
  2. 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:

  • messages is empty in POST /v1/turns.
  • The last message in messages isn't from role: "user".
  • A multipart upload to POST /v1/labs has no file part.

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/labs returns 202, 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 the src_… 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 treats 404 on DELETE as 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:

  1. POST /v1/sources/terra/connect for a provider the user is already connected to.
  2. POST /v1/auth/cli/approve for 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):

WindowLimit
Per minute60 requests
Per day5,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: 23

Recovery:

  • Sleep for Retry-After seconds, 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: 3

Recovery:

  • Wait for one of the in-flight turns to reach a terminal state (completed or failed).
  • 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-Key so the retry is safe.
  • If it persists, capture the request_id and 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 upstream is anthropic and the failure persists, check status.anthropic.com.
  • For turn failures (turn.failed event), 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:

reasonMeaning
missing_headerNo terra-signature header.
malformed_headerHeader doesn't parse as t=…,v1=….
bad_timestampt isn't a valid integer.
stale|now - t| > 300s.
signature_mismatchComputed 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

On this page