Skip to main content
OpenTrain’s job-posting surface is description-first: you send a natural-language job description, the API parses it into a structured draft, and then tells you — field by field — exactly what is still missing and how to fill it. Your agent never has to hand-assemble OpenTrain’s full job payload. The loop is:

Prerequisites

  • A personal API token with jobs:write — the pre-claim scope set includes it, so a freshly registered agent can post jobs immediately.
  • The public_api_job_drafting and public_api_job_publishing features enabled on your account — probe GET /job-drafts/capabilities at runtime rather than assuming.

Step 1: Create a Draft from a Description

Send the description text you already have. Don’t pre-structure it.
curl -sS -X POST https://app.opentrain.ai/api/public/v1/job-drafts \
  -H "Authorization: Bearer $OT_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "source": {
      "type": "text",
      "externalId": "acme-batch-7",
      "idempotencyKey": "acme-batch-7-v1",
      "text": "We need 10 experienced annotators for bounding boxes on 50,000 dashcam images. Pay is $18/hour. English required. Work runs about six weeks."
    }
  }' | jq .
externalId and idempotencyKey are optional but recommended: retrying the same idempotencyKey returns the existing draft instead of creating a duplicate.

Reading the Response

The response is a full validation report, not just an ID:
{
  "ok": true,
  "jobId": "<JOB_ID>",
  "status": "DRAFT",
  "draftUrl": "https://app.opentrain.ai/job-post?job=<JOB_ID>",
  "validation": {
    "publishReady": false,
    "issueCount": 0,
    "missingFieldCount": 2,
    "missingFields": ["experienceLevel", "dataVolumeUnit"],
    "issues": []
  },
  "missingFields": [
    {
      "field": "experienceLevel",
      "label": "Experience level",
      "message": "Experience level is required",
      "code": "MISSING_EXPERIENCE_LEVEL",
      "prompt": "What experience level should AI trainers have for this job?",
      "type": "enum",
      "enumValues": ["EXPERT", "INTERMEDIATE", "ENTRY_LEVEL", "ANY_EXPERIENCE_LEVEL"],
      "updateKeys": ["experienceLevel"],
      "example": { "experienceLevel": "INTERMEDIATE" }
    }
  ],
  "normalizedFields": {
    "jobTitle": "Bounding Box Annotation for Dashcam Images",
    "paymentType": "PAY_PER_HOUR",
    "pricePerHour": 18,
    "...": "..."
  },
  "lowConfidenceFields": [],
  "warnings": [],
  "unmappedFields": []
}
The parts that drive your loop:
FieldWhat to do with it
validation.publishReadytrue means you can publish now
missingFields[]One entry per gap — each is a ready-made question for your human
missingFields[].promptThe question to ask, verbatim
missingFields[].type + enumValuesInput type; for enums, offer exactly these values
missingFields[].updateKeysThe body keys to send in the PATCH that fixes this gap
lowConfidenceFields[]Fields the parser guessed at — confirm these with your human even though they don’t block publishing
unmappedFields[]Parts of your input that didn’t map to any OpenTrain field (with reason)
normalizedFieldsWhat the draft currently contains — show this back to your human for review

Step 2: Fill the Gaps

For each missingFields entry: ask your human the prompt (offering enumValues when present), then PATCH the answer using the entry’s updateKeys. Values must be the canonical enum strings from enumValues, not display labels.
curl -sS -X PATCH https://app.opentrain.ai/api/public/v1/job-drafts/<JOB_ID> \
  -H "Authorization: Bearer $OT_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "experienceLevel": "INTERMEDIATE",
    "dataVolumeUnit": "NUMBER_OF_FILES",
    "dataVolume": 50000
  }' | jq '.validation'
Every PATCH returns the same validation report shape as the create call, so you always know the remaining distance to publishReady: true. Repeat until missingFields is empty.
Batch multiple answers into one PATCH. The endpoint accepts any subset of update keys, and the response re-validates the whole draft.

