# Errors

Every non-2xx response from `/v1` returns `Content-Type: application/problem+json` per [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457):

```json
{
  "type": "https://developers.exayard.com/concepts/errors#insufficient_scope",
  "title": "Forbidden",
  "status": 403,
  "detail": "Token does not grant scope \"write:projects\". Create a new API key with this scope or use a token that has it.",
  "instance": "/v1/projects",
  "code": "insufficient_scope",
  "doc_url": "https://developers.exayard.com/concepts/errors#insufficient_scope",
  "request_id": "req_01H..."
}
```

Branch on **`code`**, not `detail`. `code` is stable across minor versions; `detail` is a sentence for people and may be edited. For every code listed on this page, `type` and `doc_url` point at that code's anchor here, so following the URL from a log line drops you at the right section. Some codes add fields of their own, such as `param`, `install_url` or `limit`; each section below names them.

## Operation-specific codes

Alongside the codes listed on this page, an operation can answer with a code that describes the specific thing you asked for, such as `no_calendar_connected` or `page_no_content`. These name a state of your data rather than a problem with the request. They read as `snake_case` like every other code and they are just as stable, so you can branch on them.

There are too many to list here, and new ones ship with new operations, so two rules cover them:

- `detail` always carries a sentence you can show a person.
- The status is `400`, except for codes that keep their standard HTTP meaning: `not_found` is 404; `forbidden`, `partner_company_limit_reached`, `app_not_installed` and `app_suspended` are 403; `conflict`, `ANALYSIS_IN_PROGRESS` (a takeoff is already running for the project), `project_currency_required` (the project has no currency to price in) and `takeoff_needs_review` (your `measurementContext` names an item the takeoff holds for review) are 409; and the six payment states (`insufficient_credits`, `subscription_required`, `upgrade_required`, `paid_plan_required`, `project_limit_reached` and `usage_limit_reached`) are 402.

A code that is not listed on this page still returns a valid problem response, but its `doc_url` points at this page rather than at a section of it.

## Codes

### unauthenticated (401)

Missing or invalid `Authorization` header. Pass a valid key or OAuth token. A key that was revoked or never existed, and any call from an app that was deleted, also answer 401. A 401 for missing or invalid credentials carries a `WWW-Authenticate` header pointing at the protected-resource metadata for OAuth clients.

### api_key_expired (401)

