> ## Documentation Index
> Fetch the complete documentation index at: https://teardowns.aero/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> Every documented error code, what triggers it, and how to fix it.

Most error responses follow this shape:

```jsonc theme={null}
{
  "detail": {
    "error_code": "invalid_api_key",
    "message": "Invalid API key"
  }
}
```

Sometimes additional fields are included for context for example
`unknown_vocabulary` errors include `valid_values` so you can correct
the value without reading docs. Validation errors from Pydantic
(unknown body keys, wrong types) follow FastAPI's standard 422 shape.

Branch on `error_code`, surface `message` to humans, log the rest.

### Two exceptions to the envelope shape

Some endpoints return a **plain string** in `detail` rather than the
`{ error_code, message }` object. Today this applies to:

1. **`404 Not Found`** on the by-id endpoints
   (`GET /teardowns/{id}`, `PATCH /teardowns/{id}`,
   `DELETE /teardowns/{id}`, and the matching
   `/sales-lease-exchange/{id}` routes). The body is literally:

   ```jsonc theme={null}
   { "detail": "Teardown not found" }
   ```

   or:

   ```jsonc theme={null}
   { "detail": "Listing not found" }
   ```

   There is no `error_code` or `message` field branch on the HTTP
   status `404` instead.

2. **`400` from the transition endpoint** when the state-machine
   rejects a move (e.g. trying to `complete` a teardown that is still
   in `active_starting`). The body is a plain string from the
   state-machine layer:

   ```jsonc theme={null}
   { "detail": "Cannot perform 'complete' from status 'active_starting'" }
   ```

   Same pattern: no `error_code`, branch on the `400` status and the
   `action` you sent.

For everything else listed in the table below, the
`{ error_code, message, ... }` envelope is the rule.

## Quick remediation matrix

| Status | `error_code`                          | Why                                                                                                   | Fix                                                                                                                                                              |
| ------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 401    | `missing_or_malformed_authorization`  | `Authorization` header missing, empty, or doesn't start with `Bearer tdao_live_`                      | Send the API key as `Authorization: Bearer tdao_live_…`. JWTs from the web app are not accepted here.                                                            |
| 400    | `invalid_organization_header`         | `X-Organization-Id` missing or not a UUID                                                             | Send the org's UUID from the API Access page.                                                                                                                    |
| 401    | `invalid_api_key`                     | Key not found, revoked, or rotated                                                                    | Mint a fresh key on the Settings page. Old value is dead.                                                                                                        |
| 401    | `api_key_expired`                     | Key passed its `expires_at`                                                                           | Mint a new key, optionally without an expiry.                                                                                                                    |
| 403    | `organization_mismatch`               | The key belongs to a different org than the `X-Organization-Id` header                                | Always send the org id that goes with the key. **Every** occurrence is treated as suspicious and alerted on.                                                     |
| 403    | `org_inactive`                        | The org's `account_status` is `inactive`                                                              | Contact Teardowns.aero support.                                                                                                                                  |
| 403    | `org_churned`                         | The org's `account_status` is `churned`                                                               | Contact Teardowns.aero support.                                                                                                                                  |
| 403    | `subscription_required`               | Subscription is `past_due` / `canceled` / `expired` / `rejected` / pending payment / pending approval | Resolve billing. The `subscription_status` field is included in the error body.                                                                                  |
| 403    | `api_access_disabled`                 | API access is turned off for this org                                                                 | Ask Teardowns.aero support to re-enable. Existing keys resume working, no rotation needed.                                                                       |
| 403    | `api_key_creator_revoked`             | The user who minted the key is no longer active in this org                                           | Have another eligible member mint a replacement key.                                                                                                             |
| 403    | `insufficient_capability`             | The minter doesn't hold the capability the endpoint requires                                          | Have an org admin grant the capability, or mint the key as someone who already has it.                                                                           |
| 404    | *(plain-string body, no error\_code)* | The resource doesn't exist OR belongs to a different org                                              | Confirm the id is yours. We deliberately do not distinguish never reveal foreign-org id existence. See the "Two exceptions" note above for the exact body shape. |
| 422    | `invalid_status`                      | The `status` field is not one of `Starting` / `In process` / `Completed`                              | Use one of the three exact values. Case-sensitive.                                                                                                               |
| 422    | `vocab_required`                      | A required vocabulary field is missing for the chosen `asset_type`                                    | Send `aircraft_type` for aircraft, `engine_model` for engine, etc.                                                                                               |
| 400    | `unknown_vocabulary`                  | The vocabulary name doesn't match any active row                                                      | The error body includes `valid_values` pick one of those. Match is case-insensitive.                                                                             |
| 422    | `invalid_audience`                    | An audience value is not in the allowed set                                                           | Allowed: `Airline` / `Lessor` / `OEM` / `MRO` / `Distributor` / `Others`. Case-insensitive.                                                                      |
| 400    | `file_too_large`                      | Upload exceeds 50 MB                                                                                  | Split the file or compress.                                                                                                                                      |
| 400    | `invalid_content_type`                | Upload MIME isn't in the accepted list                                                                | Accepted: PDF / XLSX / XLS / DOCX / DOC / CSV / JPEG / PNG / WEBP / GIF.                                                                                         |
| 502    | `storage_unavailable`                 | Supabase Storage upstream failed                                                                      | Retry with backoff. Each retry uploads a new file with a fresh UUID prefix no corruption risk.                                                                   |
| 413    | `payload_too_large`                   | JSON body exceeded the 10 MB cap                                                                      | Use the document upload endpoint for files, not the JSON endpoint.                                                                                               |
| 429    | `too_many_requests`                   | A rate-limit bucket overflowed                                                                        | Check `Retry-After` header. Back off. See [rate-limiting](/concepts/rate-limiting).                                                                              |
| 422    | various Pydantic shapes               | Unknown body key, wrong type, etc.                                                                    | The response body lists the bad field. Strict mode = no silent drops.                                                                                            |

