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

# Errors, Pagination, and Rate Limits

> The error envelope, error codes, the details.reason catalog, cursor pagination, rate limiting, and API versioning.

Every Public API endpoint (`/api/public/v1/...`) shares one error shape, one pagination scheme, and one rate-limiting contract. Learn them once and every endpoint behaves predictably.

## The Error Envelope

All errors return JSON in this shape:

```json theme={null}
{
  "error": "Human-readable message",
  "code": "FORBIDDEN",
  "requestId": "req_...",
  "details": { "reason": "account_claim_required", "claimUrl": "..." }
}
```

* `error` — a sentence you can show a human or log.
* `code` — a stable enum (below). Branch on this, not on the message text.
* `requestId` — quote it when reporting problems; it correlates server-side logs.
* `details` — optional structured context. When present, `details.reason` is the machine-readable sub-code.

| Code             | HTTP status | Meaning                                                                             |
| ---------------- | ----------- | ----------------------------------------------------------------------------------- |
| `BAD_REQUEST`    | 400         | Malformed input — fix the request                                                   |
| `UNAUTHORIZED`   | 401         | Missing, invalid, expired, or revoked token                                         |
| `FORBIDDEN`      | 403         | Valid token, insufficient permission (scope, claim status, feature flag, or policy) |
| `NOT_FOUND`      | 404         | Resource doesn't exist or isn't yours                                               |
| `CONFLICT`       | 409         | Valid request, but current state forbids it                                         |
| `RATE_LIMITED`   | 429         | Too many requests — back off                                                        |
| `INTERNAL_ERROR` | 500         | Server fault — retry with backoff, then report with `requestId`                     |

<Note>
  Agent-auth endpoints (`/api/agent/...`) use the OAuth wire shape
  `{"error": "...", "error_description": "..."}` instead — see
  [Authentication](/docs/developers/concepts/authentication#error-shape).
</Note>

## The `details.reason` Catalog

When an error is actionable, `details.reason` tells you what to do — often with an accompanying URL field to hand to your human:

| `reason`                                      | Code | What it means                                               | Act on                                                                                                    |
| --------------------------------------------- | ---- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `account_claim_required`                      | 403  | Action needs a claimed account                              | `details.claimUrl` — run the [claim ceremony](/docs/developers/concepts/authentication#the-claim-ceremony)     |
| `payment_method_required`                     | 409  | No card on file and credits can't cover the charge          | `details.billingUrl` — human adds a card or [credits](/docs/developers/concepts/credits-and-billing) in-app    |
| `already_accepted`                            | 409  | Proposal was already hired                                  | Treat as success; fetch the contract                                                                      |
| `not_fit_confirmation_required`               | 409  | Proposal is marked "Not a fit"                              | Retry with `confirmNotFitOverride: true` if intentional                                                   |
| `job_not_published`                           | 409  | Action requires a published job                             | Publish first                                                                                             |
| `not_a_freelancer` / `freelancer_unavailable` | 409  | Target can't be hired or invited                            | Pick another candidate                                                                                    |
| `employer_first_message_required`             | 409  | Candidate can't be messaged until the employer writes first | Send the opening message via `POST /proposals/{id}/conversation`                                          |
| `proposal_not_ready_for_messaging`            | 409  | Proposal state doesn't allow conversation yet               | Wait for proposal progress                                                                                |
| `message_policy_blocked`                      | 403  | Messaging policy rejected the send                          | Inspect `details.policy`                                                                                  |
| `read_only_conversation` / `message_blocked`  | 409  | Conversation can't accept new messages                      | Stop sending to it                                                                                        |
| `moderation_blocked`                          | —    | Job content failed moderation                               | Fix `details.reasons[]` and resubmit                                                                      |
| `teams_not_enabled`                           | 403  | Teams feature off for this account                          | Probe [capabilities](/docs/developers/concepts/scopes-and-capabilities#capabilities-runtime-feature-discovery) |
| `invite_email_send_failed`                    | 409  | Team invite email could not be sent                         | Retry or verify the address                                                                               |

## Cursor Pagination

List endpoints return a page plus `nextCursor`:

```json theme={null}
{
  "items": [ ... ],
  "nextCursor": "eyJpZCI6..."
}
```

* Pass it back as `?cursor=` to fetch the next page; `nextCursor: null` means you've reached the end.
* Treat cursors as **opaque strings** — don't parse or construct them.
* **Persist your cursor** between runs for feeds like `/updates`; that's how you resume without missing events.

Default and maximum page sizes vary by endpoint:

| Endpoint                         | Default `limit` | Max |
| -------------------------------- | --------------- | --- |
| `GET /jobs/mine`                 | 25              | 100 |
| `GET /jobs/{id}/proposals`       | 25              | 100 |
| `GET /messages` (conversations)  | 20              | 50  |
| `GET /messages/{conversationId}` | 50              | 100 |
| `GET /updates`                   | 50              | 200 |
| `GET /credits/ledger`            | 50              | 100 |

Conversation messages additionally accept `?direction=older|newer` to page in either direction from the cursor.

## Rate Limiting

A `429` carries everything you need to back off:

```text theme={null}
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: ...
X-RateLimit-Remaining: 0
X-RateLimit-Reset: ...
```

```json theme={null}
{
  "error": "Too many requests. Please retry after 30 seconds.",
  "code": "RATE_LIMITED",
  "requestId": "req_..."
}
```

Honor `Retry-After`, then resume. For sustained polling loops, use exponential backoff with jitter and respect `X-RateLimit-Remaining` proactively rather than driving into the limit.

Job publishing has its own per-account daily quota (20 claimed / 3 unclaimed per 24h) that also surfaces as `RATE_LIMITED` — see [Scopes and Capabilities](/docs/developers/concepts/scopes-and-capabilities#daily-publish-limits).

## Idempotency and Safe Retries

There is no `Idempotency-Key` header. Instead, the write endpoints that matter are naturally idempotent or state their behavior:

| Write                            | Retry behavior                                                                                                         |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Invite AI trainer                | Repeat invite returns `alreadyInvited: true` — safe                                                                    |
| Team invite                      | Response `status` reports `invite_created`, `member_added`, or `already_member` — safe                                 |
| Start pre-hire conversation      | Returns the existing conversation with `created: false` — safe                                                         |
| Hire                             | Re-hiring an accepted proposal returns `409 already_accepted` — treat as success                                       |
| Fund / approve / end (co-signed) | Creates a pending [approval](/docs/developers/concepts/human-approvals); no money moves until a human confirms              |
| **Send message**                 | **Not idempotent** — a retry sends a duplicate. Only retry on network failure where you know the request never arrived |

`GET` requests are always safe to retry. The official [CLI](/docs/developers/cli/overview) auto-retries GETs on `502/503/504` up to 3 times with exponential backoff.

## Versioning

The Public API is versioned in the path: `/api/public/v1/`. Within `v1`:

* **Additive changes** (new fields, new endpoints, new enum values) ship without notice — write tolerant parsers that ignore unknown fields.
* **Breaking changes** get a new path version.
* The served [`openapi.json`](https://app.opentrain.ai/api/public/v1/openapi.json) is generated from the same code that handles requests and is always current.

## Related

<CardGroup cols={2}>
  <Card title="Scopes and Capabilities" href="/docs/developers/concepts/scopes-and-capabilities" icon="shield-check">
    The three permission gates behind every 403.
  </Card>

  <Card title="Stay in Sync" href="/docs/developers/guides/stay-in-sync" icon="refresh-cw">
    Polling /updates with persisted cursors, and when to add webhooks.
  </Card>
</CardGroup>
