# Errors

> Every error is RFC 9457 problem+json with a stable code and a retryable flag. The full catalog, with what to do about each code.

When a request fails as a whole, the response has an HTTP error status and a `Content-Type: application/problem+json` body ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)). That holds for every error under `/external/v1`, including a path that does not exist (`404`), the wrong HTTP method (`405`) and a body that is not JSON (`415`):

```json
{
  "type": "https://funnelfeedr.com/developers/errors/insufficient_credits",
  "title": "Not enough credits",
  "status": 402,
  "detail": "This request needs 3.4 credits; the account has 1.2. Nothing was revealed or charged. Buy credits in Funnelfeedr under Settings → Subscription, then retry.",
  "code": "insufficient_credits",
  "retryable": false,
  "creditsRequired": 3.4,
  "creditsRemaining": 1.2,
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
```

| Field | Meaning |
|---|---|
| `code` | The stable, machine-readable error. **Branch on this.** |
| `retryable` | `true` when the same request may succeed if you send it again later. |
| `status` | The HTTP status, repeated. |
| `title` | A short summary; always the same for a given `code`. |
| `detail` | What went wrong this time, for humans. It may change; don't parse it. |
| `type` | A link to this code's row in the table below. |
| `traceId` | Quote it when you contact support. |
| `errors` | `validation_failed` only: the messages for each invalid field, keyed by the field's JSON path (`maxCredits`, `items`, `items[3].contactId`), by `$` for the body as a whole, or by the header's name (`Idempotency-Key`). |
| `creditsRequired` | `credit_cap_exceeded` and `insufficient_credits`: what the request would have cost. Nothing was spent. |
| `maxCredits` | `credit_cap_exceeded` only: the `maxCredits` the request carried. |
| `creditsRemaining` | `insufficient_credits` only: the account's balance when the request ran. |
| `retryAfterSeconds` | `rate_limited` only: seconds until a retry can succeed, as in `Retry-After`. |
| `maxItems` | `too_many_items` only: the most items a request may carry (25). |

A single bad item in a batch is not an error response: it comes back inside a `200` as `"status": "invalid"` with an `error` object, and the rest of the batch runs. See [Batching](https://funnelfeedr.com/developers/batching.md).

## Catalog

| `code` | Status | Retryable | Title | What to do |
|---|---|---|---|---|
| `invalid_api_key` | 401 | no | Invalid API key | The `Authorization` header is missing, malformed or holds a key that does not exist. Check the `FUNNELFEEDR_API_KEY` value; keys start with `ff_live_` and are 46 characters long. |
| `api_key_revoked` | 401 | no | API key revoked | An admin revoked the key. Ask for a new key; do not retry. |
| `api_key_expired` | 401 | no | API key expired | The key was rolled and its overlap period has ended. Switch to the replacement key. |
| `feature_not_enabled` | 403 | no | The External API is not enabled for this account | API access has not been granted to the account, or has been withdrawn. Request access from Settings → API keys. |
| `not_found` | 404 | no | Not found | No endpoint has this path. Check the path against the API reference. A record that does not exist is not this error: lookups answer `not_found` per item instead. |
| `method_not_allowed` | 405 | no | Method not allowed | The path exists but not for this HTTP method (a `GET` on a `POST` endpoint). `detail` and the `Allow` header name the method to use. |
| `unsupported_media_type` | 415 | no | The request body must be JSON | Send the body as JSON with `Content-Type: application/json`. |
| `validation_failed` | 422 | no | The request is invalid | The body, a parameter or the `Idempotency-Key` header is invalid as a whole. `errors` maps each field's JSON path to its messages. Fix the request; an unusable single item comes back as `status: "invalid"` instead. |
| `too_many_items` | 422 | no | Too many items in one request | Send at most 25 items per request (`maxItems` in the body). Split the batch. |
| `credit_cap_exceeded` | 422 | no | The request would cost more than maxCredits | Nothing was spent. `creditsRequired` in the body is what the request would cost, `maxCredits` the cap it carried. Ask the user before raising `maxCredits`. |
| `idempotency_key_required` | 400 | no | An Idempotency-Key header is required | The call spends credits (`contacts/reveal` always, dry runs included; `organizations/match`, `people/match` and `organizations/switchboards/match` when they reveal). Send an `Idempotency-Key` header with a new UUID for each logical request. |
| `idempotency_key_reused` | 422 | no | Idempotency-Key was already used with a different request | The key was used in the last 24 hours for a different method, path or body. Generate a new key for a new request. |
| `idempotency_key_in_use` | 409 | yes | A request with this Idempotency-Key is still in progress | A request with the same key is still running. Wait a moment and retry with the same key and body to get its response. |
| `insufficient_credits` | 402 | no | Not enough credits | Nothing was spent. `creditsRequired` and `creditsRemaining` in the body say how far short the account is. Stop and tell the user; credits are bought in the app under Settings → Subscription. Do not retry until they have been. |
| `rate_limited` | 429 | yes | Rate limit exceeded | Wait the number of seconds in `Retry-After` (also `retryAfterSeconds` in the body), then retry. |
| `internal_error` | 500 | yes | Internal error | Retry with backoff. For a spend call, retry with the same `Idempotency-Key` so it cannot charge twice. Quote `traceId` if you contact support. |

New codes may be added. Treat an unknown `code` by its `status` and `retryable`.

## Validation errors

A request that is invalid as a whole is `422 validation_failed`, with each problem under `errors`, keyed by field. That includes a body that is not valid JSON (`"$": ["The body is not valid JSON (line 1, position 12)."]`), a value of the wrong type (`"items[0].contactId": ["Must be a UUID."]`, `"infoTypes[0]": ["Must be one of: email, phone, linkedIn."]`) and a malformed `Idempotency-Key` header:

```json
{
  "type": "https://funnelfeedr.com/developers/errors/validation_failed",
  "title": "The request is invalid",
  "status": 422,
  "detail": "maxCredits is required when revealing. Set it to the most this request may spend, or send dryRun: true to get the price first.",
  "code": "validation_failed",
  "retryable": false,
  "errors": {
    "maxCredits": [
      "maxCredits is required when revealing. Set it to the most this request may spend, or send dryRun: true to get the price first."
    ]
  },
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
```

## Retrying

- Retry only when `retryable` is `true`: `rate_limited` (after `Retry-After`), `idempotency_key_in_use` and `internal_error`.
- Back off between attempts, and give up after a few.
- On a call that spends, retry with the **same** `Idempotency-Key` and the same body, so a retry cannot charge twice. See [Idempotency](https://funnelfeedr.com/developers/idempotency.md).
- Never retry `insufficient_credits` in a loop. Stop and tell the user.