## Detailed explanations

<AccordionGroup>
  <Accordion title="401 invalid_api_key same response, three causes">
    We return this same code whether the key doesn't exist, was revoked,
    or matched a fingerprint but failed status checks. The single
    response prevents an attacker from probing the keyspace.

    From your side: if you minted the key recently and it suddenly stops
    working, the most likely cause is that the minter was deactivated
    (which auto-revokes their keys within an hour, but the 401 starts
    immediately). Less likely: the key was rotated by another org admin.
  </Accordion>

  <Accordion title="403 organization_mismatch always investigate">
    The API key already implies an org. Sending a different org id in
    `X-Organization-Id` is, by construction, never a legitimate user
    error either the wrong value got pasted into your ERP config, or
    something more interesting is happening. We alert on every
    occurrence.

    Fix: re-paste the Organization ID from the Settings → API Access
    page where you minted the key.
  </Accordion>

  <Accordion title="403 api_access_disabled vs subscription_required">
    Two different switches:

    * `api_access_disabled`: API access was turned off for your org
      specifically. Has nothing to do with billing.
    * `subscription_required`: your subscription is past\_due / canceled /
      expired / not yet active. Resolve billing.

    Both can be true at once. If both checks would fail, the
    subscription one fires first.
  </Accordion>

  <Accordion title="400 unknown_vocabulary self-correcting error">
    The full list of valid values is in the response body under
    `valid_values`:

    ```jsonc theme={null}
    {
      "detail": {
        "error_code": "unknown_vocabulary",
        "field": "aircraft_type",
        "value": "A320-poo",
        "valid_values": ["737-700", "747-400", "A320-200", "A321-200", ...],
        "message": "Unknown aircraft_type: 'A320-poo'. See valid_values."
      }
    }
    ```

    Match is case-insensitive `a320-200` and `A320-200` both work.
    Whitespace at the edges is stripped.
  </Accordion>

  <Accordion title="422 invalid_status exact strings only">
    The `status` field accepts exactly three values:

    * `Starting`
    * `In process`
    * `Completed`

    Case-sensitive. `starting`, `in progress`, `STARTING` all return 422.
    We picked strict matching so the OpenAPI docs show the three values
    as a real enum IDE autocomplete and linters can see them.
  </Accordion>

  <Accordion title="422 with no error_code Pydantic validation">
    Validation errors from the schema layer use FastAPI's standard 422
    shape. Example for an unknown body key:

    ```jsonc theme={null}
    {
      "detail": [
        {
          "type": "extra_forbidden",
          "loc": ["body", "registration"],
          "msg": "Extra inputs are not permitted",
          "input": "VT-ABC"
        }
      ]
    }
    ```

    `loc` tells you exactly which key was bad. Common offenders:
    `registration` (use `tail_number`), `location_country` (use
    `country`), `estimated_teardown_date` (use `start_date`),
    `aircraft_type_id` (use the name, not the UUID).
  </Accordion>

  <Accordion title="429 too_many_requests when to back off">
    The response includes a `Retry-After` header in seconds. Wait at
    least that long before retrying. Exponential backoff on top is good
    practice the limit window resets gradually.

    If you're hitting bucket A (the 600 req/min per-key limit) regularly,
    your ERP is probably retrying too aggressively. Mint a second key
    for batch jobs that need their own budget.
  </Accordion>

  <Accordion title="500 / 502 retry, then ping support">
    `500` is an unexpected server-side bug. `502 storage_unavailable` is
    a Supabase Storage upstream issue.

    For both: retry the request with backoff. If it persists for more
    than a minute, capture the request id (returned in the
    `x-request-id` response header) and email `support@teardowns.aero`
    with the id + a description.
  </Accordion>
</AccordionGroup>

## What we don't return

A few common shapes you might expect that the API deliberately doesn't use:

* **Plain text errors.** Every error has a structured JSON body. No bare
  strings. No HTML pages.
* **Different shapes for different error families.** The shape is always
  `{ "detail": { "error_code", "message", ...optional fields } }` (for
  business errors) or FastAPI's default 422 shape (for schema errors).
* **`success: true/false` envelopes.** Successes return the resource
  directly. Branch on HTTP status, not a wrapper field.
