Skip to main content
POST
/
api
/
public
/
v1
/
job-drafts
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 '{
    "rawJobDescription": "We need 5 fluent Spanish speakers to label sentiment in ~20,000 social media posts. Pay per label. Annotators should have prior text-classification experience.",
    "idempotencyKey": "sentiment-es-2026-001"
  }'
{
  "ok": true,
  "jobId": "<JOB_ID>",
  "status": "DRAFT",
  "draftUrl": "https://app.opentrain.ai/job-post?jobId=<JOB_ID>",
  "reviewUrl": "https://app.opentrain.ai/job-post?jobId=<JOB_ID>",
  "validation": {
    "publishReady": false,
    "issueCount": 0,
    "missingFieldCount": 2,
    "missingFields": [],
    "issues": []
  },
  "missingFields": [
    {
      "field": "experienceLevel",
      "label": "Experience level",
      "message": "Experience level is required before publishing.",
      "code": "missing_required_field",
      "prompt": "ask: What experience level should AI trainers have? (EXPERT, ENTRY_LEVEL, INTERMEDIATE, ANY_EXPERIENCE_LEVEL)",
      "type": "enum",
      "enumValues": ["EXPERT", "ENTRY_LEVEL", "INTERMEDIATE", "ANY_EXPERIENCE_LEVEL"],
      "updateKeys": ["experienceLevel"],
      "example": "INTERMEDIATE",
      "hint": "Pick the minimum level the work realistically needs."
    },
    {
      "field": "pricePerLabel",
      "label": "Price per label",
      "message": "A pay rate is required before publishing.",
      "code": "missing_required_field",
      "prompt": "ask: How much will you pay per label (USD)?",
      "type": "number",
      "enumValues": null,
      "updateKeys": ["pricePerLabel"],
      "example": "0.05",
      "hint": null
    }
  ],
  "normalizedFields": {
    "jobTitle": "Spanish Sentiment Labeling — Social Media Posts",
    "paymentType": "PAY_PER_LABEL",
    "languages": ["Spanish"],
    "headcount": 5,
    "dataVolume": 20000,
    "dataVolumeUnit": "NUMBER_OF_FILES"
  },
  "warnings": [],
  "unmappedFields": [],
  "lowConfidenceFields": [
    { "path": "dataVolumeUnit", "reason": "Inferred NUMBER_OF_FILES from \"posts\"." }
  ],
  "import": {
    "format": "text",
    "externalId": null,
    "idempotencyKey": "sentiment-es-2026-001",
    "rawSourcePreserved": true,
    "autoPublished": false
  },
  "parser": {
    "source": "llm",
    "warnings": [],
    "parsedFields": 6
  }
}
Creates an unpublished job draft. The description-first workflow: send a plain-text job description, OpenTrain parses it into structured fields server-side, and the response tells you exactly what is still missing — each missing field carries a ready-to-ask prompt, its type, allowed enumValues, and the updateKeys to patch via PATCH /job-drafts/{jobId}. Loop until validation.publishReady is true, then publish. Structured imports (schema.org JSON-LD, Indeed-style XML, HR-XML, or OpenTrain’s canonical job object) are also accepted. See the posting guide for the full loop. Requirements: jobs:write scope + the public_api_job_drafting feature (check capabilities).

Request

Send one of the body shapes below. All are JSON objects.
rawJobDescription
string
Plain-text job description or project brief (max 60,000 characters). The simplest and recommended input — OpenTrain parses it into structured fields. Equivalent shorthand for source: {type: "text", text: ...}.
source
object
Structured import source.
format
string
Top-level alternative to source.type — same supported values. Pair with job (canonical), jsonLd (schema.org), or xml (feeds).
job
object
OpenTrain canonical job object when format is opentrain_canonical — keys like title, description, paymentType, rateAmount, languages, countries, labelTypes, tools, experienceLevel.
externalId
string
Top-level equivalent of source.externalId.
idempotencyKey
string
Top-level equivalent of source.idempotencyKey.

