When the API says no, it sends a problem details body ([RFC 7807](https://www.rfc-editor.org/rfc/rfc7807)). The content type is `application/problem+json`. The only routes that answer in a different shape are the two `syndai login` routes. See [Device login errors](#device-login-errors).

## The error body

```json
{
  "type": "about:blank",
  "title": "Admission denied",
  "status": 429,
  "code": "RUN_CONCURRENCY_LIMIT",
  "detail": "Customer concurrent run limit reached",
  "instance": "https://api.syndai.ai/api/v1/external/coding/runs",
  "reason": "concurrency_limit",
  "active_runs": 3,
  "max_concurrent_runs": 3
}
```

| field | what it holds |
|---|---|
| `type` | always `about:blank` |
| `title` | a short name for the error |
| `status` | the HTTP status |
| `code` | the error code. Your client should branch on this. |
| `detail` | one line for a person to read. The words can change, so never parse them. |
| `instance` | the URL you called |
| `existing_run_id` | on `IDEMPOTENCY_REPLAY` for a run: the run you already made |

Some codes add fields of their own beside these:

| field | sent with |
|---|---|
| `reason` | a refused run (`BUDGET_EXHAUSTED`, `QUOTA_EXCEEDED`, `RUN_CONCURRENCY_LIMIT`, `CUSTOMER_DISABLED`, and `RATE_LIMITED` from the daily spend limit), or `OVERSCOPE_ACK_REQUIRED` |
| `active_runs`, `max_concurrent_runs` | `RUN_CONCURRENCY_LIMIT` |
| `required_scope` | `INVALID_SCOPE`: the scope your key lacks |
| `review_url` | `HUMAN_APPROVAL_REQUIRED`: the page where the owner decides |
| `refusal`, `run_id`, `prompt` | `RUN_IN_FLIGHT` and `TEXT_EDIT_REFUSED` |

## Error codes

| code | status | what it means | what to do |
|---|---|---|---|
| `BUDGET_EXHAUSTED` | 402 | Syndai refused to start the work for a reason with no code of its own, such as a plan that is not active or a budget that is not valid. `reason` says which. | Read `reason` and `detail`. Fix the plan or budget, then try again. |
| `QUOTA_EXCEEDED` | 429 | The workspace is out of credits, or it hit its monthly spend limit. | Add credits or wait for the next month. Don't retry in a loop. |
| `RUN_CONCURRENCY_LIMIT` | 429 | The workspace already has as many runs going as its plan allows. | Wait for a run to end, or cancel one, then try again. |
| `CUSTOMER_DISABLED` | 403 | The workspace is turned off. | Contact Syndai support. Don't retry. |
| `REPO_NOT_BOUND` | 422 | `PUT /policy/defaults` named a `coding_repository_id` that is not in this workspace. | Check the id with `GET /repos`. |
| `IDEMPOTENCY_REPLAY` | 409 | Your request id matched one you sent before. `Location` points at what you already made. A run also sends `existing_run_id`. | This is a safe repeat. Read what `Location` points at. Use a new request id for new work. |
| `CONFLICT` | 409 | The request clashes with the current state of the run or thing you changed. | Read it again, then decide if the change still makes sense. |
| `MISSION_BUSY_RETRY` | 409 | Another write held the run for a moment. Nothing changed. | Wait a moment and send the same request again. |
| `HITL_REQUEST_CONFLICT` | 409 | The run can't take your answer yet. Nothing changed. | Wait a moment and send the same answer again. |
| `HITL_REQUEST_SETTLED` | 409 | The question was already answered, timed out, or is closed. | Don't retry. Read the run again. |
| `RUN_TERMINATED_BEFORE_RESPONSE` | 409 | The run ended before your answer got there. | Don't retry. Read the run again. |
| `INVALID_POLICY` | 400 | The request body was not valid. This is most often a bad `policy_config`. `detail` names the field. | Fix the body. See [Policy](/docs/api/policy). |
| `INVALID_SCOPE` | 403 | Your key lacks the scope this route needs. `required_scope` names it. | Make a key with that scope. |
| `RATE_LIMITED` | 429 | Too many calls in a short time, or the workspace hit its daily spend limit. It may send `Retry-After`. | Wait the `Retry-After` seconds when it is there. Back off, and don't retry at once. |
| `INSTALLATION_INCOMPLETE` | 422 | Part of the code list. No route sends it today. | None. |
| `SUBSCRIPTION_USAGE_UNAVAILABLE` | 400 | Syndai could not check your subscription's usage before the run. | Try again in a little while. |
| `INVALID_TOKEN` | 400/401 | 401: the API key is missing, wrong or revoked. 400: the invite token is not valid or was used. | Use a working key, or ask for a new invite. |
| `ROLE_REQUIRED` | 403 | You are signed in, but your role can't do this. Only owners and admins can. | Ask an owner or admin. Signing in again won't help. |
| `PLAN_UPGRADE_REQUIRED` | 403 | The workspace plan doesn't include this feature. | Upgrade the plan. A new key won't help. |
| `EMAIL_MISMATCH` | 403 | You are signed in with an email that is not the one on the invite. | Sign in with the invited email, or ask for a new invite. |
| `EXPIRED_TOKEN` | 410 | The invite has expired, or a `syndai login` code expired or was used. | Ask for a new invite, or run `syndai login` again. |
| `NOT_FOUND` | 404 | The thing you asked for does not exist, or this workspace can't see it. | Stop using that id. List again to get fresh ids. |
| `OVERSCOPE_ACK_REQUIRED` | 409 | `PUT /secrets/{key}` or `POST /secrets/import`: the token can do more than its recipe needs. `reason` says how. Nothing was saved. | Use a narrower token, or send it again with `acknowledge_risk: true`. |
| `VERIFY_FAILED` | 422 | The provider said no to a token Syndai checked before saving it. On publish, the app failed content review or the site is suspended. | Fix or replace the token. For publish, read `detail`. |
| `PUBLISH_IN_FLIGHT` | 409 | `POST .../publish`: this app is already going live. | Poll `GET .../publication` until it is done, then publish again if you need to. |
| `PUBLISH_UNSUPPORTED_REPO` | 422 | `POST .../publish`: only projects Syndai hosts can be published. | Don't retry. A project on GitHub ships through its own pipeline. |
| `PUBLICATION_NOT_LIVE` | 409 | The app isn't live, so this setting or its data can't change now. | Publish the app or wait for it to be live, then try again. |
| `NO_APP_DATA` | 404 | `GET .../publication/data.json`: the app has no data store. A static site has none. | Don't retry. Only apps with sign-in have data. |
| `FEATURE_REQUESTS_NEEDS_MEMBERS` | 400 | `PATCH .../publication` set `feature_requests` to `members`, but the app has no member sign-in. | Choose `public` or `off`. |
| `EMPTY_UPDATE` | 400 | A `PATCH` sent no field to change. | Send at least one field the route takes. `detail` lists them. |
| `BAD_CURSOR` | 400 | The `cursor` was not made by this API. | Start again with no `cursor`, then page with `next_cursor`. |
| `HUMAN_APPROVAL_REQUIRED` | 403 | A key asked for something only a person signed in at app.syndai.ai may do. Merge anyway and Go live on a held change are two of these. So are loosening protected files and editing text in a protected file. Nothing changed. | Don't retry with the key. Send the owner to `review_url`. |
| `PROTECTED_PATHS_MANAGED_ONLY` | 422 | `PATCH /repos/{id}` set `protected_paths` on a GitHub project. Protected files work only on projects Syndai hosts. | Don't retry. Use GitHub's own branch rules. |
| `RUN_IN_FLIGHT` | 409 | `POST /repos/{id}/text-edits` or its undo: a run, or a change going live, holds this site. `refusal` says which one (`RUN_IN_FLIGHT`, `CHANGE_LANDING` or `CHANGE_PENDING`), and `run_id` names it. Nothing changed. | Follow `run_id` with `GET /runs/{id}/change` until it is done, then edit again. |
| `TEXT_EDIT_REFUSED` | 400/409/422/503 | A text edit or its undo could not swap the text. `refusal` holds the reason. `NOT_LITERAL` means code builds the text, and `prompt` is a ready change request. Others are `NOT_EDITABLE`, `SITE_CHANGED`, `UNDO_STALE`, `BUSY`, `MODERATION_HELD` (422), `INVALID_TEXT` (400), and a site or review that can't be reached (503). Nothing changed. | `NOT_LITERAL`: start a change with `prompt`. `BUSY`, `SITE_CHANGED` or 503: try again. Anything else: make the change with a run. |
| `CLI_UPGRADE_REQUIRED` | 426 | The `syndai` CLI that sent this is too old. | Run `npx -y syndai@latest`. |
| `PRODUCTION_STEP_UP_REQUIRED` | 403 | `POST /secrets/resolve` asked for production values. No person has allowed this key in the browser in the last 10 minutes. No value was read. | Start `POST /cli/production-access`. An owner or admin opens its `verification_uri_complete` and allows it. Poll `POST /cli/production-access/token`, then try again. The CLI does this for you in a terminal. |
| `PRICES_UPDATING` | 503 | A plan or credit-pack checkout was asked for while our prices and the payment provider's disagree, for example during a price update. No checkout was created and nothing was charged. | Try again in a minute. |
| `GITHUB_UNAVAILABLE` | 503 | `GET /repositories/{id}/github-deploys` could not read the repository's deployments and releases from GitHub. | Try again in a minute. |
| `PUBLISH_NOT_SET_UP` | 409 | `POST /repositories/{id}/publish` on a GitHub repository found no `.github/workflows/publish.yml`, or the workflow refused the dispatch. | Set up publishing first, then publish again. |
| `VERSION_NOT_DEPLOYED` | 400 | `POST /repositories/{id}/publication/redeploy` named a sha that never went live on this site. | Pick a version from the app's deployments that went live. |
| `INTERNAL_ERROR` | 5xx | Syndai could not finish the request. This is on our side. | Retry with backoff. Contact support if it keeps happening. |

## Device login errors

`syndai login` uses the device login of [RFC 8628](https://www.rfc-editor.org/rfc/rfc8628). Its two routes answer in that shape, not as problem details. Until the login ends, `POST /api/v1/cli/device/token` returns HTTP 400 with `{"error": ...}`:

| `error` | what it means | what to do |
|---|---|---|
| `authorization_pending` | no one has said yes or no yet | poll again after `interval` seconds |
| `slow_down` | you polled faster than `interval` | wait 5 seconds longer, then poll again |
| `access_denied` | an owner or admin said no | stop. Start a new login if that was a mistake. |
| `expired_token` | no one said yes within 10 minutes, or the key was already handed out | stop and start a new login |

## Retries

- Retry `429` and `5xx` with backoff. Start near one second and double each time. When `Retry-After` is there, wait that long first.
- `MISSION_BUSY_RETRY` and `HITL_REQUEST_CONFLICT` are safe to send again after a short wait.
- `POST /runs` is safe to retry with the same `external_request_id`. A repeat never makes a second run. It answers `IDEMPOTENCY_REPLAY` and points at the first one.
- Any other `4xx` fails the same way each time. Change the request before you send it again.

The [CLI](/docs/cli#exit-codes) turns these codes into exit codes.