The `exa_` key is past the expiry it was made with. `detail` reads "This API key has expired. An admin of the company can make a new one on the Developer page." Make a new key and swap it in. See [Apps and keys](https://developers.exayard.com/apps-and-keys.md).

### invalid_end_user (400)

The `Exayard-End-User` header is empty, longer than 128 characters, or has a character that is not printable ASCII. `param` is `Exayard-End-User`. See [End users](https://developers.exayard.com/concepts/end-users.md).

### insufficient_scope (403)

The token authenticated, but doesn't grant the scope the endpoint requires. `detail` names the missing scope. For an API key, create a key with the scope. For a registered app, the company's install doesn't grant it; an admin of the company can approve it on the Developer page. See [Scopes](https://developers.exayard.com/concepts/scopes.md).

### not_found (404)

Resource not found, or not accessible to this caller. We do not distinguish "doesn't exist" from "exists but you can't see it", because the distinction would leak existence. For an app, a resource in a company other than the one the call names answers `not_found`.

### forbidden (403)

The caller authenticated, but this resource or action is off limits to it. Common cases:

- The resource belongs to another company.
- A person removed this personal connection (see [Personal connections](https://developers.exayard.com/authentication.md#personal-connections)); `detail` says so and how to allow it again.
- An app tried something that needs an admin of the company or a key set to work in **This company**, such as managing the company's webhook endpoints.
- An app tried to create, change or remove a widget or a custom property. Only an admin of the company can, in Exayard; no scope lets an app do it.
- An app asked `GET /v1/usage` for another app's usage. An app reads only its own.
- An install limited to some projects called an operation that acts on the whole company. See [Project-limited installs](https://developers.exayard.com/concepts/scopes.md#project-limited-installs).
- A caller that is not a registered app called an app-only endpoint (`GET /v1/installations`, `/v1/organizations`).

### company_required (400)

The call needs a company and named none, or it sent an `Exayard-Organization-Id` header that is not a company id. This happens with a key set to work in every company that installed the app, and with an app's OAuth token: each call acts in one company, so send the header, or `organizationId` in the query string or JSON body. `GET /v1/me`, `GET /v1/installations`, `/v1/organizations` and `/v1/organizations/{id}` are the exceptions. A key set to work in **This company** never gets this code: with no company named, it works in its own. See [Authentication](https://developers.exayard.com/authentication.md).

### company_mismatch (400)

The call names a company it cannot work in. Two cases:

- A key set to work in **This company** named another company. `detail` reads "This key works only in its own company. Leave out the company, or name the key's own company."
- The `Exayard-Organization-Id` header and `organizationId` in the query string or body name two different companies. A call acts in one company; send one, or make them agree.

### app_not_installed (403)

The company the call names has not installed this app, or an admin removed it. The response carries `install_url`, the install link to send an admin of that company. This code also answers when the app's access in this company changed during the call (see `app_install_changed` below) and the retry was refused too.

```json
{
  "type": "https://developers.exayard.com/concepts/errors#app_not_installed",
  "title": "Forbidden",
  "status": 403,
  "detail": "This app is not installed in this company. An admin of the company can install it.",
  "code": "app_not_installed",
  "install_url": "https://app.exayard.com/settings/developer?install=app_k3k3k3k3k3k3k3k3k3k3"
}
```

### app_install_changed (never sent)

An internal signal: the install changed (for example its scopes were narrowed) between the start of your call and the work. Exayard re-reads the install and retries your call once by itself. If a scope your call relied on is gone, you get `insufficient_scope`; if the retry is refused again, you get `app_not_installed`. You never receive this code.

### app_suspended (403)

Exayard suspended this app. Its installs are kept, but it can make no calls until it is reinstated. Contact Exayard support.

### usage_limit_reached (402)

A monthly AI limit stopped this AI work. `limit` says whose limit it was:

- `app`: the monthly AI limit an admin of the company set for this app. The company may still have AI usage left; an admin can raise the limit on the **Usage** page, under **Spending by app**.
- `partner`: the company is an [account for a customer](https://developers.exayard.com/concepts/customer-accounts.md), and the company that owns it and pays for its AI work has run out of usage. The owning company can add usage.

```json
{
  "type": "https://developers.exayard.com/concepts/errors#usage_limit_reached",
  "title": "Payment Required",
  "status": 402,
  "detail": "This app has reached its monthly AI limit in this company. An admin can raise it on the Usage page, under Spending by app.",
  "code": "usage_limit_reached",
  "limit": "app"
}
```

On the MCP server the tool result's text ends with `[usage_limit_reached]` and `structuredContent` carries `code` and `params.limit`.

### partner_company_limit_reached (403)

`POST /v1/organizations` was refused because your company already owns as many open [customer accounts](https://developers.exayard.com/concepts/customer-accounts.md) as it may (1,000 unless Exayard set another limit). `detail` names the number. Release a company with `DELETE /v1/organizations/{id}`, or ask Exayard to raise the limit.

### insufficient_credits (402)

The organization does not have enough AI usage left for this operation. Add usage, then retry. When the check knows the numbers, the response also carries `credits_required` and `credits_available`, and `detail` names both.

### subscription_required (402)

The organization's plan does not include this AI work, so adding usage is not the way on, whatever its balance. Choose a plan that includes it, then retry. Every Exayard plan, Free included, comes with monthly AI work, so running out of it answers `insufficient_credits` instead. When the refusal comes from the usage check, the response also carries `plan_name` (the first plan with AI work) and `plans_url` (the page that lists the plans), and `detail` names both. On the MCP server, these refusals end their text with the code in brackets, such as `[subscription_required]`, and carry `code` and these fields as `params` in `structuredContent`.

### upgrade_required (402)

The organization is subscribed, but its current plan does not cover this operation. Upgrade the plan, then retry.

### paid_plan_required (402)

The organization's plan does not include this operation, and every paid plan does. On the Free plan, these are making or sending a bid and changing the price book (products, their supplier prices and labor rates). Choose a paid plan, then retry. Bids the team already made can still be read and downloaded on any plan.

### project_limit_reached (402)

The organization already has as many active projects as its plan holds. Archive or delete a project, or choose a plan, then retry. Archived and deleted projects do not count.

### conflict (409)

The request contradicts the current state of the resource, so applying it would overwrite something. Re-read the resource and decide what should win.

### ANALYSIS_IN_PROGRESS (409)

The project already has a takeoff running, and a project runs one at a time. `POST /v1/projects/{id}/assessments` and `POST /v1/projects/{id}/analysis/run` answer this when you start a second one. `detail` reads "A takeoff is already running for this project. Wait for it to finish, or cancel it, then start a new one." Poll `GET /v1/assessments/{id}` or wait for the `assessment.completed` webhook, or stop the running takeoff with `POST /v1/assessments/{id}/cancel`, then start again. The code is spelled in capitals, unlike the others, and stays that way. See [Run a takeoff](https://developers.exayard.com/guides/run-a-takeoff.md).

### project_currency_required (409)

The project names no currency, so a bid or an estimate cannot be totalled and a price cannot be kept in its money. `POST /v1/projects/{id}/bids`, `POST /v1/projects/{id}/estimates`, `POST /v1/symbols/{id}/line-items` and `PATCH /v1/symbols/{symbolId}/line-items/{id}` answer this (for a line, when it carries a price, or on a new line a currency); nothing is written and nothing is charged. A project you cannot read is a `not_found` instead, so this code never tells you about a project you may not see. Set the project's currency (`settings.currency`, such as `USD`) with `PATCH /v1/projects/{id}` or in the project's settings, then retry.

### takeoff_needs_review (409)

The `measurementContext` you sent to `POST /v1/projects/{id}/bids`, `POST /v1/projects/{id}/estimates` or `POST /v1/projects/{id}/estimates/generate` names items the project's takeoff holds for review, and a held item is never priced. The error lists them in `needsReview`, each with its `name` and, where there is one, a `reason`. Review them in Exayard first, leave them out of your text, or call again without `measurementContext`: the bid then prices the rest and lists held items as Needs review.

### invalid_request (400)

Request body failed validation. The offending field (if any) is surfaced as `param`. Most commonly: missing required fields, wrong types, or values outside an enum.

### invalid_idempotency_key (400)

The `Idempotency-Key` header is malformed. Must be 1 to 255 URL-safe characters (letters, digits, underscore, hyphen). See [Idempotency](https://developers.exayard.com/concepts/idempotency.md).

### idempotency_key_reused (409)

The same `Idempotency-Key` was submitted with a different request body. Pick a new key. This guards against the client mixing up keys and accidentally applying the wrong state.

### rate_limited (429)

You've hit the rate limit. The response includes `Retry-After` (seconds) and the `RateLimit` / `RateLimit-Policy` headers. When an app hits one of its own limits, `rate_limit_scope` says which: `app_key` for one key's limit in its own company, `app_company` for the app's limit in that company, or `app` for its ceiling across all companies. See [Rate limits](https://developers.exayard.com/concepts/rate-limits.md).

### internal_error (500)

Unexpected server error. Include `request_id` in any bug report. It's indexed on our side and gets support to the right request immediately.

### transport_error

Emitted by the SDK when the response wasn't JSON (for example a CDN error page during an outage). The `status` field reflects what we actually received.

## On the Developer page

Making apps, keys and sandboxes happens on the Developer page, not over `/v1`, so these refusals show there as a sentence rather than as a problem response:

- `app_limit_reached`: a company can have at most 25 apps.
- `redirect_uri_not_registered`: a sign-in address is not `https://`, or `http://` on `localhost`, `127.0.0.1` or `[::1]`, is listed twice, or there are more than 10.
- `scope_not_allowed_for_apps`: apps cannot ask for `admin:org`, and an install cannot grant a scope the app does not ask for.
- `app_wrong_product`: the app belongs to another product than the company.
- `admin_required`: only an admin of the company can make, install or remove apps and manage their keys. A [customer account](https://developers.exayard.com/concepts/customer-accounts.md) accepts only its owning company's own apps.
- `install_limit_reached`: an unreviewed app is installed in 25 companies outside its own; it needs a review to go further.
- `app_key_limit_reached`: an app already has 25 active keys; revoke one first.
- `sandbox_limit_reached`: a company can have at most 5 open sandboxes; delete one first. See [Environments and sandboxes](https://developers.exayard.com/environments.md).
- `sandbox_billed_to_parent`: a sandbox has no plan or purchases of its own. It follows the plan of the company it tests, and that company pays. The sentence names that company.
