> For the complete documentation index, see [llms.txt](https://docs.moderncollections.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.moderncollections.io/getting-started/error-handling.md).

# Error handling

Two principles drive our error design:

1. **Errors carry a stable `detail` string.** The full exception (with internal state) is logged server-side; the response body only carries an opaque, stable string.
2. **Errors tell you what HTTP semantics to use.** The status code maps to retry semantics — 5xx and 429 are safe to retry with backoff; 4xx (except 429) are not.

## Standard error envelope

Every non-2xx response returns:

```json
{
  "detail": "outreach blocked by compliance"
}
```

That is the entire body shape — a single `detail` string. There is no nested `error` object, no `code`/`category`/`request_id` fields, and no per-call structured `details` payload.

## HTTP status codes and `detail` strings

Platform errors are mapped to HTTP responses most-specific-first; an error condition without its own mapping inherits its nearest parent's. These are the exact `detail` strings clients receive:

| Status | `detail` string                    | Raised by                                                             |
| ------ | ---------------------------------- | --------------------------------------------------------------------- |
| `403`  | `"outreach blocked by compliance"` | `OutreachBlockedError`.                                               |
| `409`  | `"compliance decision expired"`    | `ComplianceExpiredError`.                                             |
| `403`  | `"compliance check failed"`        | `ComplianceError` (base).                                             |
| `400`  | `"intake request rejected"`        | `IntakeError` — CSV/file/webhook/address provisioning input rejected. |
| `401`  | `"authentication failed"`          | `AuthError`.                                                          |
| `502`  | `"voice platform unavailable"`     | `VoicePlatformError`.                                                 |
| `502`  | `"email delivery failed"`          | `EmailError`.                                                         |
| `503`  | `"enrichment unavailable"`         | `EnrichmentError`.                                                    |
| `500`  | `"internal server error"`          | Fallback for any other platform error.                                |

Because `OutreachBlockedError` and `ComplianceExpiredError` are listed before their base `ComplianceError`, they keep their dedicated `403`/`409` mappings; any other `ComplianceError` subclass falls through to `403 "compliance check failed"`.

The full exception message (and traceback) is logged server-side via `logger.exception` — the response body never carries it. The complete `detail` string clients receive is always one of the opaque strings above.

Route-level `HTTPException`s use their own `detail` strings (e.g. `"Missing authorization header"`, `"Invalid API key"`, `"Account not active"`, `"Placement not found"`, `"Duplicate invoice: <invoice_number>"`, `"OTP verification required"`, `"Rate limit exceeded"`).

The `Authorization` failure modes specifically:

| Status | `detail`                         | Cause                                                |
| ------ | -------------------------------- | ---------------------------------------------------- |
| `401`  | `"Missing authorization header"` | No `Authorization` header.                           |
| `401`  | `"Invalid authorization header"` | Header not `Bearer ...`.                             |
| `401`  | `"Invalid API key"`              | Key malformed, prefix not found, or bcrypt mismatch. |
| `403`  | `"Account not active"`           | Key valid but creditor not in `verified` status.     |

## Request validation (422)

The mapping above does not cover request validation. Every request is validated against the route's schema before any business logic runs. When validation fails — missing required body fields, wrong types, malformed UUIDs, out-of-range values — the API returns `422 Unprocessable Entity` immediately, so the error table above does not apply.

Unlike the opaque single-string envelope, the `422` body's `detail` is a **list** of per-field error objects, each with `loc` (the path to the offending field), `msg`, and `type`:

```json
{
  "detail": [
    {
      "loc": ["body", "invoice_number"],
      "msg": "Field required",
      "type": "missing"
    }
  ]
}
```

This is the one case where `detail` is not a flat string. `422` is a client-side input error — fix the request and resend; do not retry unchanged.

## Other status codes

For completeness, the full set of status codes you may see:

| Status      | When                                                                                     |
| ----------- | ---------------------------------------------------------------------------------------- |
| `200`       | Successful read or successful action that returns data                                   |
| `201`       | Successful resource creation                                                             |
| `202`       | Accepted but not actionable yet (e.g. SOL-time-barred intake routed to operator review)  |
| `204`       | Successful action with nothing to return                                                 |
| `400`       | Hand-rolled validation failure with a stable `detail` string                             |
| `401`       | API key missing or invalid                                                               |
| `403`       | Account not active, compliance refused, or OTP required                                  |
| `404`       | Resource doesn't exist OR you don't have access (we don't leak existence across tenants) |
| `409`       | Duplicate invoice, expired compliance decision, state machine conflict                   |
| `422`       | Request validation failure (list-shaped `detail`)                                        |
| `429`       | Rate limit hit; honor `Retry-After`                                                      |
| `500`       | Internal server error                                                                    |
| `502`/`503` | Upstream/integration failure (voice, email, enrichment)                                  |

## Retry policy

```
on 5xx, 429: retry with exponential backoff (e.g. 1s, 2s, 4s, 8s, capped at 60s, max 8 attempts)
on 4xx (except 429): DO NOT retry. Fix the input first.
```

## Rate limits

Per-tier per-minute limits apply to authenticated `/v1/*` requests, keyed on the bearer token.

| Tier         | Limit        |
| ------------ | ------------ |
| `standard`   | 100 req/min  |
| `priority`   | 300 req/min  |
| `enterprise` | 1000 req/min |

The unauthenticated public surfaces (`/v1/auth/*`, `/v1/public/*`, `/v1/portal/access*`, `/v1/portal/dsar`, and `/v1/portal/notice-at-collection`) are IP-keyed at 10 req/min per client IP.

Additional per-resource limits apply to specific endpoints:

| Endpoint                                       | Limit                                                          |
| ---------------------------------------------- | -------------------------------------------------------------- |
| `POST /v1/portal/payment-intents`              | 5/min per debtor                                               |
| `POST /v1/portal/access/verify` (request\_otp) | 3/min per debtor; also a rolling cap of 5 per 60-minute window |
| `POST /v1/portal/dsar`                         | 5/hour per client IP                                           |
| `POST /v1/placements/{id}/enrichment/refresh`  | 1/min per (creditor, placement)                                |

On 429:

```
HTTP/1.1 429 Too Many Requests
Retry-After: 8

{ "detail": "Rate limit exceeded" }
```

Always honor the `Retry-After` header.

## Idempotency

`Idempotency-Key` is not implemented today. Recommended practice is to use a stable client-side key for `invoice_number` so duplicate placements are rejected with `409 Duplicate invoice: <invoice_number>` rather than minted twice.

## Compliance behavior

Compliance gating fires before any outreach. When the compliance engine refuses an action, the route either:

* Returns the placement in a non-outreach status (e.g. flagged on `safety_flags`), and the response continues to surface a `compliance` block snapshot with `can_call=false`/`can_email=false` and machine-readable `reasons`, **or**
* Raises `OutreachBlockedError` → `403 {"detail": "outreach blocked by compliance"}` from a service-layer entrypoint.

The detailed reasons live on the `ComplianceDecision` row (visible via `GET /v1/placements/{id}/compliance-decision`) — the public error body intentionally does not leak them.

## Webhook delivery failures

Webhook retry behavior is owned by the delivery service. Detailed retry windows and failure surfaces are documented in [Webhooks: retries](/webhooks/retries.md).

## Logging on your side

Log the HTTP status, latency, and the route path. **Do not** log the `Authorization` header.

There is no `request_id` or correlation header returned today; pair API failures with timestamps when contacting support.

## See also

* [Webhooks: retries](/webhooks/retries.md)
* [API reference overview](/rest-api/overview.md)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.moderncollections.io/getting-started/error-handling.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
