# Errors, limits and retries

> The error format and every error code, plus pagination, rate limits, retries, idempotency and versioning for the v1 API.

- Canonical URL: https://couplrfsm.com/developers/errors-and-limits
- Markdown: https://couplrfsm.com/developers/errors-and-limits.md
- API version: v1 (base URL https://couplrfsm.com/api/v1)
- OpenAPI specification: https://couplrfsm.com/developers/openapi.yaml
- All documentation pages: https://couplrfsm.com/llms.txt

> **Flip:** Things go wrong: a key gets revoked, a field is missing, a script loops too fast. Here's exactly what I send back in each case and what your code should do about it.

## Errors

Every error from `/api/v1` is a JSON object in the RFC 9457 problem details shape, with three additions: a stable `code`, a `request_id`, and `message` (the same text as `detail`, kept for older clients).

```json
{
  "type": "about:blank",
  "title": "Unprocessable Content",
  "status": 422,
  "detail": "The name field is required.",
  "code": "validation_failed",
  "request_id": "6f1c1f0e-5b1d-4c35-9a59-2f1a0b7c9c11",
  "message": "The name field is required.",
  "errors": {
    "name": ["The name field is required."]
  }
}
```

| Member | Meaning |
| --- | --- |
| `type` | Always `about:blank`. Do not branch on it. |
| `title` | The HTTP status phrase. |
| `status` | The HTTP status code. |
| `detail` | What went wrong, in a sentence. The wording may change. |
| `code` | Stable and machine-readable. Branch on this. |
| `request_id` | Unique per request. Also sent in the `X-Request-Id` response header. Give it to support. |
| `message` | Same text as `detail`. |
| `errors` | On `422` only: each invalid field mapped to its messages. |

Error bodies are served as `application/json`. Send `Accept: application/problem+json` to receive that media type instead. Successful responses also carry `X-Request-Id`.

## Error codes

| Status | Code | Meaning | Retry? |
| --- | --- | --- | --- |
| `401` | `api_key_missing` | No key was sent. | No. Send a key. |
| `401` | `api_key_invalid` | The key is unknown or revoked. | No. |
| `401` | `api_key_expired` | The key's end date has passed. | No. Use a current key. |
| `401` | `sandbox_expired` | The key belongs to a private trial sandbox whose access window has ended. | No. |
| `403` | `ability_missing` | The key lacks the ability for this endpoint. | No. Use a key with the ability. |
| `403` | `no_acting_admin` | The company has no admin user for the key to act as. | No. |
| `403` | `forbidden` | The request is not allowed for this account. | No. |
| `404` | `not_found` | No such record in this company, or no such endpoint. | No. |
| `405` | `method_not_allowed` | The endpoint does not support this HTTP method. | No. |
| `409` | `conflict` | Creating a customer with `ignore_duplicates: false` found a likely duplicate. The body lists candidates under `duplicates`. | No. Resolve the duplicate. |
| `422` | `validation_failed` | A parameter or field is invalid. See `errors`. | No. Fix the request. |
| `429` | `rate_limited` | Too many requests. | Yes, after `Retry-After` seconds. |
| `500` and above | `internal_error` | Something failed on our side. | Yes, with backoff. |

New codes may be added. Treat an unknown code by its HTTP status.

## Pagination

List endpoints take two query parameters:

| Parameter | Default | Notes |
| --- | --- | --- |
| `page` | `1` | Starts at 1. |
| `per_page` | `25` | Values above 100 are served as 100. |

Each list response includes `meta`:

```json
{"page": 1, "per_page": 25, "total": 61, "last_page": 3}
```

> **Flip:** I hand out lists a page at a time, newest first.

Request pages until `page` equals `last_page`.

To read only what changed, add `updated_since` with an ISO 8601 time, for example `?updated_since=2026-11-14T15:00:00Z`. Every paginated list accepts it and returns records changed at or after that time. Keep the time of your last successful read and send it on the next one. [Keep data in sync](https://couplrfsm.com/developers/syncing) walks through it. Lists are ordered newest first, with the id as the final sort, so a page never repeats or skips a record unless records are created or deleted while you are paging. `GET /api/v1/job-types` is not paginated.

A filter with an invalid value, such as a date that is not a date, returns `422`.

## Rate limits

- 120 requests a minute for each API key.
- 60 requests a minute per IP address for requests with a missing or invalid key.

Every response includes `X-RateLimit-Limit` and `X-RateLimit-Remaining`. When the limit is reached the response is `429` with code `rate_limited` and a `Retry-After` header giving the seconds to wait.

Requests must finish within about 28 seconds.

## Retries

> **Flip:** Retry when I say "slow down" or when I break. Don't retry when I say your request is wrong; it will be wrong the second time too.

- `GET` requests are always safe to retry.
- Retry `429` after the `Retry-After` time. Retry `5xx` and network failures with exponential backoff and some random jitter, for example 1, 2, 4 and 8 seconds, then stop.
- Do not retry other `4xx` responses. The same request will fail the same way.
- When retrying `POST` or `PATCH`, send the same `Idempotency-Key` so the operation cannot run twice.

## Idempotency

> **Flip:** This is how you retry a create without making two of something. Send me the same key twice and I'll hand back my first answer instead of doing the work again.

`POST` and `PATCH` endpoints accept an `Idempotency-Key` header: any string up to 100 characters that is unique to one operation, such as a UUID or your own order id.

```bash
curl -X POST "https://couplrfsm.com/api/v1/customers" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: YOUR_UNIQUE_KEY" \
  -d '{"name": "Dana Whitfield", "email": "dana@example.com", "phone": "(555) 201-8890"}'
```

- A repeat of the same key, by the same API key, on the same endpoint within 7 days returns the first response again without running the operation. The repeat carries the header `Idempotent-Replayed: true`.
- Responses with status `500` and above are not remembered, so they can be retried with the same key.
- The key is not compared with the request body. Reusing a key with a different body returns the first response. Use a new key for each new operation.
- Without the header, a request runs every time it is sent.

Separately, `POST /api/v1/jobs` returns the existing job instead of creating a second one when an identical job (same customer, job type and day) was created in the last 30 seconds.

## Versioning

- The version is in the path: `/api/v1`.
- v1 changes only by addition: new endpoints, new optional request fields, new response fields, new error codes. Clients must ignore response fields they do not recognize.
- A breaking change would be released under a new path prefix, with v1 kept running alongside it. No newer version exists today.

## Not supported

- Sorting parameters. Lists have one fixed order.
- Conditional requests (`ETag`, `If-Match`). Two updates to the same customer apply in the order they arrive.
- Bulk or batch endpoints.
