# Errors

Every error is JSON with a stable code and one plain sentence:

```json
{ "error": { "code": "rev_conflict", "message": "The draft is at rev 4, not 3. Read it again and reapply your change." } }
```

Some carry more: `problems` or `drafts` (a check that failed: each problem with its fixes), `field` (the query
parameter or header at fault). Codes never change meaning. Messages may get clearer.

| Code | HTTP | Means | What to do |
|---|---|---|---|
| `invalid_request` | 400 | The request is missing something or has a field in the wrong shape. | Fix the field the message names. `field` names it when it is a query parameter or a header. |
| `invalid_json` | 400 | The request body isn't valid JSON. | Send valid JSON. |
| `unauthorized` | 401 | Sign in with a passkey, or send an API token as a Bearer token. | Send `Authorization: Bearer tok_…`, or sign in. |
| `invalid_token` | 401 | That API token doesn't exist or was revoked. | Ask the owner for a new token. |
| `session_expired` | 401 | Your session ran out. Sign in again with your passkey. | Sign in again. |
| `scope_missing` | 403 | This API token doesn't have the scope this route needs. | Ask the owner for a token with that scope. |
| `owner_session_required` | 403 | Only a workspace owner, signed in with a passkey, can do this. API tokens can't. | Ask the owner. Agents request approval instead (`POST …/approval-requests`). |
| `bad_origin` | 403 | Requests signed in with a session cookie must come from the app's own origin. | Call from the app, or use an API token. |
| `approval_required` | 403 | Nothing is posted or scheduled without an owner's passkey approval of that exact snapshot. | Request approval; the owner approves with their passkey. |
| `passkey_failed` | 403 | The passkey check didn't pass. Try again. | Try the passkey again, or add a new one. |
| `not_found` | 404 | There's nothing here, or it belongs to a workspace you can't see. | Check the id and the workspace. |
| `method_not_allowed` | 405 | This route doesn't accept that method. | Use a method the message lists. |
| `rev_conflict` | 409 | The draft changed since you read it. Read it again and reapply your change. | Read the draft again, reapply your change on top, send its new rev. |
| `draft_locked` | 409 | The draft is scheduled, being posted or posted, so it can't change. Cancel its job first. | Cancel the job first, then change the draft. |
| `draft_changed` | 409 | A draft changed after the approval was prepared, so nothing was approved. Prepare the approval again. | Prepare the approval again (`POST …/approvals/options`). |
| `job_exists` | 409 | This draft already has a job waiting, scheduled or posting. | Cancel that job first. |
| `job_state` | 409 | The job isn't in a state that allows this. | Read the job; it may already be done. |
| `challenge_expired` | 409 | This passkey challenge expired or was already used. Ask for a new one. | Ask for a new challenge or link. |
| `conflict` | 409 | That conflicts with something that already exists. | Read the current state and try again. With an `Idempotency-Key`, a request with that key is still running or stopped before it answered: look where the message says (the drafts, the media library, …), then use a new key. |
| `workspace_inactive` | 402 | The workspace's plan isn't active, so this can't be done. The message says why and where to fix it. | Give the owner the billing link in the message (`POST …/billing/portal` or `…/billing/checkout` makes one). Reading and export still work. |
| `signup_closed` | 403 | Sign-up isn't open yet. | Join the waitlist at the site the message names. |
| `account_exists` | 409 | That email already has an account. Sign in instead. | Sign in with the passkey, or recover it (`POST /auth/recover`). |
| `payload_too_large` | 413 | The upload is too large. | Upload a smaller file. |
| `unsupported_media_type` | 415 | That content type isn't accepted here. | Send JSON, or a supported media type for uploads. |
| `check_failed` | 422 | The draft wouldn't post exactly as previewed. See problems for what to fix. | Apply the fixes in `problems` (or `drafts[].problems`) and check again. |
| `idempotency_key_reused` | 422 | That Idempotency-Key was already used with a different request. Send a new key with a new request. | Send a new `Idempotency-Key` (a fresh UUID) with each new request; resend a key only with the exact request it was first sent with. |
| `not_supported` | 422 | That isn't supported yet. | Leave that feature out for now. |
| `x_credits_exhausted` | 503 | Flaghand's prepaid X credits ran out, not the workspace's plan. Publishing jobs are held with this `error_code`, not failed. | Wait for a top-up; the owner retries the held job in the desk. Agents can read its reason but cannot resume it. Already-posted jobs stay posted if only read-back fails. |
| `connection_login_expired` | Job / connection result | The platform login ended or its renewal was refused. A job is held with its reason, approval and already-posted ids intact, not failed. | The owner reconnects the pinned account in Settings, then retries the held job in the desk. Reconnecting alone never replays it, and agents cannot retry it. TikTok attempts that might already have reached TikTok are never resent. |
| `duplicate_post` | Job result | X (403, "duplicate content") or LinkedIn (422, "Content is a duplicate") refused the post because its text repeats one of the account's recent posts. The job failed and isn't retried; a thread's earlier posts stay live. | Change the text (the error names a thread's post) and approve it again; on LinkedIn, a time at least 10 minutes after the earlier post works too. Check flags these repeats before approval with an `edit-text` fix. |
| `upstream_failed` | 502 | The platform (X, LinkedIn or TikTok, or Stripe) didn't answer as expected. | Try again later; nothing was posted because of it. |
| `internal` | 500 | Something went wrong on our side. Nothing was posted because of it. | Try again. If it keeps happening, tell the owner; nothing was posted because of it. |

## Local CLI and stdio MCP errors

These can happen before a request reaches the server, so they have no HTTP status:

- `network`: check `FLAGHAND_URL` (or CLI `--url`) is the app's origin, without `/api/v1`, and that the server is running.
  A read can be retried. A write may already have completed even without its response: read the current state before
  retrying to avoid duplicate drafts or uploads. The same URL environment variable configures stdio MCP. The same code
  covers an answer that broke off after it started arriving, and an answer whose JSON can't be read (usually a URL that
  isn't the app's origin).
- `invalid_request` when a local file cannot be read: use a readable path on the machine running the CLI or MCP server.
  Relative paths start at that process's working directory, not at the document or chat containing the command.
- `invalid_request` naming `data_base64` (the remote MCP server answers it too): send the file's bytes as standard
  base64 (`A-Z`, `a-z`, `0-9`, `+` and `/`, padded with `=`), without a `data:` prefix, or, with the local CLI or MCP
  server, the file's path as `file`.
- `invalid_request` saying a number reads as infinity: a JSON number too large for a double (`1e999`) can't be sent. The
  remote MCP server and the API refuse it the same way.
- `usage` (CLI exit 2): fix the command or flag named in the sentence; `flaghand <resource> <action> --help` lists its
  arguments. API refusals and connection failures exit 1. A completed `drafts check` exits 0 even when `ok` is false:
  read `ok` / the listed problems before requesting approval.

The local desk's original API (`/api/*` without `v1`) keeps its own `{ "error": "…" }` shape until the desk moves onto
`/api/v1`.
