DEVELOPER DOCUMENTATION
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
Section titled “The Error Envelope”All errors return JSON in this shape:
{ "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.reasonis 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 |
The details.reason Catalog
Section titled “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 |
payment_method_required | 409 | No card on file and credits can’t cover the charge | details.billingUrl — human adds a card or credits 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 |
invite_email_send_failed | 409 | Team invite email could not be sent | Retry or verify the address |
Cursor Pagination
Section titled “Cursor Pagination”List endpoints return a page plus nextCursor:
{ "items": [ ... ], "nextCursor": "eyJpZCI6..."}- Pass it back as
?cursor=to fetch the next page;nextCursor: nullmeans 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 |
GET /lms/retraining/cases | 50 | 200 |
GET /lms/retraining/modules | 50 | 200 |
Conversation messages additionally accept ?direction=older|newer to page in either direction from the cursor.
Rate Limiting
Section titled “Rate Limiting”A 429 carries everything you need to back off:
HTTP/1.1 429 Too Many RequestsRetry-After: 30X-RateLimit-Limit: ...X-RateLimit-Remaining: 0X-RateLimit-Reset: ...{ "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.
Idempotency and Safe Retries
Section titled “Idempotency and Safe Retries”Some newer write endpoints require an Idempotency-Key header; other operations are naturally idempotent or document their own retry behavior. Reuse one stable key for the same logical operation. Reusing a key with changed input fails with 409 CONFLICT.
| 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; no money moves until a human confirms |
| LMS retraining create, update, assign, close, and module registration | Requires Idempotency-Key; the same key and request replay the stored result |
| LMS retraining plan | Strictly no-write and intentionally takes no idempotency key; re-plan when its signed receipt expires or becomes stale |
| Send message | Not idempotent — a retry sends a duplicate. Only retry on network failure where you know the request never arrived |
The OpenTrain CLI maps its required --key flag to the endpoint’s durable idempotency key.
GET requests are always safe to retry. The official CLI auto-retries GETs on 502/503/504 up to 3 times with exponential backoff.
Versioning
Section titled “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.jsonis generated from the same code that handles requests and is always current.