Errors
Every non-2xx response from /v1 returns Content-Type: application/problem+json per RFC 9457 Problem Details:
{
"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:
detailalways carries a sentence you can show a person.- The status is
400, except for codes that keep their standard HTTP meaning:not_foundis 404;forbidden,partner_company_limit_reached,app_not_installedandapp_suspendedare 403;conflict,ANALYSIS_IN_PROGRESS(a takeoff is already running for the project),project_currency_required(the project has no currency to price in) andtakeoff_needs_review(yourmeasurementContextnames 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_reachedandusage_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.
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.
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.
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);
detailsays 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/usagefor 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.
- 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.
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.
detailreads "This key works only in its own company. Leave out the company, or name the key's own company." - The
Exayard-Organization-Idheader andorganizationIdin 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.
{
"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, and the company that owns it and pays for its AI work has run out of usage. The owning company can add usage.
{
"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 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.
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.
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.
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 nothttps://, orhttp://onlocalhost,127.0.0.1or[::1], is listed twice, or there are more than 10.scope_not_allowed_for_apps: apps cannot ask foradmin: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 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.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.