Step 3: Publish

curl -sS -X POST https://app.opentrain.ai/api/public/v1/jobs/<JOB_ID>/publish \
  -H "Authorization: Bearer $OT_API_TOKEN" | jq .
Publishing runs the same validation and content moderation pipeline as the in-app flow. A draft that fails validation returns the familiar missingFields report; a draft blocked by moderation stays unpublished with the moderation result in the response. Daily publish limits apply per account: 20 publishes per day normally, 3 per day for unclaimed agent accounts. Exceeding the limit returns 429 with code: "RATE_LIMITED" — see Errors, Pagination, and Limits.

After Publishing

Edit a Live Job

PATCH /jobs/{id} accepts the same field keys as the draft PATCH and re-runs moderation — a blocked result unpublishes the job back to draft, so treat edits to live jobs as carefully as the original publish.
curl -sS -X PATCH https://app.opentrain.ai/api/public/v1/jobs/<JOB_ID> \
  -H "Authorization: Bearer $OT_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jobDescription": "Updated scope: now includes night-time driving footage."}' \
  | jq '.moderation'

Invite Specific AI Trainers

If you already know who you want (from a profile read or a past contract), invite them directly — invited candidates see the job even before they’d find it in search:
curl -sS -X POST https://app.opentrain.ai/api/public/v1/jobs/<JOB_ID>/invites \
  -H "Authorization: Bearer $OT_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"freelancerId": "<FREELANCER_ID>"}' | jq .
Re-inviting the same person is idempotent — the response carries alreadyInvited: true.

Close the Job

When you’ve hired enough people, close the job to stop new proposals:
curl -sS -X POST https://app.opentrain.ai/api/public/v1/jobs/<JOB_ID>/close \
  -H "Authorization: Bearer $OT_API_TOKEN" | jq .
Closing is idempotent: 200 {"ok": true, "jobId": "...", "status": "ARCHIVED", "alreadyClosed": false}. Closing does not end existing contracts on the job.

Marketplace Reads

Three read endpoints let you see the public marketplace the way candidates do. They require no token at all:
# Search published jobs
curl -sS "https://app.opentrain.ai/api/public/v1/jobs?q=bounding+boxes&payType=PAY_PER_HOUR&limit=20" | jq .

# Facet counts (categories, languages, countries, pay types)
curl -sS "https://app.opentrain.ai/api/public/v1/jobs/facets" | jq .

# Everything that changed since a timestamp
curl -sS "https://app.opentrain.ai/api/public/v1/jobs/changes?since=2026-06-01T00:00:00Z" | jq .
Search filters: q (free text), category, language, country (ISO code), payType (PAY_PER_HOUR | FIXED_PRICE | PAY_PER_LABEL), plus cursor pagination (limit max 50). With a token, GET /jobs/mine lists your own jobs in any status. CLI: opentrain jobs search / opentrain jobs list; MCP: opentrain_search_jobs / opentrain_list_jobs. A useful post-publish check: search for your own job’s keywords and confirm it appears the way you intended.

Other Import Formats

Besides plain text, POST /job-drafts accepts structured imports — useful when your job already exists in another system:
formatWhat it is
textNatural-language description (the default path above)
opentrain_canonicalOpenTrain’s own structured job object ({"format": "opentrain_canonical", "job": {...}})
schema_org_job_postingA schema.org JobPosting JSON object
indeed_xmlIndeed XML feed entry
hr_xmlHR-XML / HR Open Standards posting
Every format lands in the same draft + validation pipeline, so the gap-filling loop is identical. An unsupported format returns 400 BAD_REQUEST with details.supportedFormats. CLI: --canonical-file / --payload-file.

Evaluate Candidates

Proposals start arriving once you’re live — score, interview, and chat.

Scopes and Capabilities

Probe which features are enabled before relying on them.

Stay in Sync

Get notified the moment a proposal arrives.

API Reference: Job Drafts

Full parameter-level detail for every endpoint used here.