Response

ok
boolean
true on success.
jobId
string
The new draft’s job ID — use it for every subsequent PATCH and the publish call.
status
string
Always DRAFT (this endpoint never auto-publishes).
draftUrl
string
In-app URL of the draft editor.
reviewUrl
string
In-app URL where a human can review the draft.
validation
object
Publish-readiness summary.
missingFields
object[]
The gap-filling work list. Relay each prompt to your human, then patch the answer.
normalizedFields
object
The structured fields OpenTrain extracted from your input.
warnings
string[]
Non-blocking warnings.
unmappedFields
object[]
Input keys that could not be mapped — {path, reason, valuePreview}.
lowConfidenceFields
object[]
Parsed fields worth double-checking with your human — {path, reason}.
import
object
Audit echo: {format, externalId, idempotencyKey, rawSourcePreserved, autoPublished: false}.
parser
object
Parser metadata: {source, warnings, parsedFields}.

Errors

StatuscodeMeaning
400BAD_REQUESTEmpty body, invalid JSON, unsupported format (details.supportedFormats), or description over 60,000 characters
401UNAUTHORIZEDMissing or invalid token
403FORBIDDENMissing jobs:write scope (details.requiredScopes) or public_api_job_drafting disabled (details.featureKey)
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 '{
    "rawJobDescription": "We need 5 fluent Spanish speakers to label sentiment in ~20,000 social media posts. Pay per label. Annotators should have prior text-classification experience.",
    "idempotencyKey": "sentiment-es-2026-001"
  }'
{
  "ok": true,
  "jobId": "<JOB_ID>",
  "status": "DRAFT",
  "draftUrl": "https://app.opentrain.ai/job-post?jobId=<JOB_ID>",
  "reviewUrl": "https://app.opentrain.ai/job-post?jobId=<JOB_ID>",
  "validation": {
    "publishReady": false,
    "issueCount": 0,
    "missingFieldCount": 2,
    "missingFields": [],
    "issues": []
  },
  "missingFields": [
    {
      "field": "experienceLevel",
      "label": "Experience level",
      "message": "Experience level is required before publishing.",
      "code": "missing_required_field",
      "prompt": "ask: What experience level should AI trainers have? (EXPERT, ENTRY_LEVEL, INTERMEDIATE, ANY_EXPERIENCE_LEVEL)",
      "type": "enum",
      "enumValues": ["EXPERT", "ENTRY_LEVEL", "INTERMEDIATE", "ANY_EXPERIENCE_LEVEL"],
      "updateKeys": ["experienceLevel"],
      "example": "INTERMEDIATE",
      "hint": "Pick the minimum level the work realistically needs."
    },
    {
      "field": "pricePerLabel",
      "label": "Price per label",
      "message": "A pay rate is required before publishing.",
      "code": "missing_required_field",
      "prompt": "ask: How much will you pay per label (USD)?",
      "type": "number",
      "enumValues": null,
      "updateKeys": ["pricePerLabel"],
      "example": "0.05",
      "hint": null
    }
  ],
  "normalizedFields": {
    "jobTitle": "Spanish Sentiment Labeling — Social Media Posts",
    "paymentType": "PAY_PER_LABEL",
    "languages": ["Spanish"],
    "headcount": 5,
    "dataVolume": 20000,
    "dataVolumeUnit": "NUMBER_OF_FILES"
  },
  "warnings": [],
  "unmappedFields": [],
  "lowConfidenceFields": [
    { "path": "dataVolumeUnit", "reason": "Inferred NUMBER_OF_FILES from \"posts\"." }
  ],
  "import": {
    "format": "text",
    "externalId": null,
    "idempotencyKey": "sentiment-es-2026-001",
    "rawSourcePreserved": true,
    "autoPublished": false
  },
  "parser": {
    "source": "llm",
    "warnings": [],
    "parsedFields": 6
  }
}