# Errors

## What this page covers

Two groups of operations answer errors with the envelope described here:

- the automated recipe operations under `/api/v3.1/automated-recipe*`;
- [`GET /api/v3.1/connections/{connection_id}/accounts/`](/api/accounts#list-connection-accounts-v3-1).

Every other operation of this API answers with `{"detail": "..."}`, as described in the
[Introduction](/introduction). The deprecated v3 recipe operations are in that older group too:
see [Moving recipes to v3.1](/recipes-migration).

This page also lists the **build error codes**, which are not HTTP errors at all: they arrive
inside a successful `200` response, in the `error` field of a recipe.

## The error envelope

Every error response of the operations above has the same three keys:

```json
{
  "code": "recipe_not_configured",
  "message": "The recipe has no data sources or connections. Configure it with PUT first.",
  "details": {}
}
```

- **`code`** is machine-readable and stable. Branch on it, never on the message.
- **`message`** is human-readable text meant for a log or a support ticket. Its wording can change
  at any time and is not part of the contract.
- **`details`** is always present and always an object. Its keys depend on the code; codes that
  carry no extra data return `{}`. Read it only after you have matched the code.

The HTTP status matches the code, so a client that does not know a code can still fall back to the
status. See [New values without a new version](#new-values-without-a-new-version).

## Validation errors

`validation_error` is the only code with a list inside `details`. It reports every field of the
request body that failed the schema, in `details.fields`:

```json
{
  "code": "validation_error",
  "message": "The request is invalid.",
  "details": {
    "fields": [
      {
        "field": "connections[0].accounts",
        "code": "empty",
        "message": "This list may not be empty."
      },
      {
        "field": "title",
        "code": "max_length",
        "message": "Ensure this field has no more than 255 characters."
      }
    ]
  }
}
```

Each entry has three keys:

| Key | Meaning |
|---|---|
| `field` | Where in the body the problem is. |
| `code` | Why it failed, for example `required`, `empty`, `max_length`, `invalid`. |
| `message` | Human-readable text for that one field. |

The `field` path is written the way you would index into the JSON body you sent: a dot for an
object key and square brackets for a list position. `connections[0].accounts` is the `accounts`
list of the first connection, and `connections[1].accounts[2].id` is the `id` of the third account
of the second connection. A problem with the body as a whole, one that belongs to no single field,
is reported with an empty string: `"field": ""`.

The field codes are the codes of the validation layer, not the codes of the catalogue below. They
are a separate, open set; treat an unfamiliar one as "this field is invalid".

## HTTP error codes

### How the status is chosen

| Status | Meaning |
|---|---|
| `400` | The request is wrong by itself: it breaks the schema, or the rules of the configuration. Fix the request. |
| `401` | Authentication failed. |
| `403` | Authentication succeeded, but this operation is not available to you. |
| `404` | The resource is not there for you. |
| `405` | The method is not supported on this path. |
| `409` | The request is well formed, but contradicts the current state of the resource. Read the resource, then decide. |
| `500` | Our fault. The request was not served. |
| `501` | The destination of your workspace cannot answer the question. Only `destination_check_unsupported`. |
| `503` | Infrastructure is temporarily unavailable. Retry - see [Temporary failures and retries](#temporary-failures-and-retries). |

`bad_request` is the exception to the table: it is the catch-all for a `4xx` that has no code of
its own, so it usually arrives with `400` but can arrive with another `4xx` status, for example
`415` for an unsupported content type. Read the status of the response, not the code, to know what
happened.

### Codes any operation can return

| `code` | Status | When | `details` |
|---|---|---|---|
| `validation_error` | `400` | The request body does not match the schema. | `{fields: [{field, code, message}]}` |
| `malformed_request` | `400` | The request body is not valid JSON. | `{}` |
| `invalid_query_parameter` | `400` | A query parameter has a value that is not allowed. | `{parameter, allowed_values}` - `allowed_values` is filled in only where the values can be enumerated, for example `state`; otherwise it is `[]`. |
| `bad_request` | `4xx` | Any other `4xx` refusal that has no code of its own. | `{}` |
| `authentication_failed` | `401` | The token is missing, invalid or expired, or the session has expired. | `{}` |
| `permission_denied` | `403` | The operation is not available to this user or agency. | `{}` |
| `not_found` | `404` | No such recipe, template or connection in this workspace - it may belong to another workspace, be deleted, or not be managed through this API. Also an unknown path under the recipes prefix. The message names what was not found, for example `Recipe not found.` | `{}` |
| `method_not_allowed` | `405` | The HTTP method is not supported on this path. | `{}` |
| `internal_error` | `500` | Something failed on our side. The request was not served. | `{}` |
| `service_unavailable` | `503` | A service this operation depends on did not answer in time. | `{}` |

### Codes of specific operations

| `code` | Status | Operations | When | `details` |
|---|---|---|---|---|
| `template_not_found` | `400` | create | There is no such template for your agency, or it cannot be deployed through the API. | `{template_id}` |
| `template_not_deployable` | `409` | create | The template exists but carries no configuration that can be deployed. | `{template_id}` |
| `recipe_already_exists` | `409` | create | This workspace already has a recipe from this template; one template gives one recipe per workspace. | `{existing_recipe_ids}` - the ids worth reusing, best first. **It can be empty**, when two creates raced; then re-read `GET /api/v3.1/automated-recipes/?template_id=...`. |
| `view_name_taken` | `409` | create, build, resetup | Another recipe already holds the output table name this template produces. | `{view_name, conflicting_recipe_ids}` |
| `no_active_destination` | `409` | create, delete, extractions | The workspace has no active destination to build into. | `{}` |
| `destination_not_supported` | `409` | create, delete, extractions | The destination of this workspace is not one recipes can be built into. | `{}` |
| `data_table_reservation_failed` | `503` | create | The output table could not be reserved right now. Nothing was created. | `{}` |
| `data_source_not_allowed` | `400` | configure | A data source in the body is not allowed by the template of this recipe. | `{data_sources}` - the ones that are not allowed |
| `connection_data_source_not_selected` | `400` | configure | A connection's `connection_data_source` is not in the `data_sources` list of the same body. | `{connections: [{index, connection_id, connection_data_source}]}` |
| `duplicate_connection` | `400` | configure | The same `(connection_id, connection_data_source)` pair is listed twice. | `{connections: [{index, connection_id, connection_data_source}]}` |
| `data_source_without_connection` | `400` | configure | A newly added data source has no connection in the body. | `{data_sources}` - the ones added without a connection |
| `account_unavailable` | `400` | configure | An account id is unknown, is not active, or belongs to another connection or workspace. Take ids from the v3.1 accounts endpoint, and only those with `is_active: true`. | `{connections: [{connection_id, connection_data_source, accounts: [{id}]}]}` |
| `data_source_removal_not_allowed` | `409` | configure | A data source that the recipe already has is missing from the body. Configuration is append-only. | `{data_sources}` - the ones that went missing |
| `connection_removal_not_allowed` | `409` | configure | A connection the recipe already has is missing from the body. | `{connections: [{connection_id, connection_data_source}]}` |
| `account_removal_not_allowed` | `409` | configure | An account the recipe already has is missing from the body. | `{connections: [{connection_id, connection_data_source, accounts: [{id}]}]}` |
| `account_check_unavailable` | `503` | configure | The accounts in the body could not be checked right now. **Nothing was saved.** | `{}` |
| `recipe_not_configured` | `409` | build, resetup | The recipe has no data sources or connections yet: configure it first. | `{}` |
| `build_already_running` | `409` | build, resetup | A build of this recipe is already running. | `{}` on build. On resetup it can carry `{recipe_updated}`: `true` means the steps were already recreated from the template and only the build did not start, so repeat the call once the running build finishes; `false`, or no key at all, means nothing was changed. |
| `recipe_template_unavailable` | `409` | resetup | The template of this recipe can no longer be deployed. | `{}` |
| `delete_incomplete` | `500` | delete | The recipe itself could not be deleted. Read it again to see whether it is still there. | `{recipe_id}` |
| `destination_unavailable` | `503` | extractions | The destination of the workspace could not be queried right now. | `{}` |
| `destination_check_unsupported` | `501` | extractions | The destination of this workspace cannot report whether a table exists. Retrying will not help. | `{}` |

Only one rule fails at a time: a body that breaks several of the configuration rules above is
answered with the first problem found, so fixing one can reveal the next.

The catalogue has no code of its own for throttling; rate limits still apply to these paths as
they do to the rest of the API, see [Rate limits](/rate-limits).

## Temporary failures and retries

A `503` means "not now", not "no". The response carries the header `Retry-After: 30`, so wait
30 seconds before repeating the request. The header is part of the response even though it is not
shown in the operation reference on this site.

On a read - listing templates, reading a recipe, listing accounts - simply repeat the request.

On a writing operation - create, configure, delete, build, resetup - the outcome is unknown: the
request may have gone through before the connection broke. **Re-read the resource first, then
decide whether to repeat it.** The same applies after a timeout on your side. Repeating is safe
once you have looked:

- create answers `409 recipe_already_exists` if the first attempt did land, and the ids in
  `details.existing_recipe_ids` tell you which recipe to keep;
- [configure](/api/automated-recipes#configure-a-recipe) sends the full configuration every time,
  so sending it twice changes nothing;
- [build](/api/automated-recipes#start-a-recipe-build) and
  [resetup](/api/automated-recipes#recreate-a-recipe-from-its-template) answer
  `409 build_already_running` if a build is already going;
- a repeated delete answers `404`.

`account_check_unavailable` and `data_table_reservation_failed` are the two `503` codes that
promise nothing was saved, so they can be repeated as they are. `destination_unavailable` is a
`503` about your destination rather than about us; it too changes nothing.

`501 destination_check_unsupported` is the opposite case: it is permanent for this workspace, and
repeating the call will keep returning it.

## Build errors

A build failure is not an HTTP error. The call that starts a build answers `202` and the build runs
in the background; when it fails, the failure arrives inside a normal `200` response, in the
`error` field of the recipe and in `error` of an entry of `sources`:

```json
{
  "code": "sql_object_not_found",
  "message": "Column 'audience' was not found in the source tables.",
  "stage": "final_build",
  "data_source": null,
  "occurred_at": "2026-09-01T12:22:33Z",
  "details": {"missing_column": "audience"}
}
```

A recipe carries `error` if and only if its `state` is `failed`; an entry of `sources` carries one
if and only if its `status` is `failed`.

**Build error codes are a namespace of their own**, separate from the HTTP codes above. Two names
appear in both lists on purpose - `view_name_taken` and `build_already_running` - because the same
situation can be caught either when a request comes in or later, during the build. Match a build
code against this catalogue only, and an HTTP code against the catalogue above only.

| `code` | When | `details` |
|---|---|---|
| `extractions_not_created` | The extractions this recipe needs could not be created. The detailed reason stays on our side. | `{}` |
| `no_data_delivered` | Nothing had arrived to build from: no extraction of the recipe, of that data source, or of that template delivered data. | `{}` |
| `all_sources_failed` | Progressive build: every data source failed, so nothing was left to build. | `{}` |
| `nothing_to_build` | The recipe has no steps to build - it has no configuration. | `{}` |
| `view_name_taken` | Another recipe took the output table name while this build was running. | `{}` |
| `data_table_registration_failed` | The output table could not be registered once it had been built. | `{}` |
| `build_already_running` | Another build of this recipe held the lock. | `{}` |
| `sql_object_not_found` | A table or a column the recipe reads was not found. | `{missing_column}` - the column name, or `null` when the missing object was not a column |
| `sql_type_mismatch` | A column type does not match the data in the source. | `{column, source_type, target_type, mixed_types}` - all four keys are always present, any of the first three can be `null`, and `mixed_types` is always a list (empty when there is nothing to list) |
| `sql_permission_denied` | The query did not have the privileges it needed. | `{}` |
| `sql_resource_limit` | The query exceeded a time or memory limit. | `{}` |
| `sql_syntax_error` | The query has a syntax error. | `{}` |
| `sql_error` | Some other database failure. | `{}` |
| `build_failed` | Everything else, including a failure recorded without a code. | `{}` |

`details` here follows the same rule as in the HTTP envelope: always an object, `{}` when the code
carries nothing extra.

The other fields of a build error:

- **`stage`** - `extraction`, `transformation_build`, `final_build`, or `null` when the failure was
  recorded without a phase.
- **`data_source`** - the data source the failure belongs to, or `null` when it belongs to no
  single one. Inside `sources` it always matches that entry's `data_source`.
- **`occurred_at`** - when the failure was recorded, or `null`.
- **`message`** - public text, never raw internal output. For our own codes it is a fixed sentence
  chosen by the code, so it adds nothing the code does not already tell you. For the `sql_*` codes
  it is the message of the database with internal names and paths stripped out, which is the one
  case where the message is worth reading. Its wording can still change, so do not parse it.

A failure that was recorded before v3.1 existed has no code stored with it. Such a failure is
classified from the database text that was kept: if it is recognised, it arrives under an `sql_*`
code with the cleaned-up text; if it is not recognised, or nothing was kept, it arrives as
`build_failed` with the generic message.

## Data that did not arrive

A build can succeed while part of the data is missing. That is not an error and has no code: the
recipe is `ready_to_work` with `degraded: true`, and the reasons are reported per data source in
`not_delivered_reasons` and `not_delivered`. The table of reasons and what to do about each of them
is in [Deploying recipes](/recipes).

## New values without a new version

These fields are open sets and can gain new values without a change of API version:

- `code`, both in the HTTP envelope and in a build error;
- `reason` in `not_delivered_reasons` and `not_delivered`;
- `status` of an entry of `sources`;
- `stage` of a build error;
- `build_mode` and `origin` of a template.

Write your client so that an unfamiliar value does not break it:

- an unfamiliar **HTTP `code`** is handled by the HTTP status of the response, using the table in
  [How the status is chosen](#how-the-status-is-chosen) - retry a `503`, fix the request on a
  `400`, look at the resource on a `409`, and report a `500`;
- an unfamiliar value anywhere else is treated as "unknown" and shown as it is, rather than mapped
  onto a value you do know.

Existing values are not removed or renamed without a new version of the API.

## Getting help

Every response carries an `x-im-trace-id` header. Keep it - for a single failing request, and for
the poll that first showed a failed build - and quote it when you contact support. It is the
identifier that lets us find what happened to that exact request.
