Skip to content
OpenTrain AIOpenTrain AIOpenTrain AIDocs

Ask OpenTrain

Answers from the documentation, with sources.

What would you like to do with OpenTrain?

AI answers can be mistaken. Check the linked sources. Don’t include private account information.

Open app

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.

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.reason is the machine-readable sub-code.
CodeHTTP statusMeaning
BAD_REQUEST400Malformed input — fix the request
UNAUTHORIZED401Missing, invalid, expired, or revoked token
FORBIDDEN403Valid token, insufficient permission (scope, claim status, feature flag, or policy)
NOT_FOUND404Resource doesn’t exist or isn’t yours
CONFLICT409Valid request, but current state forbids it
RATE_LIMITED429Too many requests — back off
INTERNAL_ERROR500Server fault — retry with backoff, then report with requestId

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

reasonCodeWhat it meansAct on
account_claim_required403Action needs a claimed accountdetails.claimUrl — run the claim ceremony
payment_method_required409No card on file and credits can’t cover the chargedetails.billingUrl — human adds a card or credits in-app
already_accepted409Proposal was already hiredTreat as success; fetch the contract
not_fit_confirmation_required409Proposal is marked “Not a fit”Retry with confirmNotFitOverride: true if intentional
job_not_published409Action requires a published jobPublish first
not_a_freelancer / freelancer_unavailable409Target can’t be hired or invitedPick another candidate
employer_first_message_required409Candidate can’t be messaged until the employer writes firstSend the opening message via POST /proposals/{id}/conversation
proposal_not_ready_for_messaging409Proposal state doesn’t allow conversation yetWait for proposal progress
message_policy_blocked403Messaging policy rejected the sendInspect details.policy
read_only_conversation / message_blocked409Conversation can’t accept new messagesStop sending to it
moderation_blockedJob content failed moderationFix details.reasons[] and resubmit
teams_not_enabled403Teams feature off for this accountProbe capabilities
invite_email_send_failed409Team invite email could not be sentRetry or verify the address

List endpoints return a page plus nextCursor:

{
"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:

EndpointDefault limitMax
GET /jobs/mine25100
GET /jobs/{id}/proposals25100
GET /messages (conversations)2050
GET /messages/{conversationId}50100
GET /updates50200
GET /credits/ledger50100
GET /lms/retraining/cases50200
GET /lms/retraining/modules50200

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

A 429 carries everything you need to back off:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: ...
X-RateLimit-Remaining: 0
X-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.

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.

WriteRetry behavior
Invite AI trainerRepeat invite returns alreadyInvited: true — safe
Team inviteResponse status reports invite_created, member_added, or already_member — safe
Start pre-hire conversationReturns the existing conversation with created: false — safe
HireRe-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 registrationRequires Idempotency-Key; the same key and request replay the stored result
LMS retraining planStrictly no-write and intentionally takes no idempotency key; re-plan when its signed receipt expires or becomes stale
Send messageNot 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.

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 is generated from the same code that handles requests and is always current.