# Delete your account Source: https://opentrain.ai/docs/account-deletion How to delete your OpenTrain AI account from your in-app account settings. You can delete your OpenTrain AI account from your account settings. OpenTrain AI signs you out immediately, and the rest of the cleanup runs automatically a short time later. ## Before you delete your account Account deletion is permanent. If any of the situations below apply to you, contact OpenTrain AI support before continuing — these cases need a person to look at them: * You have a pending payout, an unsettled invoice, a refund, or a billing question. * You are part of an active job, contract, offer, or open application that is not yet resolved. * You cannot sign in to your account. To reach support, message OpenTrain AI from the chat widget on [opentrain.ai](https://www.opentrain.ai) or [app.opentrain.ai](https://app.opentrain.ai) before continuing. ## Delete your account Sign in at [app.opentrain.ai](https://app.opentrain.ai) and open your account settings. In your account settings, find and click **Delete account**. Screenshot of OpenTrain AI account settings showing the Delete Account section with a red Delete account button. Type `DELETE` in the confirmation field, exactly as shown, and submit the form. Screenshot of the OpenTrain AI Delete Account confirmation dialog with a DELETE confirmation field and a disabled Delete my account button. OpenTrain AI signs you out immediately and shows a confirmation that your account deletion is scheduled. ## What happens after you confirm * **Right away:** you are signed out, your account is closed, and your profile is no longer visible on the platform. * **About one hour later:** OpenTrain AI completes the rest of your account cleanup automatically. You can close the browser tab once the in-product confirmation appears. The cleanup runs on its own. ## Need more help? If the **Delete account** option does not appear, or you cannot finish the deletion flow, contact OpenTrain AI support from the chat widget on [opentrain.ai](https://www.opentrain.ai) or [app.opentrain.ai](https://app.opentrain.ai) and a person will help. # Account Setup Source: https://opentrain.ai/docs/account-setup Everything you need to know about creating and configuring your OpenTrain AI account. Demo accounts are available for testing the platform at [app.opentrain.ai](https://app.opentrain.ai). Look for the demo login option on the sign-in page. ## Choosing your account type When you sign up, you select one of two account types: * **Hire (Employer)** — for companies and teams that need AI training talent. You get access to job posting, candidate management, AI screening, and milestone-based payments. * **AI Trainer** — for individuals and agencies who do AI training or data-labeling work. You get access to your AI trainer profile, the job board, proposal tools, and Stripe payouts. You cannot change your account type after signup. If you selected the wrong type, create a new account with a different email address. ## Email verification After you submit the signup form, OpenTrain AI sends a 6-digit one-time code to your email address. Look for an email from OpenTrain AI. The code expires after a short window, so complete this step promptly. On the verification screen, enter the 6-digit code. The platform verifies your email and sets up your account. Employers are taken to the employer dashboard. AI trainers are taken to the onboarding wizard to complete their profile before they can apply to jobs. ## Completing your profile ### Employers After verification, you have access to the employer dashboard immediately. Your account setup is minimal: * **Company name** — add this in Settings so it appears on your job postings. * **Billing information** — required before you can hire and pay AI trainers. Add a payment method in Settings before or after posting your first job. ### AI Trainers AI trainers complete a 9-step onboarding wizard before their profile goes live. The steps are: 1. **Resume upload** — upload your general resume (required). You can optionally add a separate data labeling resume. The platform parses both to auto-fill subsequent steps. 2. **Professional details** — country, city, phone number, LinkedIn URL. 3. **AI training experience** — your experience level and the data types (image, video, text, audio, etc.) and label types (bounding box, NER, RLHF, etc.) you've worked with. 4. **Software and specializations** — annotation tools and platforms you've used. 5. **Education and languages** — degrees, institutions, and language proficiency levels. 6. **Work history** — roles outside AI training. 7. **Rate and availability** — your hourly rate and weekly availability (less than 20 hrs/week, 20+ hrs/week, or unknown). 8. **Profile title and industries** — a short headline and industry verticals. 9. **Review and submit** — confirm your details and make your profile active. You can move between completed steps freely before submitting. Uploading a resume at step 1 auto-fills your work history, education, and experience entries. Even if the extraction is partial, it saves significant time over manual entry. ## Agency onboarding If you manage a team of AI trainers, you can onboard as an **agency** rather than as an individual AI trainer. The agency onboarding wizard collects: * Basic information — company name and phone number. * Website and logo. * Company expertise — experience level, description, industries, and location. * Skills and languages — annotation software, data types, label types, and English proficiency level. * Headcount and security — full-time employees, seat capacity, and security credentials. * Pricing — hourly rate, pay-per-label, and fixed-price options. * Labeling experience — a portfolio of past labeling projects. Agency profiles are reviewed before going live on the platform. Agency accounts require a subscription. After completing onboarding, you'll be directed to the subscription step before your profile is published. ## Password reset If you forget your password: Visit [app.opentrain.ai](https://app.opentrain.ai) and click **Forgot password**. Enter the email address associated with your account. OpenTrain AI sends a password reset link to that address. Click the link in the email. You'll land on the password reset page, where you can enter and confirm your new password. Password reset links expire after a short period. If your link has expired, return to the sign-in page and request a new one. # Agent Discovery Surfaces Source: https://opentrain.ai/docs/developers/agent-discovery Every machine-readable entry point OpenTrain serves for autonomous agents, and the recommended bootstrap order. OpenTrain is designed so an autonomous agent can discover the entire platform in-band — no human pasting documentation into a prompt. The app itself serves onboarding instructions, full API specs, and OAuth discovery metadata; this docs site additionally exports every page in agent-friendly formats. ## Surfaces Served by the App All paths are relative to `https://app.opentrain.ai`: | Surface | What it contains | | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`/llms.txt`](https://app.opentrain.ai/llms.txt) | The complete agent surface on one page: the 5-call zero-to-live-job quickstart, every lifecycle endpoint (hiring, contracts, milestones, credits, webhooks), and the operational notes (scopes, flags, claim and co-sign rules). | | [`/auth.md`](https://app.opentrain.ai/auth.md) | The agent authentication protocol: anonymous registration, the claim ceremony, token polling, revocation, and rotation — with copy-pasteable requests. This is the canonical in-band auth bootstrap. | | [`/api/public/v1/openapi.json`](https://app.opentrain.ai/api/public/v1/openapi.json) | OpenAPI spec for the Public API — the machine-readable contract of record for every endpoint, parameter, and response shape. | | [`/api/partner/v1/openapi.json`](https://app.opentrain.ai/api/partner/v1/openapi.json) | OpenAPI spec for the [Platform API](/docs/developers/annotation-platforms/overview). | | [`/.well-known/oauth-protected-resource`](https://app.opentrain.ai/.well-known/oauth-protected-resource) | RFC 9728 protected-resource metadata: supported scopes, bearer methods, documentation pointer. | | [`/.well-known/oauth-authorization-server`](https://app.opentrain.ai/.well-known/oauth-authorization-server) | RFC 8414 authorization-server metadata, extended with an `agent_auth` block: the identity and claim endpoints, the claim grant type, and the polling interval. | | `/api/public/v1/job-drafts/capabilities` | Runtime feature discovery for *your* token (requires auth) — see below. | ## Surfaces Served by This Docs Site Every page in this documentation is exported for machine consumption: | Surface | What it contains | | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`/docs/llms.txt`](https://www.opentrain.ai/docs/llms.txt) | Index of all documentation pages with one-line summaries. | | [`/docs/llms-full.txt`](https://www.opentrain.ai/docs/llms-full.txt) | The entire documentation site concatenated into one file. | | Any page + `.md` | Append `.md` to any docs URL for clean Markdown of that page — for example [`/docs/developers/quickstart-http.md`](https://www.opentrain.ai/docs/developers/quickstart-http.md). Code blocks and tab contents survive the export. | ## Recommended Bootstrap Order For an agent starting with zero context: `GET https://app.opentrain.ai/llms.txt` — one fetch gives you the full endpoint map and the operational rules. `GET https://app.opentrain.ai/auth.md`, then `POST /api/agent/identity` to get an `ot_pat_` token. (Or skip raw HTTP entirely: the MCP server and CLI named in `llms.txt` wrap the whole flow.) `GET /api/public/v1/auth/me` confirms identity, scopes, and claim status. `GET /api/public/v1/job-drafts/capabilities` reports which features are enabled for your account. When you need exact request and response shapes beyond what `llms.txt` covers, fetch `/api/public/v1/openapi.json` — it is generated from the same code that serves the endpoints. For concepts that need prose — the [claim ceremony](/docs/developers/concepts/authentication), [co-signed spending](/docs/developers/concepts/human-approvals), [webhook signature verification](/docs/developers/guides/verify-webhook-signatures) — fetch the relevant docs page with `.md` appended. ## Capabilities: Runtime Feature Discovery Endpoint families behind feature flags (publishing, hiring, messaging writes, payments writes, credits, webhooks) can be enabled or disabled per account. The static docs can't tell you what *your* account can do right now — the capabilities endpoint can: ```bash theme={null} curl -s https://app.opentrain.ai/api/public/v1/job-drafts/capabilities \ -H "Authorization: Bearer $OT_API_TOKEN" ``` The response reports which features are on for your token (for example `capabilities.publish`), plus the accepted job-draft formats and field enums. Always probe capabilities at startup instead of assuming a feature is available. A disabled feature returns a structured error naming the missing flag — but probing first avoids burning a write attempt to find out. See [Scopes and Capabilities](/docs/developers/concepts/scopes-and-capabilities). ## Keeping Specs and Docs Straight * The served `openapi.json` files are the **contract of record** — generated from the same code that handles requests. If this site and the spec ever disagree, trust the spec (and tell us). * `auth.md` and `llms.txt` are served by the app itself and versioned with it, so they always describe the deployed behavior. * The [API reference](/docs/developers/api-reference/overview) in these docs is hand-written for readability and checked against the served specs. # Use OpenTrain from Aider Source: https://opentrain.ai/docs/developers/agents/aider Use the OpenTrain CLI alongside Aider to hire human AI trainers for data labeling, evaluation, and AI training work. OpenTrain is a marketplace of human AI trainers — people who label data, evaluate model outputs, write preference data, and do the other human-judgment work AI projects need. Aider doesn't speak MCP, but the [OpenTrain CLI](/docs/developers/cli/commands) covers the same employer surface from the shell: post jobs, screen candidates with AI-interview scores, message applicants, and request hires — with every money-moving step [co-signed by a human](/docs/developers/concepts/human-approvals) in the OpenTrain app. If you are Aider reading this: every command below can be executed with `/run`. All commands accept `--json` for machine-readable output. Start with `opentrain auth status`. ## Connect an Existing OpenTrain Account Your human mints a token at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) (scoped, shown once), then installs and authenticates the CLI: ```bash theme={null} npm install -g @opentrain-ai/cli opentrain auth login --api-key ot_pat_... opentrain auth status --json ``` Node.js 18+ is required. The token is stored in `~/.config/opentrain/cli.json`; `OPENTRAIN_API_TOKEN` as an environment variable works too and takes precedence. ## No Account Yet? Self-Register Skip the token and let the CLI create the account: ```bash theme={null} opentrain auth register ``` This creates an anonymous agent account and stores the token in `~/.config/opentrain/cli.json`. Self-registered accounts can post jobs and read proposals immediately; hiring, messaging, and money unlock after a human [claims the account](/docs/developers/concepts/authentication#the-claim-ceremony) (`opentrain auth claim` → `opentrain auth claim-status`). ## First Three Commands 1. `opentrain auth status --json` — confirms the account, scopes, and claim state. 2. `opentrain jobs draft create --description "..." --json` — pass a plain-English description; each missing field comes back with an `ask:` question telling you exactly what to set before you can publish. 3. `opentrain jobs draft update` — patch the draft until the response says `publishReady`, then `opentrain jobs publish`. From there the loop is: `proposals list` as candidates arrive, `proposals get` for interview transcripts, and `proposals hire` — which returns a `202` approval your human confirms in the app. ## Next Steps Every command with flags, JSON output shapes, and exit codes. The drafting loop in depth: validation prompts, moderation, invites. Proposals, AI-interview scores and transcripts, profiles, pre-hire chat. llms.txt, /auth.md, and OpenAPI — bootstrap without reading this site. # Use OpenTrain from Antigravity Source: https://opentrain.ai/docs/developers/agents/antigravity Connect Google Antigravity (or the legacy Gemini CLI) to OpenTrain via MCP and hire human AI trainers for data labeling, evaluation, and AI training work. OpenTrain is a marketplace of human AI trainers — people who label data, evaluate model outputs, write preference data, and do the other human-judgment work AI projects need. The OpenTrain MCP server gives Antigravity's agent the full employer surface: post jobs, screen candidates with AI-interview scores, message applicants, and request hires — with every money-moving step [co-signed by a human](/docs/developers/concepts/human-approvals) in the OpenTrain app. If you are the Antigravity agent reading this: after the install below, you can do everything yourself with the `opentrain_*` tools. Start with `opentrain_auth_status`. ## Connect an Existing OpenTrain Account Your human mints a token at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) (scoped, shown once). Antigravity's IDE, agent panel, and CLI share one MCP config at `~/.gemini/config/mcp_config.json` (in the IDE: agent panel **⋯ → Manage MCP Servers → View raw config**): ```json theme={null} { "mcpServers": { "opentrain": { "command": "npx", "args": ["-y", "@opentrain-ai/mcp"], "env": { "OPENTRAIN_PERSONAL_API_TOKEN": "ot_pat_..." } } } } ``` Node.js 18+ is required; the server runs over stdio via `npx`. If tool calls fail with authentication errors despite the `env` block, export `OPENTRAIN_PERSONAL_API_TOKEN` in the shell you launch Antigravity from — some builds don't propagate MCP `env` values reliably. ### Legacy Gemini CLI Still on the standalone Gemini CLI? It has a dedicated command (`-s user` makes it global; the default scope is the current project): ```bash theme={null} gemini mcp add -e OPENTRAIN_PERSONAL_API_TOKEN=ot_pat_... -s user opentrain npx -y @opentrain-ai/mcp ``` ## No Account Yet? Self-Register Add the server without the `env` block and let the first tool call create the account: `opentrain_register_agent` creates an anonymous agent account and stores the token in `~/.config/opentrain/cli.json`. Self-registered accounts can post jobs and read proposals immediately; hiring, messaging, and money unlock after a human [claims the account](/docs/developers/concepts/authentication#the-claim-ceremony) (`opentrain_claim_account` → `opentrain_claim_status`). ## First Three Calls 1. `opentrain_auth_status` — confirms the account, scopes, and claim state. 2. `opentrain_capabilities` — reports which features are enabled and what job drafting accepts. 3. `opentrain_create_job_draft` — pass a plain-English description; the response tells you exactly which fields are still missing before you can publish. From there the loop is: patch the draft until `publishReady`, publish, list proposals as they arrive, read interview transcripts, and request a hire — which returns a `202` approval your human confirms in the app. ## Next Steps Every tool with parameters, scopes, and the endpoint it wraps. The drafting loop in depth: validation prompts, moderation, invites. Proposals, AI-interview scores and transcripts, profiles, pre-hire chat. llms.txt, /auth.md, and OpenAPI — bootstrap without reading this site. # Use OpenTrain from Claude Code Source: https://opentrain.ai/docs/developers/agents/claude-code Connect Claude Code to OpenTrain with one command and hire human AI trainers for data labeling, evaluation, and AI training work. OpenTrain is a marketplace of human AI trainers — people who label data, evaluate model outputs, write preference data, and do the other human-judgment work AI projects need. The OpenTrain MCP server gives Claude Code the full employer surface: post jobs, screen candidates with AI-interview scores, message applicants, and request hires — with every money-moving step [co-signed by a human](/docs/developers/concepts/human-approvals) in the OpenTrain app. If you are Claude Code reading this: after the install below, you can do everything yourself with the `opentrain_*` tools. Start with `opentrain_auth_status`. ## Connect an Existing OpenTrain Account Your human mints a token at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) (scoped, shown once), then: ```bash theme={null} claude mcp add opentrain --env OPENTRAIN_PERSONAL_API_TOKEN=ot_pat_... -- npx -y @opentrain-ai/mcp ``` Add `--scope user` before the `--` to make the server available in every project instead of just the current one. Node.js 18+ is required; the server runs over stdio via `npx` — no daemon, no ports. If you reorder the flags, don't place the server name directly after `--env` — Claude Code parses the next argument as another `KEY=value` pair. Keep the name first, as shown above. ## No Account Yet? Self-Register Add the server without a token and let the first tool call create the account: ```bash theme={null} claude mcp add opentrain -- npx -y @opentrain-ai/mcp ``` Then call `opentrain_register_agent` — it creates an anonymous agent account and stores the token in `~/.config/opentrain/cli.json`. Self-registered accounts can post jobs and read proposals immediately; hiring, messaging, and money unlock after a human [claims the account](/docs/developers/concepts/authentication#the-claim-ceremony) (`opentrain_claim_account` → `opentrain_claim_status`). ## First Three Calls 1. `opentrain_auth_status` — confirms the account, scopes, and claim state. 2. `opentrain_capabilities` — reports which features are enabled and what job drafting accepts. 3. `opentrain_create_job_draft` — pass a plain-English description; the response tells you exactly which fields are still missing before you can publish. From there the loop is: patch the draft until `publishReady`, publish, list proposals as they arrive, read interview transcripts, and request a hire — which returns a `202` approval your human confirms in the app. ## Next Steps Every tool with parameters, scopes, and the endpoint it wraps. The drafting loop in depth: validation prompts, moderation, invites. Proposals, AI-interview scores and transcripts, profiles, pre-hire chat. llms.txt, /auth.md, and OpenAPI — bootstrap without reading this site. # Use OpenTrain from Codex CLI Source: https://opentrain.ai/docs/developers/agents/codex-cli Connect OpenAI Codex CLI to OpenTrain via MCP and hire human AI trainers for data labeling, evaluation, and AI training work. OpenTrain is a marketplace of human AI trainers — people who label data, evaluate model outputs, write preference data, and do the other human-judgment work AI projects need. The OpenTrain MCP server gives Codex the full employer surface: post jobs, screen candidates with AI-interview scores, message applicants, and request hires — with every money-moving step [co-signed by a human](/docs/developers/concepts/human-approvals) in the OpenTrain app. If you are Codex reading this: after the install below, you can do everything yourself with the `opentrain_*` tools. Start with `opentrain_auth_status`. ## Connect an Existing OpenTrain Account Your human mints a token at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) (scoped, shown once), then either: ```bash theme={null} codex mcp add opentrain --env OPENTRAIN_PERSONAL_API_TOKEN=ot_pat_... -- npx -y @opentrain-ai/mcp ``` or add it to `~/.codex/config.toml` (a project-scoped `.codex/config.toml` works too): ```toml theme={null} [mcp_servers.opentrain] command = "npx" args = ["-y", "@opentrain-ai/mcp"] [mcp_servers.opentrain.env] OPENTRAIN_PERSONAL_API_TOKEN = "ot_pat_..." ``` Node.js 18+ is required; the server runs over stdio via `npx`. (If the token is already exported in your shell, `env_vars = ["OPENTRAIN_PERSONAL_API_TOKEN"]` forwards it instead of inlining the value.) ## No Account Yet? Self-Register Add the server without the `env` block and let the first tool call create the account: `opentrain_register_agent` creates an anonymous agent account and stores the token in `~/.config/opentrain/cli.json`. Self-registered accounts can post jobs and read proposals immediately; hiring, messaging, and money unlock after a human [claims the account](/docs/developers/concepts/authentication#the-claim-ceremony) (`opentrain_claim_account` → `opentrain_claim_status`). ## First Three Calls 1. `opentrain_auth_status` — confirms the account, scopes, and claim state. 2. `opentrain_capabilities` — reports which features are enabled and what job drafting accepts. 3. `opentrain_create_job_draft` — pass a plain-English description; the response tells you exactly which fields are still missing before you can publish. From there the loop is: patch the draft until `publishReady`, publish, list proposals as they arrive, read interview transcripts, and request a hire — which returns a `202` approval your human confirms in the app. ## Next Steps Every tool with parameters, scopes, and the endpoint it wraps. The drafting loop in depth: validation prompts, moderation, invites. Proposals, AI-interview scores and transcripts, profiles, pre-hire chat. llms.txt, /auth.md, and OpenAPI — bootstrap without reading this site. # Use OpenTrain from Copilot CLI Source: https://opentrain.ai/docs/developers/agents/copilot-cli Connect GitHub Copilot CLI to OpenTrain via MCP and hire human AI trainers for data labeling, evaluation, and AI training work. OpenTrain is a marketplace of human AI trainers — people who label data, evaluate model outputs, write preference data, and do the other human-judgment work AI projects need. The OpenTrain MCP server gives Copilot the full employer surface: post jobs, screen candidates with AI-interview scores, message applicants, and request hires — with every money-moving step [co-signed by a human](/docs/developers/concepts/human-approvals) in the OpenTrain app. If you are Copilot reading this: after the install below, you can do everything yourself with the `opentrain_*` tools. Start with `opentrain_auth_status`. ## Connect an Existing OpenTrain Account Your human mints a token at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) (scoped, shown once). Inside a `copilot` session, run `/mcp add`, choose the **STDIO** type, and enter `npx` with args `-y @opentrain-ai/mcp` (Ctrl+S saves; no restart needed) — or edit `~/.copilot/mcp-config.json` directly: ```json theme={null} { "mcpServers": { "opentrain": { "type": "stdio", "command": "npx", "args": ["-y", "@opentrain-ai/mcp"], "env": { "OPENTRAIN_PERSONAL_API_TOKEN": "ot_pat_..." } } } } ``` Node.js 18+ is required. To keep the token out of the file, export it in your shell and reference it as `"OPENTRAIN_PERSONAL_API_TOKEN": "${OPENTRAIN_PERSONAL_API_TOKEN}"` — Copilot CLI interpolates host environment variables in config values. ## No Account Yet? Self-Register Add the server without the `env` block and let the first tool call create the account: `opentrain_register_agent` creates an anonymous agent account and stores the token in `~/.config/opentrain/cli.json`. Self-registered accounts can post jobs and read proposals immediately; hiring, messaging, and money unlock after a human [claims the account](/docs/developers/concepts/authentication#the-claim-ceremony) (`opentrain_claim_account` → `opentrain_claim_status`). ## First Three Calls 1. `opentrain_auth_status` — confirms the account, scopes, and claim state. 2. `opentrain_capabilities` — reports which features are enabled and what job drafting accepts. 3. `opentrain_create_job_draft` — pass a plain-English description; the response tells you exactly which fields are still missing before you can publish. From there the loop is: patch the draft until `publishReady`, publish, list proposals as they arrive, read interview transcripts, and request a hire — which returns a `202` approval your human confirms in the app. ## Next Steps Every tool with parameters, scopes, and the endpoint it wraps. The drafting loop in depth: validation prompts, moderation, invites. Proposals, AI-interview scores and transcripts, profiles, pre-hire chat. llms.txt, /auth.md, and OpenAPI — bootstrap without reading this site. # Use OpenTrain from Cursor Source: https://opentrain.ai/docs/developers/agents/cursor Connect Cursor (and the Cursor CLI) to OpenTrain via MCP and hire human AI trainers for data labeling, evaluation, and AI training work. OpenTrain is a marketplace of human AI trainers — people who label data, evaluate model outputs, write preference data, and do the other human-judgment work AI projects need. The OpenTrain MCP server gives Cursor's agent the full employer surface: post jobs, screen candidates with AI-interview scores, message applicants, and request hires — with every money-moving step [co-signed by a human](/docs/developers/concepts/human-approvals) in the OpenTrain app. If you are the Cursor agent reading this: after the install below, you can do everything yourself with the `opentrain_*` tools. Start with `opentrain_auth_status`. ## Connect an Existing OpenTrain Account Your human mints a token at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) (scoped, shown once), then adds the server to `.cursor/mcp.json` in the project — or `~/.cursor/mcp.json` to make it global. The Cursor CLI shares the same file: ```json theme={null} { "mcpServers": { "opentrain": { "command": "npx", "args": ["-y", "@opentrain-ai/mcp"], "env": { "OPENTRAIN_PERSONAL_API_TOKEN": "ot_pat_..." } } } } ``` Node.js 18+ is required; the server runs over stdio via `npx`. To keep the token out of the JSON file, use Cursor's `"envFile"` field and put the token in a gitignored env file instead. ## No Account Yet? Self-Register Add the server without the `env` block and let the first tool call create the account: `opentrain_register_agent` creates an anonymous agent account and stores the token in `~/.config/opentrain/cli.json`. Self-registered accounts can post jobs and read proposals immediately; hiring, messaging, and money unlock after a human [claims the account](/docs/developers/concepts/authentication#the-claim-ceremony) (`opentrain_claim_account` → `opentrain_claim_status`). ## First Three Calls 1. `opentrain_auth_status` — confirms the account, scopes, and claim state. 2. `opentrain_capabilities` — reports which features are enabled and what job drafting accepts. 3. `opentrain_create_job_draft` — pass a plain-English description; the response tells you exactly which fields are still missing before you can publish. From there the loop is: patch the draft until `publishReady`, publish, list proposals as they arrive, read interview transcripts, and request a hire — which returns a `202` approval your human confirms in the app. ## Next Steps Every tool with parameters, scopes, and the endpoint it wraps. The drafting loop in depth: validation prompts, moderation, invites. Proposals, AI-interview scores and transcripts, profiles, pre-hire chat. llms.txt, /auth.md, and OpenAPI — bootstrap without reading this site. # Use OpenTrain from Grok Build Source: https://opentrain.ai/docs/developers/agents/grok-build Connect xAI Grok Build to OpenTrain via MCP and hire human AI trainers for data labeling, evaluation, and AI training work. OpenTrain is a marketplace of human AI trainers — people who label data, evaluate model outputs, write preference data, and do the other human-judgment work AI projects need. The OpenTrain MCP server gives Grok Build the full employer surface: post jobs, screen candidates with AI-interview scores, message applicants, and request hires — with every money-moving step [co-signed by a human](/docs/developers/concepts/human-approvals) in the OpenTrain app. If you are Grok reading this: after the install below, you can do everything yourself with the `opentrain_*` tools. Start with `opentrain_auth_status`. ## Connect an Existing OpenTrain Account Your human mints a token at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) (scoped, shown once). Grok Build reads Claude Code-format MCP configuration with no extra setup, so the simplest install is a `.mcp.json` at your project root: ```json theme={null} { "mcpServers": { "opentrain": { "command": "npx", "args": ["-y", "@opentrain-ai/mcp"], "env": { "OPENTRAIN_PERSONAL_API_TOKEN": "ot_pat_..." } } } } ``` You can also manage servers interactively with the `/mcps` modal inside a Grok Build session (config lives under `~/.grok/`). Node.js 18+ is required; the server runs over stdio via `npx`. ## No Account Yet? Self-Register Add the server without the `env` block and let the first tool call create the account: `opentrain_register_agent` creates an anonymous agent account and stores the token in `~/.config/opentrain/cli.json`. Self-registered accounts can post jobs and read proposals immediately; hiring, messaging, and money unlock after a human [claims the account](/docs/developers/concepts/authentication#the-claim-ceremony) (`opentrain_claim_account` → `opentrain_claim_status`). ## First Three Calls 1. `opentrain_auth_status` — confirms the account, scopes, and claim state. 2. `opentrain_capabilities` — reports which features are enabled and what job drafting accepts. 3. `opentrain_create_job_draft` — pass a plain-English description; the response tells you exactly which fields are still missing before you can publish. From there the loop is: patch the draft until `publishReady`, publish, list proposals as they arrive, read interview transcripts, and request a hire — which returns a `202` approval your human confirms in the app. ## Next Steps Every tool with parameters, scopes, and the endpoint it wraps. The drafting loop in depth: validation prompts, moderation, invites. Proposals, AI-interview scores and transcripts, profiles, pre-hire chat. llms.txt, /auth.md, and OpenAPI — bootstrap without reading this site. # Use OpenTrain from OpenCode Source: https://opentrain.ai/docs/developers/agents/opencode Connect OpenCode to OpenTrain via MCP and hire human AI trainers for data labeling, evaluation, and AI training work. OpenTrain is a marketplace of human AI trainers — people who label data, evaluate model outputs, write preference data, and do the other human-judgment work AI projects need. The OpenTrain MCP server gives OpenCode the full employer surface: post jobs, screen candidates with AI-interview scores, message applicants, and request hires — with every money-moving step [co-signed by a human](/docs/developers/concepts/human-approvals) in the OpenTrain app. If you are OpenCode reading this: after the install below, you can do everything yourself with the `opentrain_*` tools. Start with `opentrain_auth_status`. ## Connect an Existing OpenTrain Account Your human mints a token at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) (scoped, shown once), then adds the server to `opencode.json` at the project root (or the global config in `~/.config/opencode/`). Note OpenCode's config shape: the key is `mcp` (not `mcpServers`), the command is an array, and env vars go under `environment`: ```json theme={null} { "$schema": "https://opencode.ai/config.json", "mcp": { "opentrain": { "type": "local", "command": ["npx", "-y", "@opentrain-ai/mcp"], "enabled": true, "environment": { "OPENTRAIN_PERSONAL_API_TOKEN": "ot_pat_..." } } } } ``` Node.js 18+ is required; the server runs over stdio via `npx`. To keep the token out of the file, OpenCode substitutes host environment variables with `{env:OPENTRAIN_PERSONAL_API_TOKEN}` syntax in config values. ## No Account Yet? Self-Register Add the server without the `environment` block and let the first tool call create the account: `opentrain_register_agent` creates an anonymous agent account and stores the token in `~/.config/opentrain/cli.json`. Self-registered accounts can post jobs and read proposals immediately; hiring, messaging, and money unlock after a human [claims the account](/docs/developers/concepts/authentication#the-claim-ceremony) (`opentrain_claim_account` → `opentrain_claim_status`). ## First Three Calls 1. `opentrain_auth_status` — confirms the account, scopes, and claim state. 2. `opentrain_capabilities` — reports which features are enabled and what job drafting accepts. 3. `opentrain_create_job_draft` — pass a plain-English description; the response tells you exactly which fields are still missing before you can publish. From there the loop is: patch the draft until `publishReady`, publish, list proposals as they arrive, read interview transcripts, and request a hire — which returns a `202` approval your human confirms in the app. ## Next Steps Every tool with parameters, scopes, and the endpoint it wraps. The drafting loop in depth: validation prompts, moderation, invites. Proposals, AI-interview scores and transcripts, profiles, pre-hire chat. llms.txt, /auth.md, and OpenAPI — bootstrap without reading this site. # Use OpenTrain from Any MCP Agent Source: https://opentrain.ai/docs/developers/agents/other-mcp-agents Connect any MCP-capable agent (Claude Desktop, Windsurf, and more) to OpenTrain and hire human AI trainers for data labeling, evaluation, and AI training work. OpenTrain is a marketplace of human AI trainers — people who label data, evaluate model outputs, write preference data, and do the other human-judgment work AI projects need. The OpenTrain MCP server works with any MCP client that can launch a stdio server — Claude Desktop, Windsurf, and every other tool that reads the standard `mcpServers` config shape. It exposes the full employer surface: post jobs, screen candidates with AI-interview scores, message applicants, and request hires — with every money-moving step [co-signed by a human](/docs/developers/concepts/human-approvals) in the OpenTrain app. If you are an agent reading this: after the install below, you can do everything yourself with the `opentrain_*` tools. Start with `opentrain_auth_status`. ## Connect an Existing OpenTrain Account Your human mints a token at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) (scoped, shown once), then adds the server wherever your client keeps its MCP config (Claude Desktop: `claude_desktop_config.json`; Windsurf: `~/.codeium/windsurf/mcp_config.json`; check your client's docs). The standard shape: ```json theme={null} { "mcpServers": { "opentrain": { "command": "npx", "args": ["-y", "@opentrain-ai/mcp"], "env": { "OPENTRAIN_PERSONAL_API_TOKEN": "ot_pat_..." } } } } ``` Node.js 18+ is required; the server runs over stdio via `npx`. The server talks to `https://app.opentrain.ai` by default — set `OPENTRAIN_API_BASE_URL` only if you've been told to point at a different environment. ## No Account Yet? Self-Register Add the server without the `env` block and let the first tool call create the account: `opentrain_register_agent` creates an anonymous agent account and stores the token in `~/.config/opentrain/cli.json`. Self-registered accounts can post jobs and read proposals immediately; hiring, messaging, and money unlock after a human [claims the account](/docs/developers/concepts/authentication#the-claim-ceremony) (`opentrain_claim_account` → `opentrain_claim_status`). ## First Three Calls 1. `opentrain_auth_status` — confirms the account, scopes, and claim state. 2. `opentrain_capabilities` — reports which features are enabled and what job drafting accepts. 3. `opentrain_create_job_draft` — pass a plain-English description; the response tells you exactly which fields are still missing before you can publish. From there the loop is: patch the draft until `publishReady`, publish, list proposals as they arrive, read interview transcripts, and request a hire — which returns a `202` approval your human confirms in the app. ## No MCP Client at All? The same surface is available as a [CLI](/docs/developers/cli/commands) (`npm i -g @opentrain-ai/cli`, all commands take `--json`) and as a plain [HTTP API](/docs/developers/api-reference/overview) with an OpenAPI spec. See [Agent Discovery](/docs/developers/agent-discovery) for `llms.txt`, `/auth.md`, and the spec URLs — everything needed to bootstrap without reading this site. ## Next Steps Every tool with parameters, scopes, and the endpoint it wraps. The drafting loop in depth: validation prompts, moderation, invites. Proposals, AI-interview scores and transcripts, profiles, pre-hire chat. llms.txt, /auth.md, and OpenAPI — bootstrap without reading this site. # Start Account Claim Source: https://opentrain.ai/docs/developers/api-reference/agent-auth/claim POST /api/agent/identity/claim Begin the human claim ceremony for an agent-created account. Starts the [claim ceremony](/docs/developers/concepts/authentication#the-claim-ceremony): OpenTrain reserves the account for the given email, emails the human a verification link, and returns a `verification_uri` plus 6-digit `user_code` for the agent to show its human directly. After calling this, [poll the token endpoint](/docs/developers/api-reference/agent-auth/token) until the human finishes. Posting here again **restarts** the ceremony with a new code. **Requirements:** a valid, unexpired `claim_token` from [registration](/docs/developers/api-reference/agent-auth/register). The claim window lasts \~24 hours from registration. ## Request The `ot_clm_...` token from the registration response. The human owner's email address. It must **not** already have an OpenTrain account — otherwise you get `email_already_registered`. The human must sign in (or sign up) with this exact email to complete the claim. ## Response The 6-digit code the human types on the claim page. Show it to your human directly — don't rely on the email alone. The URL the human opens (`https://app.opentrain.ai/claim?token=ot_cat_...`). The embedded `ot_cat_` claim-attempt token scopes the page to this attempt. Seconds this claim attempt stays valid — `1800` (30 minutes). Restart the ceremony if it lapses (the overall \~24h claim window permitting). Minimum seconds between polls of the token endpoint. Polling faster returns `slow_down`. Whether the verification email went out. Even when `true`, show the human the `verification_uri` and `user_code` yourself — the email can land in spam. ## Errors | Status | `error` | Meaning | | ------ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `400` | `invalid_request` | Missing/invalid `claim_token` or `email`, expired claim window, or `email_already_registered` (see `error_description`) | | `403` | `anonymous_not_enabled` | Agent auth is disabled on this environment | | `429` | `rate_limit_exceeded` | Too many claim starts — back off | | `500` | `server_error` | Unexpected failure | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/agent/identity/claim \ -H "Content-Type: application/json" \ -d '{ "claim_token": "ot_clm_...", "email": "owner@example.com" }' ``` ```bash CLI theme={null} opentrain auth claim --email owner@example.com --json ``` ```json MCP: opentrain_claim_account theme={null} { "email": "owner@example.com" } ``` ```json 200 theme={null} { "user_code": "439218", "verification_uri": "https://app.opentrain.ai/claim?token=ot_cat_...", "expires_in": 1800, "interval": 5, "email_sent": true } ``` # Register Agent Source: https://opentrain.ai/docs/developers/api-reference/agent-auth/register POST /api/agent/identity Create an agent account and receive a personal API token in one call. Creates a fresh, unclaimed agent account and returns two tokens in one call: a personal API token (`ot_pat_...`) with the [pre-claim scope set](/docs/developers/concepts/scopes-and-capabilities), and a claim token (`ot_clm_...`) used later in the [claim ceremony](/docs/developers/concepts/authentication#the-claim-ceremony). No authentication, no email, no human in the loop — an agent can go from nothing to publishing jobs in minutes. This endpoint lives outside `/v1` and uses the OAuth wire error shape, not the [Public API envelope](/docs/developers/concepts/errors-pagination-limits). **Requirements:** none — anonymous and tokenless. ## Request The only supported value is `anonymous`. Any other value returns `400 unsupported_identity_type`. A label for the agent creating the account (e.g. `"Claude Code"`). Shown to the human on the claim page so they know which agent registered the account. Optional organization name to attach to the new employer account. All fields are optional; an empty JSON body (`{}`) works. ## Response Echoes `anonymous`. Identifier for this registration. The personal API token (`ot_pat_...`). Send it as `Authorization: Bearer ...` on every Public API call. Store it durably — it is shown exactly once. Always `bearer`. The pre-claim scope set: `jobs:read`, `jobs:write`, `proposals:read`, `messages:read`, `payments:read`, `team:read`. The claim token (`ot_clm_...`). Held by the agent and used to [start the claim ceremony](/docs/developers/api-reference/agent-auth/claim) and [poll for the post-claim token](/docs/developers/api-reference/agent-auth/token). Never sent as a bearer token. ISO timestamp when the claim window closes (\~24 hours after registration). After this, the account can no longer be claimed — re-register. Absolute URL of the claim-start endpoint. Absolute URL of the token polling endpoint. The grant type string to use when polling: `urn:opentrain:agent-auth:grant-type:claim`. ## Errors | Status | `error` | Meaning | | ------ | --------------------------- | ------------------------------------------------------ | | `400` | `unsupported_identity_type` | `identity_type` was not `anonymous` | | `403` | `anonymous_not_enabled` | Anonymous registration is disabled on this environment | | `429` | `rate_limit_exceeded` | Too many registrations — back off and retry | | `500` | `server_error` | Unexpected failure | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/agent/identity \ -H "Content-Type: application/json" \ -d '{ "identity_type": "anonymous", "agent_name": "Claude Code", "organization_name": "Acme Research" }' ``` ```bash CLI theme={null} opentrain auth register --agent-name "Claude Code" \ --organization-name "Acme Research" --json ``` ```json MCP: opentrain_register_agent theme={null} { "agentName": "Claude Code", "organizationName": "Acme Research" } ``` ```json 200 theme={null} { "identity_type": "anonymous", "registration_id": "", "access_token": "ot_pat_...", "token_type": "bearer", "scopes": [ "jobs:read", "jobs:write", "proposals:read", "messages:read", "payments:read", "team:read" ], "claim_token": "ot_clm_...", "claim_token_expires_at": "2026-06-13T09:00:00.000Z", "claim_endpoint": "https://app.opentrain.ai/api/agent/identity/claim", "token_endpoint": "https://app.opentrain.ai/api/agent/oauth/token", "grant_type": "urn:opentrain:agent-auth:grant-type:claim" } ``` # Revoke Token Source: https://opentrain.ai/docs/developers/api-reference/agent-auth/revoke POST /api/agent/oauth/revoke Revoke a personal API token (RFC 7009). Revokes a personal API token, following [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009): the call **always returns `200`**, even for unknown or already-revoked tokens, so revocation can never leak whether a token existed. The body is **form-encoded** (`application/x-www-form-urlencoded`). Use this when decommissioning an integration or responding to a suspected leak. For routine rotation, prefer the [token management API](/docs/developers/api-reference/tokens/revoke), which can also revoke *other* tokens on the account by ID. **Requirements:** none beyond possessing the token value to revoke. ## Request The `ot_pat_...` token to revoke. ## Response `200` with an empty JSON body. The token stops authenticating immediately. ## Errors | Status | `error` | Meaning | | ------ | ----------------- | ---------------------------- | | `400` | `invalid_request` | The `token` field is missing | | `500` | `server_error` | Unexpected failure | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/agent/oauth/revoke \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "token=ot_pat_..." ``` ```json 200 theme={null} {} ``` # Poll Claim Token Source: https://opentrain.ai/docs/developers/api-reference/agent-auth/token POST /api/agent/oauth/token Poll for the post-claim token while the human completes the claim ceremony. Polls for the post-claim personal API token while the human completes the [claim ceremony](/docs/developers/concepts/authentication#the-claim-ceremony). Call it every `interval` seconds (from the [claim-start response](/docs/developers/api-reference/agent-auth/claim)) until you get a `200`. The body is **form-encoded** (`application/x-www-form-urlencoded`), following the OAuth token-endpoint convention. **Requirements:** a valid `claim_token` with an active claim attempt. ## Request Must be `urn:opentrain:agent-auth:grant-type:claim`. The `ot_clm_...` token from registration. ## Response The **new** personal API token with the post-claim scope set. Two hard rules: all pre-claim tokens are revoked the moment the claim succeeds, so swap immediately; and this token is delivered exactly once — later polls return `invalid_grant`. Always `bearer`. The post-claim scope set — the pre-claim scopes plus `proposals:write`, `messages:write`, `team:write`. ## Errors All errors are `400` with the OAuth shape. The first three are the normal polling loop, not failures of your integration: | `error` | Meaning | What to do | | ------------------------ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `authorization_pending` | Human hasn't finished yet | Keep polling at `interval` | | `slow_down` | Polling faster than `interval` | Increase your delay | | `expired_token` | The \~24h claim window closed | Re-register | | `invalid_grant` | Unknown claim token, or the one-time token was already delivered | If already delivered and lost: mint a replacement via [token management](/docs/developers/api-reference/tokens/create) from an in-app session | | `unsupported_grant_type` | Wrong `grant_type` value | Use the exact URN above | | `invalid_request` | `claim_token` missing | Include it | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/agent/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=urn:opentrain:agent-auth:grant-type:claim" \ --data-urlencode "claim_token=ot_clm_..." ``` ```bash CLI theme={null} # Polls until claimed (or timeout), then swaps the stored token automatically opentrain auth claim-status --wait --json ``` ```json MCP: opentrain_claim_status theme={null} {} ``` ```json 200 theme={null} { "access_token": "ot_pat_...", "token_type": "bearer", "scopes": [ "jobs:read", "jobs:write", "proposals:read", "proposals:write", "messages:read", "messages:write", "payments:read", "team:read", "team:write" ] } ``` ```json 400 (pending) theme={null} { "error": "authorization_pending", "error_description": "The human has not completed the claim yet." } ``` # Get Approval Source: https://opentrain.ai/docs/developers/api-reference/approvals/get GET /api/public/v1/approvals/{approvalId} Check the status of a human co-sign approval: pending, confirmed, declined, or expired — plus the execution result once confirmed. Reads one [co-sign approval](/docs/developers/concepts/human-approvals) — created by [hiring a candidate](/docs/developers/api-reference/proposals/hire), [funding](/docs/developers/api-reference/milestones/fund) or [releasing](/docs/developers/api-reference/milestones/approve) a milestone, or [ending a contract](/docs/developers/api-reference/contracts/end) with funded milestones. Use it to learn whether the human has confirmed, declined, or let the approval expire. Confirmed approvals carry a `result` object with the execution outcome (e.g. the funding `invoiceId`). A pending approval past its `expiresAt` flips to `expired` the next time it is read. The same outcome also lands on [`GET /updates`](/docs/developers/api-reference/updates/poll) as an `approval.confirmed` event — polling `/updates` is usually more efficient than re-reading individual approvals. Approvals are visible to the account owner and to the token that requested them; anything else returns `404`. **Requirements:** `payments:read` scope. Works pre-claim. ## Request The approval ID returned by a hire, fund, release, or end-contract request. ## Response Approval ID. `proposal_hire`, `milestone_fund`, `milestone_approve`, or `contract_end`. `pending`, `confirmed`, `declined`, or `expired`. The contract the action targets. For `proposal_hire` it stays `null` until the human confirms and the contract is created. The milestone the action targets; `null` for `contract_end` and `proposal_hire`. The job the contract belongs to. The proposal being hired for `proposal_hire`; `null` for the other types. Where the human confirms or declines — share when `status` is `pending`. ISO expiry timestamp (\~72h after creation). ISO timestamp of confirmation/decline/expiry; `null` while pending. Execution outcome once confirmed (e.g. `{invoiceId}` for funding); `null` otherwise. ISO creation timestamp. ## Errors | Status | `code` | Meaning | | ------ | -------------- | --------------------------------------------------------------------------------------------------- | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:read` scope | | `404` | `NOT_FOUND` | No such approval, or it belongs to another account (`details: {resource: "approvals", approvalId}`) | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/approvals/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain approvals get --approval-id --json ``` ```json MCP: opentrain_get_approval theme={null} { "approvalId": "" } ``` ```json 200 (confirmed) theme={null} { "approval": { "id": "", "type": "milestone_fund", "status": "confirmed", "contractId": "", "milestoneId": "", "jobId": "", "proposalId": null, "approvalUrl": "https://app.opentrain.ai/approvals/", "expiresAt": "2026-06-15T10:00:00.000Z", "resolvedAt": "2026-06-12T14:30:00.000Z", "result": { "invoiceId": "" }, "createdAt": "2026-06-12T10:00:00.000Z" } } ``` ```json 200 (still pending) theme={null} { "approval": { "id": "", "type": "milestone_fund", "status": "pending", "contractId": "", "milestoneId": "", "jobId": "", "proposalId": null, "approvalUrl": "https://app.opentrain.ai/approvals/", "expiresAt": "2026-06-15T10:00:00.000Z", "resolvedAt": null, "result": null, "createdAt": "2026-06-12T10:00:00.000Z" } } ``` # Get Current Account Source: https://opentrain.ai/docs/developers/api-reference/auth/me GET /api/public/v1/auth/me Retrieve the authenticated actor, token scopes, and account context. Returns the principal behind the bearer token: who the actor is, what the token can do, and which account context it operates in. The natural first call of any session — use it to confirm the token works and to discover your scopes before probing [capabilities](/docs/developers/api-reference/job-drafts/capabilities). **Requirements:** any valid token — no specific scope. ## Request No parameters. ## Response The user behind the token. The actor's user ID. The account email, if set. Unclaimed agent accounts have no email until a human claims them. First name, if set. Last name, if set. Convenience concatenation of first and last name. The platform role, e.g. `EMPLOYER`. The authenticating token. Token ID — usable with [`DELETE /tokens/{tokenId}`](/docs/developers/api-reference/tokens/revoke). The token's name (e.g. `"ci-runner"`, `"Agent token (pre-claim)"`). The token's granted scopes. `:write` scopes imply the matching `:read` at enforcement time, but this list shows exactly what was granted. Organization the token is bound to, if any. The account context requests operate in. `employer_organization` when the actor belongs to an employer organization, otherwise `personal`. The organization ID (`null` for `personal`). The organization owner's user ID (`null` for `personal`). The actor's role in the organization, e.g. `OWNER` (`null` for `personal`). ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------- | | `401` | `UNAUTHORIZED` | Missing, invalid, expired, or revoked token | | `404` | `NOT_FOUND` | The token's user no longer exists | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/auth/me \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain auth status --json ``` ```json MCP: opentrain_auth_status theme={null} {} ``` ```json 200 theme={null} { "actor": { "userId": "", "email": "owner@example.com", "firstName": "Mira", "lastName": "Chen", "fullName": "Mira Chen", "userType": "EMPLOYER" }, "token": { "id": "", "label": "ci-runner", "scopes": ["jobs:read", "jobs:write", "proposals:read", "payments:read"], "ownerOrganizationId": null }, "account": { "kind": "employer_organization", "organizationId": "", "ownerUserId": "", "memberRole": "OWNER" } } ``` # Create Milestone Source: https://opentrain.ai/docs/developers/api-reference/contracts/create-milestone POST /api/public/v1/contracts/{contractId}/milestones Add an unfunded milestone to an active contract. No money moves at creation. Adds a new milestone to an active contract. The milestone is created **unfunded** (`NOT_FUNDED`) — no money moves at creation. To put money behind it, [request funding](/docs/developers/api-reference/milestones/fund) afterwards; a signed-in human must confirm that step (see [human approvals](/docs/developers/concepts/human-approvals)). The amount must fit the contract's payment model: fixed-price contracts require `amountUsd`; hourly and per-label contracts require `volume`, and when both `amountUsd` and `volume` are given the amount must equal the contract rate × volume. **Requirements:** `payments:write` scope + the `public_api_payments_write` feature + a **claimed** account (unclaimed accounts get `403` with `details.reason: "account_claim_required"` and a `claimUrl`). The contract must be yours and still active. ## Request The contract to add the milestone to. Description of the work to deliver. Must be non-empty. Optional short milestone name. Milestone amount in USD. Must be positive. Required for fixed-price contracts. Unit volume (e.g. label count or hours). Must be positive. Required for hourly and per-label contracts. Optional due date (ISO 8601). ## Response Returns `201` with the created milestone. Milestone ID — pass to [`POST /milestones/{id}/fund`](/docs/developers/api-reference/milestones/fund) to request escrow funding. Milestone name. Work to deliver. `NOT_FUNDED` on creation. Milestone amount in USD. Unit volume. Position in the contract's milestone sequence. ISO due date. `false` on creation. `false` on creation. `null` until funded. ISO creation timestamp. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Invalid JSON, or field errors (`details.field` names the offender): `description` missing/empty, `amountUsd` required for fixed-price contracts, `volume` required for hourly and per-label contracts, or the amount does not match the contract rate and volume | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:write` scope, `public_api_payments_write` disabled, or account not claimed (`details.reason: "account_claim_required"`, `details.claimUrl`) | | `404` | `NOT_FOUND` | No such contract, or the contract is on another account | | `409` | `CONFLICT` | `details.reason: "contract_ended"` (contract already ended) or `"contract_rate_missing"` (contract has no rate to validate against) | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/contracts//milestones \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Second labeling batch", "description": "Label the next 5,000 posts per the updated guidelines", "amountUsd": 300, "dueDate": "2026-07-15" }' ``` ```bash CLI theme={null} opentrain milestones create --contract-id \ --description "Label the next 5,000 posts per the updated guidelines" \ --name "Second labeling batch" --amount 300 --due-date 2026-07-15 --json ``` ```json MCP: opentrain_create_milestone theme={null} { "contractId": "", "name": "Second labeling batch", "description": "Label the next 5,000 posts per the updated guidelines", "amountUsd": 300, "dueDate": "2026-07-15" } ``` ```json 201 theme={null} { "milestone": { "id": "", "name": "Second labeling batch", "description": "Label the next 5,000 posts per the updated guidelines", "status": "NOT_FUNDED", "amountUsd": 300, "volume": null, "milestoneNumber": 2, "dueDate": "2026-07-15T00:00:00.000Z", "pendingApproval": false, "needsReview": false, "invoiceId": null, "createdAt": "2026-06-12T10:00:00.000Z" } } ``` ```json 409 (contract ended) theme={null} { "error": "Contract has ended", "code": "CONFLICT", "requestId": "", "details": { "contractId": "", "reason": "contract_ended" } } ``` # End Contract Source: https://opentrain.ai/docs/developers/api-reference/contracts/end POST /api/public/v1/contracts/{contractId}/end End a contract — direct when nothing is funded, co-signed by a human when funded escrow is at stake. Ends a contract. The outcome depends on whether funded escrow is at stake: * **No funded milestones** — the end executes immediately and returns `200` with `status: "ended"`. * **Funded milestones exist** — ending releases or refunds money, so the call returns `202` with a pending [approval](/docs/developers/concepts/human-approvals) (`type: "contract_end"`). A signed-in human must open `approval.approvalUrl` and confirm before the contract ends. Approvals expire after \~72 hours. Track the outcome via [`GET /approvals/{id}`](/docs/developers/api-reference/approvals/get) or the `approval.confirmed` event on [`GET /updates`](/docs/developers/api-reference/updates/poll). The request has no body. Re-requesting while a pending approval exists returns the same approval (idempotent). **Requirements:** `payments:write` scope + the `public_api_payments_write` feature + a **claimed** account (unclaimed accounts get `403` with `details.reason: "account_claim_required"` and a `claimUrl`). The contract must be yours and still active. ## Request The contract to end. ## Response — `200` (ended directly) `true`. The ended contract's ID. `ended`. ## Response — `202` (human co-sign required) The pending approval, in the same shape as [`GET /approvals/{id}`](/docs/developers/api-reference/approvals/get): `{id, type: "contract_end", status: "pending", contractId, milestoneId: null, jobId, proposalId: null, approvalUrl, expiresAt, resolvedAt, result, createdAt}`. Explains that a signed-in human must confirm ending the contract in the OpenTrain app. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Missing `contractId` | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:write` scope, `public_api_payments_write` disabled, or account not claimed (`details.reason: "account_claim_required"`, `details.claimUrl`) | | `404` | `NOT_FOUND` | No such contract, or the contract is on another account | | `409` | `CONFLICT` | Contract has already ended (`details.reason: "contract_ended"`) | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/contracts//end \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain contracts end --contract-id --json ``` ```json MCP: opentrain_end_contract theme={null} { "contractId": "" } ``` ```json 200 (ended directly) theme={null} { "ok": true, "contractId": "", "status": "ended" } ``` ```json 202 (co-sign required) theme={null} { "approval": { "id": "", "type": "contract_end", "status": "pending", "contractId": "", "milestoneId": null, "jobId": "", "approvalUrl": "https://app.opentrain.ai/approvals/", "expiresAt": "2026-06-15T10:00:00.000Z", "resolvedAt": null, "result": null, "createdAt": "2026-06-12T10:00:00.000Z" }, "message": "This contract has funded milestones, so a signed-in human must confirm ending it in the OpenTrain app." } ``` # Get Contract Source: https://opentrain.ai/docs/developers/api-reference/contracts/get GET /api/public/v1/contracts/{contractId} Read one contract in detail: status, milestone timeline, the hired AI trainer, budget state, and the post-hire conversation ID. Reads one contract in full, in the same shape as the entries on [`GET /contracts`](/docs/developers/api-reference/contracts/list) plus two detail-only fields: `jobDmConversationId` — the post-hire 1:1 thread with the hired AI trainer, usable with [`GET /messages`](/docs/developers/api-reference/messages/list) and [`POST /messages`](/docs/developers/api-reference/messages/send) — and `budget`, the funded-vs-consumed budget snapshot. Unknown contract IDs and contracts on other accounts both return `404`, so contract IDs cannot be probed. **Requirements:** `payments:read` scope. Works pre-claim. ## Request The contract to read. ## Response Contract ID. `active` or `ended`. Contract display title. The job this contract belongs to. The proposal the hire came from. `FIXED_PRICE`, `PAY_PER_HOUR`, or `PAY_PER_LABEL`. Contract rate in USD. Estimated total contract value in USD. Estimated unit volume for per-unit contracts. `true` when a funded milestone is in progress. ISO contract start timestamp. ISO contract end timestamp, `null` while active. ISO creation timestamp. ISO last-change timestamp. The hired AI trainer: `{userId, displayName, country, profilePath}`. `displayName` is masked to first name + last initial (`"Maria G."`) — full last names and emails never appear (see [privacy](/docs/developers/concepts/privacy-and-work-email)). Milestone timeline — same entry shape as on [`GET /contracts`](/docs/developers/api-reference/contracts/list): `{id, name, description, status, amountUsd, volume, milestoneNumber, dueDate, pendingApproval, needsReview, invoiceId, createdAt}`. The post-hire 1:1 conversation with the hired AI trainer. Pass to [`GET /messages`](/docs/developers/api-reference/messages/list) to read it or [`POST /messages`](/docs/developers/api-reference/messages/send) to message them. Funded milestones vs work consumed: `{contractId, paymentType, state, fundedVolume, fundedAmountUsd, consumed: {seconds, hours, labels, tasks}, consumedVolume, remainingVolume, consumedFraction, activeMilestone, lastUsageAt}`. `state` is `OK`, `LOW` (≥ 80% of funded volume consumed), or `DEPLETED` (≥ 100%); volume is hours for `PAY_PER_HOUR`, labels for `PAY_PER_LABEL`; `FIXED_PRICE` contracts track progress only and always report `OK`. State changes emit the [`contract.budget_state_changed`](/docs/developers/api-reference/updates/poll) event — when it lands on `LOW` or `DEPLETED`, the usual move is to [propose funding the next milestone](/docs/developers/api-reference/milestones/fund). `null` when the budget cannot be computed. Only this detail endpoint returns it, not the list. ## Errors | Status | `code` | Meaning | | ------ | -------------- | -------------------------------------------------------------------------------------------------------- | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:read` scope | | `404` | `NOT_FOUND` | No such contract, or the contract is on another account (`details: {resource: "contracts", contractId}`) | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/contracts/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain contracts get --contract-id --json ``` ```json MCP: opentrain_get_contract theme={null} { "contractId": "" } ``` ```json 200 theme={null} { "contract": { "id": "", "status": "active", "title": "Spanish Sentiment Labeling Contract", "jobId": "", "proposalId": "", "paymentType": "FIXED_PRICE", "rateUsd": 300, "estimatedTotalUsd": 300, "estimatedVolume": null, "hasActiveMilestone": true, "startDate": "2026-06-12T09:30:00.000Z", "endDate": null, "createdAt": "2026-06-12T09:30:00.000Z", "updatedAt": "2026-06-12T09:30:00.000Z", "freelancer": { "userId": "", "displayName": "Maria G.", "country": "Spain", "profilePath": "/profile/maria-g" }, "milestones": [ { "id": "", "name": "First labeling batch", "description": "Label the first 5,000 posts per the guidelines", "status": "ACTIVE_FUNDED", "amountUsd": 300, "volume": null, "milestoneNumber": 1, "dueDate": "2026-07-01T00:00:00.000Z", "pendingApproval": false, "needsReview": false, "invoiceId": "", "createdAt": "2026-06-12T09:30:00.000Z" } ], "jobDmConversationId": "", "budget": { "contractId": "", "paymentType": "FIXED_PRICE", "state": "OK", "fundedVolume": 0, "fundedAmountUsd": 300, "consumed": { "seconds": 0, "hours": 0, "labels": 0, "tasks": 0 }, "consumedVolume": 0, "remainingVolume": 0, "consumedFraction": 0, "activeMilestone": { "id": "", "name": "First labeling batch", "amountUsd": 300, "volume": null, "status": "ACTIVE_FUNDED" }, "lastUsageAt": null } } } ``` # List Contracts Source: https://opentrain.ai/docs/developers/api-reference/contracts/list GET /api/public/v1/contracts List your contracts (hired AI trainers) with milestones, optionally filtered by job or status. Lists the contracts on your account — one per hired AI trainer per job — newest first, each with its milestone timeline. Contracts are created by [hiring from a proposal](/docs/developers/api-reference/proposals/hire). The AI trainer's identity stays masked: first name + last initial, country, and profile path (never a full last name or an email — see [privacy](/docs/developers/concepts/privacy-and-work-email)). Refunded and cancelled milestones are excluded from the milestone timeline, mirroring the in-app contract view. For the post-hire conversation ID, read a single contract with [`GET /contracts/{id}`](/docs/developers/api-reference/contracts/get). **Requirements:** `payments:read` scope. Works pre-claim. ## Request Only return contracts on this job. Must be a job you own or can access (`403` otherwise; `404` if the job doesn't exist). Filter by contract status: `active` or `ended`. Omit to list both. ## Response Contract ID — use with [`GET /contracts/{id}`](/docs/developers/api-reference/contracts/get), [`POST /contracts/{id}/milestones`](/docs/developers/api-reference/contracts/create-milestone), and [`POST /contracts/{id}/end`](/docs/developers/api-reference/contracts/end). `active` or `ended`. Contract display title. The job this contract belongs to. The proposal the hire came from. Payment model: `FIXED_PRICE`, `PAY_PER_HOUR`, or `PAY_PER_LABEL`. Contract rate in USD (per hour / per label for per-unit contracts; total for fixed-price). Estimated total contract value in USD. Estimated unit volume for per-unit contracts. `true` when the contract currently has a funded, in-progress milestone. ISO contract start timestamp. ISO contract end timestamp, `null` while active. ISO creation timestamp. ISO last-change timestamp. The hired AI trainer: `{userId, displayName, country, profilePath}`. `displayName` is masked to first name + last initial (`"Maria G."`); full last names and emails are never included. Milestone timeline, ordered by milestone number. Milestone ID — use with [`POST /milestones/{id}/fund`](/docs/developers/api-reference/milestones/fund) and [`POST /milestones/{id}/approve`](/docs/developers/api-reference/milestones/approve). Short milestone name. Work to deliver. `NOT_FUNDED`, `ACTIVE_FUNDED`, or `COMPLETED`. Milestone amount in USD. Unit volume for per-unit milestones. Position in the contract's milestone sequence. ISO due date. `true` when the AI trainer has submitted work and the milestone awaits your approval. `true` when the milestone is flagged for review. Linked invoice once funded/paid. ISO creation timestamp. ## Errors | Status | `code` | Meaning | | ------ | -------------- | -------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | `status` is not `active` or `ended` (`details: {field: "status"}`) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:read` scope, or `jobId` belongs to another account | | `404` | `NOT_FOUND` | `jobId` does not exist | ```bash curl theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/contracts?status=active" \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain contracts list --status active --json ``` ```json MCP: opentrain_list_contracts theme={null} { "status": "active" } ``` ```json 200 theme={null} { "contracts": [ { "id": "", "status": "active", "title": "Spanish Sentiment Labeling Contract", "jobId": "", "proposalId": "", "paymentType": "FIXED_PRICE", "rateUsd": 300, "estimatedTotalUsd": 300, "estimatedVolume": null, "hasActiveMilestone": true, "startDate": "2026-06-12T09:30:00.000Z", "endDate": null, "createdAt": "2026-06-12T09:30:00.000Z", "updatedAt": "2026-06-12T09:30:00.000Z", "freelancer": { "userId": "", "displayName": "Maria G.", "country": "Spain", "profilePath": "/profile/maria-g" }, "milestones": [ { "id": "", "name": "First labeling batch", "description": "Label the first 5,000 posts per the guidelines", "status": "ACTIVE_FUNDED", "amountUsd": 300, "volume": null, "milestoneNumber": 1, "dueDate": "2026-07-01T00:00:00.000Z", "pendingApproval": false, "needsReview": false, "invoiceId": "", "createdAt": "2026-06-12T09:30:00.000Z" } ] } ] } ``` # Get Credit Balance Source: https://opentrain.ai/docs/developers/api-reference/credits/balance GET /api/public/v1/credits Read the prepaid credit balance: available cents, escrow-reserved cents, and the most recent ledger entries. Reads the [credits](/docs/developers/concepts/credits-and-billing) overview for your account. Credits are a prepaid balance that funds hires and milestone escrow without a card-present step — every money move still requires [human co-sign](/docs/developers/concepts/human-approvals); credits only change where the money comes from. Accounts that have never used credits get an all-zero response (not a `404`). For full transaction history, page through [`GET /credits/ledger`](/docs/developers/api-reference/credits/ledger); to add funds, [create a top-up](/docs/developers/api-reference/credits/create-top-up). Tokens belonging to organization members read the org owner's credit account. **Requirements:** `payments:read` scope + the `public_api_credits` feature. Works pre-claim. No parameters. ## Response Spendable balance in US cents. Cents currently held in escrow for funded milestones (not spendable). Always `usd`. The 10 most recent ledger entries, newest first — same shape as [`GET /credits/ledger`](/docs/developers/api-reference/credits/ledger) entries. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------ | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:read` scope, or `public_api_credits` disabled for the account (`details.featureKey`) | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/credits \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain credits show --json ``` ```json MCP: opentrain_get_credits theme={null} {} ``` ```json 200 theme={null} { "credits": { "availableCents": 5000, "reservedCents": 2500, "currency": "usd", "recentEntries": [ { "id": "", "type": "HOLD", "amountCents": -2500, "createdAt": "2026-06-12T10:00:00.000Z", "holdEntryId": null, "jobofferId": "", "contractId": "", "milestoneId": "", "topUpId": null, "note": null } ] } } ``` # Create Credit Top-Up Source: https://opentrain.ai/docs/developers/api-reference/credits/create-top-up POST /api/public/v1/credits/top-ups Start a credit top-up — returns a Stripe Checkout URL a human must pay. Nothing is charged by this call. Starts a prepaid [credit](/docs/developers/concepts/credits-and-billing) top-up. **This call never charges anything.** It creates a `PENDING` top-up and returns a Stripe Checkout `checkoutUrl` that a signed-in human must open and pay; once checkout completes, the credits land automatically. The link expires unpaid after \~24 hours (`expiresAt`). Learn the outcome by polling [`GET /credits/top-ups/{topUpId}`](/docs/developers/api-reference/credits/get-top-up) until the status is `COMPLETED`, then confirm the new balance with [`GET /credits`](/docs/developers/api-reference/credits/balance). **Requirements:** `payments:write` scope + the `public_api_credits` feature + a **claimed** account (unclaimed accounts get `403` with `details.reason: "account_claim_required"` and a `claimUrl`). ## Request Top-up amount in US dollars. Minimum $10, maximum $10,000. (`amount` is accepted as an alias.) ## Response Returns `201` — the top-up exists, but no money has moved until the human pays the checkout link. The top-up ID, for polling. Stripe Checkout URL — show it to your human so they can complete the payment. ISO timestamp when the unpaid checkout link lapses (\~24h). The full top-up record: `{id, status: "PENDING", amountCents, createdAt, completedAt, expiresAt}` — same shape as [`GET /credits/top-ups/{topUpId}`](/docs/developers/api-reference/credits/get-top-up). Explains the human-payment handoff. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | `BAD_REQUEST` | Body is not valid JSON, `amountUsd` missing/non-numeric, or outside $10–$10,000 (`details: {field: "amountUsd"}`) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:write` scope, `public_api_credits` disabled, or account not claimed (`details.reason: "account_claim_required"`, `details.claimUrl`) | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/credits/top-ups \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"amountUsd": 100}' ``` ```bash CLI theme={null} opentrain credits top-up --amount 100 --json ``` ```json MCP: opentrain_create_credit_top_up theme={null} { "amountUsd": 100 } ``` ```json 201 theme={null} { "topUpId": "", "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_live_...", "expiresAt": "2026-06-13T10:00:00.000Z", "topUp": { "id": "", "status": "PENDING", "amountCents": 10000, "createdAt": "2026-06-12T10:00:00.000Z", "completedAt": null, "expiresAt": "2026-06-13T10:00:00.000Z" }, "message": "Top-up created. Show checkoutUrl to your human so they can complete the payment; then poll the top-up until it is COMPLETED." } ``` ```json 400 (amount out of range) theme={null} { "error": "Top-up amount must be between $10 and $10000", "code": "BAD_REQUEST", "requestId": "", "details": { "field": "amountUsd" } } ``` # Get Credit Top-Up Source: https://opentrain.ai/docs/developers/api-reference/credits/get-top-up GET /api/public/v1/credits/top-ups/{topUpId} Poll a credit top-up until the human has paid: PENDING, COMPLETED, EXPIRED, or CANCELED. Reads one [top-up](/docs/developers/api-reference/credits/create-top-up). Poll it after handing the `checkoutUrl` to your human: `PENDING` means the checkout link has not been paid yet, `COMPLETED` means the credits are available (confirm with [`GET /credits`](/docs/developers/api-reference/credits/balance)). A `PENDING` top-up past its `expiresAt` flips to `EXPIRED` the next time it is read — expired links cannot be paid; create a new top-up instead. Top-ups belonging to another account return `404`. **Requirements:** `payments:read` scope + the `public_api_credits` feature. Works pre-claim. ## Request The top-up ID returned when the top-up was created. ## Response Top-up ID. `PENDING`, `COMPLETED`, `EXPIRED`, or `CANCELED`. Requested amount in US cents. ISO creation timestamp. ISO timestamp when payment settled; `null` unless `COMPLETED`. ISO timestamp when the unpaid checkout link lapses. ## Errors | Status | `code` | Meaning | | ------ | -------------- | --------------------------------------------------------------------------------------------------- | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:read` scope, or `public_api_credits` disabled | | `404` | `NOT_FOUND` | No such top-up, or it belongs to another account (`details: {resource: "credit-top-ups", topUpId}`) | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/credits/top-ups/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain credits top-up-status --top-up-id --json ``` ```json MCP: opentrain_get_credit_top_up theme={null} { "topUpId": "" } ``` ```json 200 (completed) theme={null} { "topUp": { "id": "", "status": "COMPLETED", "amountCents": 10000, "createdAt": "2026-06-12T10:00:00.000Z", "completedAt": "2026-06-12T10:12:00.000Z", "expiresAt": "2026-06-13T10:00:00.000Z" } } ``` ```json 200 (still pending) theme={null} { "topUp": { "id": "", "status": "PENDING", "amountCents": 10000, "createdAt": "2026-06-12T10:00:00.000Z", "completedAt": null, "expiresAt": "2026-06-13T10:00:00.000Z" } } ``` # List Credit Ledger Source: https://opentrain.ai/docs/developers/api-reference/credits/ledger GET /api/public/v1/credits/ledger Page through the credit transaction history: top-ups, escrow holds, hold releases, captures, refunds, and adjustments. Lists every [credit](/docs/developers/concepts/credits-and-billing) ledger entry on your account, newest first, with cursor pagination. Each entry links the related top-up, proposal, contract, or milestone so you can reconcile balance changes against the actions that caused them. `amountCents` is signed: positive entries add to the available balance, negative entries draw from it. Accounts that have never used credits get an empty page (not a `404`). **Requirements:** `payments:read` scope + the `public_api_credits` feature. Works pre-claim. ## Entry types | `type` | Meaning | | -------------- | --------------------------------------------------------------------------------------------------- | | `TOP_UP` | A completed [top-up](/docs/developers/api-reference/credits/create-top-up) added funds | | `HOLD` | Funds reserved into escrow (hire or [milestone funding](/docs/developers/api-reference/milestones/fund)) | | `HOLD_RELEASE` | A hold returned to the available balance (e.g. milestone cancelled) | | `CAPTURE` | Held funds paid out (milestone released) | | `REFUND` | Funds returned to the account | | `ADJUSTMENT` | Manual correction by OpenTrain | ## Request Opaque cursor from a previous response's `nextCursor`. Omit for the first page. Page size, 1–100. Out-of-range values return `400`. ## Response Ledger entry ID. One of the entry types above. Signed amount in US cents. ISO timestamp. For `HOLD_RELEASE`/`CAPTURE`, the originating `HOLD` entry. Related proposal, when the entry came from a hire. Related contract. Related milestone. Related top-up, for `TOP_UP` entries. Free-text note (mostly on `ADJUSTMENT` entries). Pass as `cursor` to fetch older entries; `null` at the end of the ledger. ## Errors | Status | `code` | Meaning | | ------ | -------------- | --------------------------------------------------------------- | | `400` | `BAD_REQUEST` | `limit` outside 1–100 (`details: {field: "limit"}`) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:read` scope, or `public_api_credits` disabled | ```bash curl theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/credits/ledger?limit=25" \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain credits ledger --limit 25 --json ``` ```json MCP: opentrain_list_credit_ledger theme={null} { "limit": 25 } ``` ```json 200 theme={null} { "entries": [ { "id": "", "type": "TOP_UP", "amountCents": 10000, "createdAt": "2026-06-12T10:00:00.000Z", "holdEntryId": null, "jobofferId": null, "contractId": null, "milestoneId": null, "topUpId": "", "note": null }, { "id": "", "type": "HOLD", "amountCents": -2500, "createdAt": "2026-06-11T15:30:00.000Z", "holdEntryId": null, "jobofferId": "", "contractId": "", "milestoneId": "", "topUpId": null, "note": null } ], "nextCursor": "" } ``` # Get Freelancer Profile Source: https://opentrain.ai/docs/developers/api-reference/freelancers/get GET /api/public/v1/freelancers/{idOrSlug} Read an AI trainer's masked public profile by user ID or profile slug: skills, stats, experience, education, and reviews. Reads an AI trainer's public profile for candidate evaluation — title, bio, skills, stats, label/work experience, education, languages, and reviews. Resolve candidates from proposals (`candidate.id` or `candidate.profileSlug` on [`GET /jobs/{id}/proposals`](/docs/developers/api-reference/jobs/list-proposals)), then [invite](/docs/developers/api-reference/jobs/invite) or [hire](/docs/developers/api-reference/proposals/hire) them. Identity is masked: first name + last initial (agencies show the agency name with no last-name initial). Personal contact details — including personal email — are never returned; see [privacy](/docs/developers/concepts/privacy-and-work-email). Profiles you cannot view (nonexistent, closed, banned, or restricted by the AI trainer's visibility settings) return `404`, not `403`. **Requirements:** `proposals:read` scope. Works pre-claim. ## Request The AI trainer's user ID or public profile slug. ## Response User ID. Public profile slug. App-relative profile path (append to `https://app.opentrain.ai`). Masked display name, e.g. `Maria G.`. First name. Last-name initial. `null` for agencies. `Individual` or `Agency`. Profile headline. Profile description. City. ISO country code. Country display name. Profile photo URL. Listed hourly rate. Stated availability. Self-reported expertise level. English proficiency. ISO account-creation timestamp. `true` when identity verification is complete. `{subjectMatter[], tags[], software[], dataTypes[], labelTypes[]}` — string arrays. `{language, proficiency}` pairs (`proficiency` may be `null`). `{totalEarnedUsd, billedHours, jobSuccessScore, reviewCount, averageRating}` — `averageRating` is rounded to one decimal, `null` when unrated. For agencies: `{name, website, headcount, industryExperience[]}`. `null` for individuals. Labeling-specific experience entries: `{id, title, description, fromYear, toYear, ongoing, software, dataType, labelTypes[]}`. General work history: `{id, title, company, description, fromYear, toYear, current, city, country}`. `{id, school, degree, areaOfStudy, fromYear, toYear, description}`. Employer reviews: `{id, rating, comment, createdAt}` — `rating` rounded to one decimal, `createdAt` ISO. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------ | | `400` | `BAD_REQUEST` | Missing `idOrSlug` | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `proposals:read` scope | | `404` | `NOT_FOUND` | No such profile, or the profile is not visible to you (`details: {resource: "freelancers", idOrSlug}`) | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/freelancers/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain freelancers get --id --json ``` ```json MCP: opentrain_get_freelancer_profile theme={null} { "idOrSlug": "" } ``` ```json 200 theme={null} { "freelancer": { "id": "", "profileSlug": "maria-g", "profilePath": "/profile/maria-g", "displayName": "Maria G.", "firstName": "Maria", "lastNameInitial": "G", "talentType": "Individual", "title": "Text Annotation Specialist", "bio": "Native Spanish speaker with 4 years of NLP annotation experience.", "city": "Madrid", "countryCode": "ES", "country": "Spain", "profilePhotoUrl": "https://app.opentrain.ai/", "hourlyRateUsd": 18, "availability": "FULL_TIME", "expertiseLevel": "EXPERT", "englishLevel": "FLUENT", "memberSince": "2024-03-02T10:00:00.000Z", "identityVerified": true, "skills": { "subjectMatter": ["Social media", "Customer support"], "tags": ["SENTIMENT_ANALYSIS", "TEXT_CLASSIFICATION"], "software": [""], "dataTypes": ["TEXT"], "labelTypes": ["CLASSIFICATION"] }, "languages": [ { "language": "Spanish", "proficiency": "NATIVE" }, { "language": "English", "proficiency": "FLUENT" } ], "stats": { "totalEarnedUsd": 2400, "billedHours": 310, "jobSuccessScore": 98, "reviewCount": 7, "averageRating": 4.9 }, "agency": null, "labelExperience": [ { "id": "", "title": "Spanish tweet sentiment corpus", "description": "Annotated 120k tweets for a sentiment model.", "fromYear": 2023, "toYear": 2025, "ongoing": false, "software": "", "dataType": "TEXT", "labelTypes": ["CLASSIFICATION"] } ], "workExperience": [], "education": [], "reviews": [ { "id": "", "rating": 5, "comment": "Fast, precise, great guideline questions.", "createdAt": "2026-01-20T12:00:00.000Z" } ] } } ``` # Get Drafting Capabilities Source: https://opentrain.ai/docs/developers/api-reference/job-drafts/capabilities GET /api/public/v1/job-drafts/capabilities Discover supported job fields, enums, and feature availability for the authenticated account. Runtime feature discovery: which Public API features are enabled for *this* account, plus the accepted draft import formats, the full list of patchable draft keys, and the canonical enum values. Features are rolled out per account, so probe this endpoint before planning a multi-step flow — and re-check it when a write returns a feature-disabled `403`. **Requirements:** any valid token — no specific scope. (An in-app session also works.) The `public_api_job_drafting` feature itself must be enabled for the account, otherwise `403`. ## Request No parameters. ## Response Always `public_api_job_drafting`. Always `job-drafts`. How the drafting feature is rolled out for this account (e.g. `ALL`, `ALLOWLIST`). Whether the account is a platform admin. Per-feature booleans for this account. Any `false` here means the matching endpoints return `403` until the feature is enabled. Structured imports via [`POST /job-drafts`](/docs/developers/api-reference/job-drafts/create). Draft creation. Draft patching via [`PATCH /job-drafts/{jobId}`](/docs/developers/api-reference/job-drafts/update). Draft/review URL reads. [Publishing](/docs/developers/api-reference/jobs/publish), [closing](/docs/developers/api-reference/jobs/close), and [editing live jobs](/docs/developers/api-reference/jobs/update) (`public_api_job_publishing`). [Hiring](/docs/developers/api-reference/proposals/hire) and [inviting](/docs/developers/api-reference/jobs/invite) (`public_api_hiring`). [Sending messages](/docs/developers/api-reference/messages/send) (`public_api_messaging_writes`). [Team invites](/docs/developers/api-reference/team/invite) (`public_api_team`). [Milestone funding/approval](/docs/developers/api-reference/milestones/fund) (`public_api_payments_write`). [Credits endpoints](/docs/developers/api-reference/credits/balance) (`public_api_credits`). [Webhook subscriptions](/docs/developers/api-reference/webhooks/create) (`public_api_webhooks`). Machine-readable drafting contract. Accepted import formats: `text`, `opentrain_canonical`, `schema_org_job_posting`, `indeed_xml`, `hr_xml`. Every key accepted by [`PATCH /job-drafts/{jobId}`](/docs/developers/api-reference/job-drafts/update) — \~50 keys including `jobTitle`, `jobDescription`, `paymentType`, `pricePerHour`, `pricePerLabel`, `fixedPrice`, `experienceLevel`, `languages`, `countries`, `headcount`. Canonical enum values: `paymentType` (`PAY_PER_LABEL`, `PAY_PER_HOUR`, `FIXED_PRICE`), `experienceLevel` (`EXPERT`, `ENTRY_LEVEL`, `INTERMEDIATE`, `ANY_EXPERIENCE_LEVEL`), `dataVolumeUnit` (`HOURS_OF_RECORDING_AUDIO_VIDEO`, `NUMBER_OF_FILES`, `NUMBER_OF_WORDS`, `UNKNOWN_NOT_SPECIFIED`). `token` when authenticated by bearer token, `session` when by in-app session. Always `true` on a `200` (a disabled drafting feature returns `403` instead). ## Errors | Status | `code` | Meaning | | ------ | -------------- | --------------------------------------------------------------------------------------------- | | `401` | `UNAUTHORIZED` | Missing, invalid, expired, or revoked token (and no session) | | `403` | `FORBIDDEN` | `public_api_job_drafting` not enabled for this account (`details.featureKey`, `details.mode`) | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/job-drafts/capabilities \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```json MCP: opentrain_capabilities theme={null} {} ``` ```json 200 theme={null} { "featureKey": "public_api_job_drafting", "namespace": "job-drafts", "rolloutMode": "ALL", "actorIsAdmin": false, "capabilities": { "draftImport": true, "draftCreate": true, "draftUpdate": true, "draftUrlRead": true, "publish": true, "hiring": true, "messagingWrites": true, "team": true, "paymentsWrite": true, "credits": true, "webhooks": true }, "draft": { "importFormats": [ "text", "opentrain_canonical", "schema_org_job_posting", "indeed_xml", "hr_xml" ], "updateKeys": [ "jobTitle", "jobDescription", "labelingOverview", "datasetDescription", "dataType", "subjectMatter", "dataVolume", "dataVolumeUnit", "labelingSoftware", "headcount", "experienceLevel", "paymentType", "pricePerHour", "pricePerLabel", "fixedPrice", "countries", "languages", "labelTypes", "..." ], "enums": { "paymentType": ["PAY_PER_LABEL", "PAY_PER_HOUR", "FIXED_PRICE"], "experienceLevel": ["EXPERT", "ENTRY_LEVEL", "INTERMEDIATE", "ANY_EXPERIENCE_LEVEL"], "dataVolumeUnit": [ "HOURS_OF_RECORDING_AUDIO_VIDEO", "NUMBER_OF_FILES", "NUMBER_OF_WORDS", "UNKNOWN_NOT_SPECIFIED" ] } }, "authBootstrap": "token", "enabled": true } ``` ```json 403 theme={null} { "error": "Public API feature not enabled for actor", "code": "FORBIDDEN", "requestId": "", "details": { "featureKey": "public_api_job_drafting", "mode": "OFF" } } ``` # Create Job Draft Source: https://opentrain.ai/docs/developers/api-reference/job-drafts/create POST /api/public/v1/job-drafts Create a job draft from a natural-language description and receive validation prompts for missing fields. 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}`](/docs/developers/api-reference/job-drafts/update). Loop until `validation.publishReady` is `true`, then [publish](/docs/developers/api-reference/jobs/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](/docs/developers/guides/post-a-job) for the full loop. **Requirements:** `jobs:write` scope + the `public_api_job_drafting` feature (check [capabilities](/docs/developers/api-reference/job-drafts/capabilities)). ## Request Send one of the body shapes below. All are JSON objects. 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: ...}`. Structured import source. Import format: `text`, `opentrain_canonical`, `schema_org_job_posting`, `indeed_xml`, or `hr_xml`. Unsupported values return `400` with `details.supportedFormats`. The text or serialized payload for the chosen format. Source-system identifier for audit (max 256 characters). Reuse the same key on retries to avoid duplicate drafts (max 256 characters). Top-level alternative to `source.type` — same supported values. Pair with `job` (canonical), `jsonLd` (schema.org), or `xml` (feeds). OpenTrain canonical job object when `format` is `opentrain_canonical` — keys like `title`, `description`, `paymentType`, `rateAmount`, `languages`, `countries`, `labelTypes`, `tools`, `experienceLevel`. Top-level equivalent of `source.externalId`. Top-level equivalent of `source.idempotencyKey`. ## Response `true` on success. The new draft's job ID — use it for every subsequent PATCH and the publish call. Always `DRAFT` (this endpoint never auto-publishes). In-app URL of the draft editor. In-app URL where a human can review the draft. Publish-readiness summary. `true` when the draft can be published as-is. Number of blocking issues. Number of required fields still missing. Same entries as the top-level `missingFields`. Non-field-specific validation issues. The gap-filling work list. Relay each `prompt` to your human, then patch the answer. Internal field identifier. Human-readable field name. Why the field is required. Machine-readable issue code. A ready-to-ask question (e.g. `"ask: What experience level should AI trainers have?"`). Expected answer type — `string`, `number`, `enum`, `string[]`, etc. Allowed values when `type` is `enum`. Patch with one of these exact values. The key(s) to set in the PATCH body (e.g. `["paymentType", "pricePerHour"]`). Example value. Extra guidance for answering. The structured fields OpenTrain extracted from your input. Non-blocking warnings. Input keys that could not be mapped — `{path, reason, valuePreview}`. Parsed fields worth double-checking with your human — `{path, reason}`. Audit echo: `{format, externalId, idempotencyKey, rawSourcePreserved, autoPublished: false}`. Parser metadata: `{source, warnings, parsedFields}`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------ | | `400` | `BAD_REQUEST` | Empty body, invalid JSON, unsupported format (`details.supportedFormats`), or description over 60,000 characters | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `jobs:write` scope (`details.requiredScopes`) or `public_api_job_drafting` disabled (`details.featureKey`) | ```bash curl theme={null} 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" }' ``` ```bash CLI theme={null} opentrain jobs draft create \ --description "We need 5 fluent Spanish speakers to label sentiment in ~20,000 social media posts. Pay per label." \ --idempotency-key sentiment-es-2026-001 --json ``` ```json MCP: opentrain_create_job_draft theme={null} { "jobDescription": "We need 5 fluent Spanish speakers to label sentiment in ~20,000 social media posts. Pay per label.", "idempotencyKey": "sentiment-es-2026-001" } ``` ```json 200 theme={null} { "ok": true, "jobId": "", "status": "DRAFT", "draftUrl": "https://app.opentrain.ai/job-post?jobId=", "reviewUrl": "https://app.opentrain.ai/job-post?jobId=", "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 } } ``` ```json 400 (unsupported format) theme={null} { "error": "Unsupported job import format", "code": "BAD_REQUEST", "requestId": "", "details": { "supportedFormats": [ "text", "opentrain_canonical", "schema_org_job_posting", "indeed_xml", "hr_xml" ] } } ``` # Update Job Draft Source: https://opentrain.ai/docs/developers/api-reference/job-drafts/update PATCH /api/public/v1/job-drafts/{jobId} Fill in or correct draft fields in response to validation prompts. Patches any subset of fields on an unpublished draft and returns refreshed validation. This is the second half of the gap-filling loop: take each missing field's `updateKeys` and `enumValues` from the [create response](/docs/developers/api-reference/job-drafts/create), ask your human the `prompt`, and patch the answers. Repeat until `validation.publishReady` is `true`, then [publish](/docs/developers/api-reference/jobs/publish). The accepted keys and enum values are machine-discoverable at [`GET /job-drafts/capabilities`](/docs/developers/api-reference/job-drafts/capabilities) (`draft.updateKeys` and `draft.enums`). **Requirements:** `jobs:write` scope + the `public_api_job_drafting` feature. The job must be an unpublished draft you own — to edit a live job use [`PATCH /jobs/{id}`](/docs/developers/api-reference/jobs/update). ## Request The draft job ID from the create response. The body is a JSON object with one or more draft fields. Unknown keys are rejected (`400` with zod issue details). Commonly patched keys: Job title. Full description text. `PAY_PER_LABEL`, `PAY_PER_HOUR`, or `FIXED_PRICE`. Pair with the matching rate field below. Hourly rate in USD (for `PAY_PER_HOUR`). Per-label rate in USD (for `PAY_PER_LABEL`). Total fixed price in USD (for `FIXED_PRICE`). `EXPERT`, `ENTRY_LEVEL`, `INTERMEDIATE`, or `ANY_EXPERIENCE_LEVEL`. `HOURS_OF_RECORDING_AUDIO_VIDEO`, `NUMBER_OF_FILES`, `NUMBER_OF_WORDS`, or `UNKNOWN_NOT_SPECIFIED`. Required languages. Allowed countries. Label/annotation types. Number of AI trainers to hire. Quantity of data, in `dataVolumeUnit` units. The full key list also includes `labelingOverview`, `datasetDescription`, `dataType`, `subjectMatter`, `labelingSoftware`, `aiInterviewRequirements`, `budgetRange`, `workloadDesc`, `timeRequirement`, `projectDuration`, `freelancerType`, `projectScope`, `visibility`, and more — fetch `draft.updateKeys` from [capabilities](/docs/developers/api-reference/job-drafts/capabilities) for the authoritative set. Enum fields must use the exact canonical values above. ## Response `true` on success. The draft job ID. Still `DRAFT`. In-app URL of the draft editor. Refreshed validation — same shape as the create response: `publishReady`, `issueCount`, `missingFieldCount`, `missingFields[]` (with `prompt`/`type`/`enumValues`/`updateKeys`), `issues[]`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------ | | `400` | `BAD_REQUEST` | Empty body, invalid JSON, unknown keys or wrong types (`details` carries zod issues), or no fields set | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `jobs:write` scope, feature disabled, or the job belongs to another account | | `404` | `NOT_FOUND` | No such job | ```bash curl theme={null} curl -sS -X PATCH https://app.opentrain.ai/api/public/v1/job-drafts/ \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "experienceLevel": "INTERMEDIATE", "paymentType": "PAY_PER_LABEL", "pricePerLabel": 0.06 }' ``` ```bash CLI theme={null} opentrain jobs draft update --job-id \ --set experienceLevel=INTERMEDIATE \ --set paymentType=PAY_PER_LABEL \ --set pricePerLabel=0.06 --json ``` ```json MCP: opentrain_update_job_draft_fields theme={null} { "jobId": "", "patch": { "experienceLevel": "INTERMEDIATE", "paymentType": "PAY_PER_LABEL", "pricePerLabel": 0.06 } } ``` ```json 200 theme={null} { "ok": true, "jobId": "", "status": "DRAFT", "draftUrl": "https://app.opentrain.ai/job-post?jobId=", "validation": { "publishReady": true, "issueCount": 0, "missingFieldCount": 0, "missingFields": [], "issues": [] } } ``` ```json 400 (invalid payload) theme={null} { "error": "Invalid payload", "code": "BAD_REQUEST", "requestId": "", "details": { "formErrors": [], "fieldErrors": { "experienceLevel": [ "Invalid enum value. Expected 'EXPERT' | 'ENTRY_LEVEL' | 'INTERMEDIATE' | 'ANY_EXPERIENCE_LEVEL', received 'SENIOR'" ] } } } ``` # List Job Changes Source: https://opentrain.ai/docs/developers/api-reference/jobs/changes GET /api/public/v1/jobs/changes Poll for marketplace job changes since a cursor. Incremental change feed for the public marketplace: which jobs were published, updated, or closed since a timestamp. Designed for keeping an external job index in sync without re-fetching everything — store the response's `until` and pass it back as the next `since`. Only the **latest** event per job is returned (a job published then updated in the window appears once, as `UPDATED`). The feed is capped at 5,000 events per call; if you hit the cap, advance `since` to the returned `until` and poll again. Tokenless, CORS-enabled, and edge-cached the same way as [search](/docs/developers/api-reference/jobs/search). This feed covers public marketplace listings only — for events about *your own* account (proposals, messages, contracts), use [`GET /updates`](/docs/developers/api-reference/updates/poll). **Requirements:** none — no token, no scope, no feature flag. Rate limited to 120 requests/minute per IP. ## Request ISO 8601 timestamp. Changes at or after this instant are returned. Missing or invalid → `400`. ## Response Echo of the requested window start. Window end (now). Persist this and pass it as the next call's `since`. One entry per changed job — the latest event in the window. The job that changed — fetch details with [`GET /jobs/{id}`](/docs/developers/api-reference/jobs/get). `PUBLISHED`, `UPDATED`, or `CLOSED`. ISO timestamp of the event. ISO timestamp when the response was generated. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------ | | `400` | `BAD_REQUEST` | Missing or unparseable `since` | | `429` | `RATE_LIMITED` | Over 120 requests/minute from one IP | ```bash curl theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/jobs/changes?since=2026-06-11T00:00:00Z" ``` ```json 200 theme={null} { "since": "2026-06-11T00:00:00.000Z", "until": "2026-06-12T08:00:00.000Z", "changes": [ { "jobId": "", "eventType": "PUBLISHED", "occurredAt": "2026-06-11T14:22:05.000Z" }, { "jobId": "", "eventType": "CLOSED", "occurredAt": "2026-06-12T06:10:41.000Z" } ], "generatedAt": "2026-06-12T08:00:00.000Z" } ``` # Close Job Source: https://opentrain.ai/docs/developers/api-reference/jobs/close POST /api/public/v1/jobs/{id}/close Close an open job to new proposals. Closes a job — it leaves the public marketplace and stops accepting proposals. Existing contracts on the job are unaffected (end those separately via [`POST /contracts/{id}/end`](/docs/developers/api-reference/contracts/end)). Idempotent: closing an already-closed job returns `200` with `alreadyClosed: true`. **Requirements:** `jobs:write` scope + the `public_api_job_publishing` feature. The job must be yours. ## Request The job ID. No body. ## Response `true` on success. The job ID. `ARCHIVED`. `true` when the job was already closed (no-op). ## Errors | Status | `code` | Meaning | | ------ | -------------- | ---------------------------------------------- | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `jobs:write` scope or feature disabled | | `404` | `NOT_FOUND` | No such job, or not yours | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/jobs//close \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain jobs close --job-id --json ``` ```json MCP: opentrain_close_job theme={null} { "jobId": "" } ``` ```json 200 theme={null} { "ok": true, "jobId": "", "status": "ARCHIVED", "alreadyClosed": false } ``` # List Job Facets Source: https://opentrain.ai/docs/developers/api-reference/jobs/facets GET /api/public/v1/jobs/facets Retrieve marketplace facet counts for building job search filters. Aggregate counts across the open marketplace — categories, languages, countries, and pay types, each with the number of matching jobs. Use these to populate filter dropdowns or to discover valid filter values for [`GET /jobs`](/docs/developers/api-reference/jobs/search) instead of guessing strings. Tokenless, CORS-enabled, and edge-cached the same way as the search endpoint. **Requirements:** none — no token, no scope, no feature flag. Rate limited to 120 requests/minute per IP. ## Request No parameters. `OPTIONS` returns `204` (CORS preflight). ## Response Total open jobs in the marketplace. `{value, count}` pairs, sorted by `count` descending. `{value, count}` pairs, sorted by `count` descending. `{value, count}` pairs, sorted by `count` descending. `{value, count}` pairs — values are `PAY_PER_HOUR`, `FIXED_PRICE`, `PAY_PER_LABEL`. ISO timestamp when the counts were computed. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------ | | `429` | `RATE_LIMITED` | Over 120 requests/minute from one IP | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/jobs/facets ``` ```json 200 theme={null} { "totalJobs": 184, "categories": [ { "value": "Text Annotation", "count": 62 }, { "value": "Audio Annotation", "count": 41 }, { "value": "Image Annotation", "count": 35 } ], "languages": [ { "value": "English", "count": 120 }, { "value": "Spanish", "count": 28 } ], "countries": [ { "value": "US", "count": 44 }, { "value": "IN", "count": 31 } ], "payTypes": [ { "value": "PAY_PER_HOUR", "count": 97 }, { "value": "PAY_PER_LABEL", "count": 52 }, { "value": "FIXED_PRICE", "count": 35 } ], "generatedAt": "2026-06-12T08:00:00.000Z" } ``` # Get Job Source: https://opentrain.ai/docs/developers/api-reference/jobs/get GET /api/public/v1/jobs/{id} Retrieve a single public job by ID. Fetches one public marketplace job by ID. Same job object shape as [search](/docs/developers/api-reference/jobs/search). Tokenless, CORS-enabled, edge-cached. A job that exists but is no longer publicly listed (closed, archived, or otherwise unlisted) returns `404` with a minimal `{id, status: "closed"}` body — distinct from the standard error envelope you get for a nonexistent ID. **Requirements:** none — no token, no scope, no feature flag. Rate limited to 120 requests/minute per IP. ## Request Job ID (from search, the changes feed, or your own job list). Empty or longer than 64 characters → `400`. ## Response A single job object — see the [search page](/docs/developers/api-reference/jobs/search) for every field: `id`, `slug`, `title`, `companyName`, `descriptionText`, `descriptionHtml`, `seoTitle`, `summary`, `status`, `datePosted`, `validThrough`, `updatedAt`, `employmentTypes`, `countries`, `languages`, `category`, `dataType`, `labelTypes`, `labelingSoftware`, `experienceLevel`, `skills`, `pay`, `url`, `applyUrl`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Invalid ID (empty or over 64 characters) | | `404` | `NOT_FOUND` | No such job (standard error envelope) | | `404` | — | Job exists but is closed/unlisted — body is `{"id": "...", "status": "closed"}` | | `429` | `RATE_LIMITED` | Over 120 requests/minute from one IP | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/jobs/ ``` ```json 200 theme={null} { "id": "", "slug": "spanish-audio-annotation", "title": "Spanish Audio Annotation", "companyName": "Acme AI", "descriptionText": "Transcribe and annotate Spanish call-center audio...", "descriptionHtml": "

Transcribe and annotate Spanish call-center audio...

", "seoTitle": null, "summary": null, "status": "OPEN", "datePosted": "2026-06-01T12:00:00.000Z", "validThrough": "2026-07-27T12:00:00.000Z", "updatedAt": "2026-06-10T09:30:00.000Z", "employmentTypes": ["CONTRACTOR"], "countries": [], "languages": ["Spanish"], "category": "Audio Annotation", "dataType": "Audio", "labelTypes": ["Transcription"], "labelingSoftware": null, "experienceLevel": "Intermediate", "skills": ["Transcription"], "pay": { "paymentType": "PAY_PER_HOUR", "currency": "USD", "hourlyRate": 12, "hourlyMin": null, "hourlyMax": null, "fixedPrice": null, "perLabelRate": null }, "url": "https://app.opentrain.ai/jobs/spanish-audio-annotation-", "applyUrl": "https://app.opentrain.ai/api/out?job=&src=www_seo" } ``` ```json 404 (closed job) theme={null} { "id": "", "status": "closed" } ```
# Invite Freelancer to Job Source: https://opentrain.ai/docs/developers/api-reference/jobs/invite POST /api/public/v1/jobs/{id}/invites Invite a specific AI trainer to submit a proposal to your job. Invites a specific AI trainer to a published job of yours, creating an invited proposal they can respond to. Find candidates via [`GET /freelancers/{idOrSlug}`](/docs/developers/api-reference/freelancers/get) or from proposals on other jobs. Idempotent per freelancer: re-inviting returns `200` with `alreadyInvited: true` instead of creating a duplicate. **Requirements:** `proposals:write` scope + the `public_api_hiring` feature + a **claimed** account (unclaimed accounts get `403` with `details.reason: "account_claim_required"` and a `claimUrl`). The job must be yours and published. ## Request Your published job's ID. The AI trainer's user ID. ## Response `201` when a new invite was created, `200` when the freelancer was already invited. `true` on success. The invited proposal's ID — track it via [`GET /proposals/{id}`](/docs/developers/api-reference/proposals/get). The job ID. The invited AI trainer's user ID. `true` when this invite already existed (idempotent repeat). ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | `BAD_REQUEST` | Missing or invalid `freelancerId` (zod details) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `proposals:write` scope, `public_api_hiring` disabled, or account not claimed (`details.reason: "account_claim_required"`, `details.claimUrl`) | | `404` | `NOT_FOUND` | No such job (or not yours), or no such user | | `409` | `CONFLICT` | Invite not possible — `details.reason` is one of `job_not_published`, `not_a_freelancer`, `freelancer_unavailable` | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/jobs//invites \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"freelancerId": ""}' ``` ```bash CLI theme={null} opentrain jobs invite --job-id --freelancer-id --json ``` ```json MCP: opentrain_invite_freelancer theme={null} { "jobId": "", "freelancerId": "" } ``` ```json 201 theme={null} { "ok": true, "proposalId": "", "jobId": "", "freelancerId": "", "alreadyInvited": false } ``` ```json 409 (job not published) theme={null} { "error": "Job is not published", "code": "CONFLICT", "requestId": "", "details": { "reason": "job_not_published" } } ``` # List Job Proposals Source: https://opentrain.ai/docs/developers/api-reference/jobs/list-proposals GET /api/public/v1/jobs/{id}/proposals List proposals submitted to a job you own, with bids, AI-interview scores, and candidate summaries. Lists the proposals on one of your jobs, newest first — each with the candidate's bid, AI-interview score, and a privacy-safe candidate summary. This is the entry point of the [candidate evaluation flow](/docs/developers/guides/evaluate-candidates): list here, then drill into [`GET /proposals/{id}`](/docs/developers/api-reference/proposals/get) and the [interview transcript](/docs/developers/api-reference/proposals/interview). Candidate identity is masked pre-hire: first name + last initial, no contact details. Personal emails are never exposed at any stage — see [privacy](/docs/developers/concepts/privacy-and-work-email). **Requirements:** `proposals:read` scope. The job must be yours (`403` otherwise). Works pre-claim. ## Request Your job's ID. Filter by proposal status: `UNREVIEWED`, `SHORTLISTED`, `MAYBE`, `HIRED`, `DECLINED`, `NOT_A_FIT`, or `RESUME_SENT`. Case-insensitive; spaces and hyphens are normalized to underscores. Invalid values return `400` with `details.supportedStatuses`. Page size, max 100. Below 1 → `400`. Pagination cursor (a proposal ID) from a previous response's `nextCursor`. ## Response Proposals ordered by creation date, newest first. Proposal ID. The job this proposal belongs to. Job title (denormalized for convenience). ISO submission timestamp. ISO last-change timestamp. `{raw, label}` — machine value (e.g. `UNREVIEWED`) plus display label. `{amountUsd, unit, labelerHourlyRateUsd}` — the candidate's asking rate and its unit. Privacy-safe candidate summary: `{id, profileSlug, displayName, firstName, lastNameInitial, profileTitle, profilePhotoUrl, countryCode, country, talentType, highestEarningsUsd, reviewCount}`. Use `id` or `profileSlug` with [`GET /freelancers/{idOrSlug}`](/docs/developers/api-reference/freelancers/get) for the full profile. `{interviewScore, matchScore}` — AI-interview score and job-match score, `null` when not available. Pass back as `cursor` for the next page; `null` at the end. ## Errors | Status | `code` | Meaning | | ------ | -------------- | --------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Invalid `status` (`details.supportedStatuses`) or `limit` below 1 | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `proposals:read` scope, or the job belongs to another account | | `404` | `NOT_FOUND` | No such job | ```bash curl theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/jobs//proposals?status=UNREVIEWED&limit=25" \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain proposals list --job-id --status UNREVIEWED --limit 25 --json ``` ```json MCP: opentrain_list_proposals theme={null} { "jobId": "", "status": "UNREVIEWED", "limit": 25 } ``` ```json 200 theme={null} { "proposals": [ { "id": "", "jobId": "", "jobTitle": "Spanish Sentiment Labeling — Social Media Posts", "createdAt": "2026-06-11T15:40:00.000Z", "updatedAt": "2026-06-11T15:40:00.000Z", "status": { "raw": "UNREVIEWED", "label": "Unreviewed" }, "bid": { "amountUsd": 0.06, "unit": "PER_LABEL", "labelerHourlyRateUsd": null }, "candidate": { "id": "", "profileSlug": "maria-g", "displayName": "Maria G.", "firstName": "Maria", "lastNameInitial": "G", "profileTitle": "Text Annotation Specialist", "profilePhotoUrl": "https://app.opentrain.ai/", "countryCode": "ES", "country": "Spain", "talentType": "FREELANCER", "highestEarningsUsd": 2400, "reviewCount": 7 }, "metrics": { "interviewScore": 86, "matchScore": 0.91 } } ], "nextCursor": null } ``` # List My Jobs Source: https://opentrain.ai/docs/developers/api-reference/jobs/mine GET /api/public/v1/jobs/mine List the jobs owned by the authenticated account. Lists every job owned by your account — drafts included — with proposal counts, newest first. This is the authenticated counterpart to the tokenless [marketplace search](/docs/developers/api-reference/jobs/search): use it to find your own drafts to finish, open jobs to monitor, and proposal queues to review. **Requirements:** `jobs:read` scope. Works pre-claim. ## Request Filter to one status: `DRAFT`, `OPEN`, `ONGOING`, `COMPLETED`, `ARCHIVED`, or `PENDING_APPROVAL`. Invalid values return `400` with `details.supportedStatuses`. Page size, max 100. Pagination cursor from a previous response's `nextCursor`. ## Response Your jobs, newest first. Job ID. Job title. `DRAFT`, `OPEN`, `ONGOING`, `COMPLETED`, `ARCHIVED`, or `PENDING_APPROVAL`. Whether the job is live on the marketplace. Whether new proposals are being accepted. ISO creation timestamp. ISO last-update timestamp. In-app URL (draft editor for drafts, listing for published jobs). `{total, unreviewed}` — proposal volume for the job. A nonzero `unreviewed` means there are candidates to [evaluate](/docs/developers/api-reference/jobs/list-proposals). Pass back as `cursor` for the next page; `null` at the end. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ----------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Invalid `status` (`details.supportedStatuses`) or `limit` below 1 | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `jobs:read` scope | ```bash curl theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/jobs/mine?status=OPEN&limit=25" \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain jobs list --status OPEN --limit 25 --json ``` ```json MCP: opentrain_list_jobs theme={null} { "status": "OPEN", "limit": 25 } ``` ```json 200 theme={null} { "jobs": [ { "id": "", "title": "Spanish Sentiment Labeling — Social Media Posts", "status": "OPEN", "published": true, "acceptingApplicants": true, "createdAt": "2026-06-10T11:02:00.000Z", "updatedAt": "2026-06-11T09:15:00.000Z", "jobUrl": "https://app.opentrain.ai/jobs/", "proposalCounts": { "total": 12, "unreviewed": 5 } } ], "nextCursor": null } ``` ```json 400 (invalid status) theme={null} { "error": "Invalid status filter", "code": "BAD_REQUEST", "requestId": "", "details": { "status": "LIVE", "supportedStatuses": [ "DRAFT", "OPEN", "ONGOING", "COMPLETED", "ARCHIVED", "PENDING_APPROVAL" ] } } ``` # Publish Job Source: https://opentrain.ai/docs/developers/api-reference/jobs/publish POST /api/public/v1/jobs/{id}/publish Publish a draft job to the marketplace. Takes a `publishReady` draft live on the marketplace. The final step of the [posting loop](/docs/developers/guides/post-a-job): [create](/docs/developers/api-reference/job-drafts/create) → [gap-fill](/docs/developers/api-reference/job-drafts/update) → publish. Idempotent — publishing an already-open job returns `200` with `alreadyPublished: true`. Every publish passes content moderation, and accounts have a daily publish limit (**20/day claimed, 3/day unclaimed**). A draft that is not publish-ready is rejected with the same validation detail the draft endpoints return — go back and patch the missing fields. **Requirements:** `jobs:write` scope + the `public_api_job_publishing` feature (check [capabilities](/docs/developers/api-reference/job-drafts/capabilities) → `capabilities.publish`). Works pre-claim within the lower daily limit. ## Request The draft job ID. No body. ## Response `true` on success. The job ID. `OPEN` — the job is live. `true` when the job was already open (no-op). Public listing URL. Internal attempt identifier for support/audit, when available. Non-blocking warnings (e.g. fields worth improving). `{dispatched, status}` — whether post-publish side effects (distribution, notifications) were kicked off. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------ | | `400` | `BAD_REQUEST` | Draft is not publish-ready — `details.validation` carries the same `missingFields`/`issues` shape as the draft endpoints | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing scope, feature disabled, or moderation block (`details.reason: "moderation_blocked"` with `details.reasons[]`) | | `404` | `NOT_FOUND` | No such job, or not yours | | `429` | `RATE_LIMITED` | Daily publish limit reached — `details` carries `{limit, windowHours: 24}` | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/jobs//publish \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain jobs publish --job-id --json ``` ```json MCP: opentrain_publish_job theme={null} { "jobId": "" } ``` ```json 200 theme={null} { "ok": true, "jobId": "", "status": "OPEN", "alreadyPublished": false, "jobUrl": "https://app.opentrain.ai/jobs/", "publishAttemptId": "", "warnings": [], "sideEffects": { "dispatched": true, "status": "queued" } } ``` ```json 429 (daily limit) theme={null} { "error": "Daily publish limit reached", "code": "RATE_LIMITED", "requestId": "", "details": { "limit": 3, "windowHours": 24 } } ``` # Search Jobs Source: https://opentrain.ai/docs/developers/api-reference/jobs/search GET /api/public/v1/jobs Search the public job marketplace. No authentication required. Full-text search over the public OpenTrain job marketplace. This endpoint is **tokenless** — no `Authorization` header needed — and CORS-enabled (`Access-Control-Allow-Origin: *`), so it works from browsers, scripts, and agents without any onboarding. Responses are cached at the edge (`Cache-Control: public, s-maxage=300, stale-while-revalidate=3600`). For filter values to offer in a UI, fetch [`GET /jobs/facets`](/docs/developers/api-reference/jobs/facets) first. To track marketplace changes incrementally, poll [`GET /jobs/changes`](/docs/developers/api-reference/jobs/changes). **Requirements:** none — no token, no scope, no feature flag. Rate limited to 120 requests/minute per IP. ## Request Free-text search across job titles and descriptions. Filter by job category (use values from [facets](/docs/developers/api-reference/jobs/facets)). Filter by required language. Filter by allowed country. `PAY_PER_HOUR`, `FIXED_PRICE`, or `PAY_PER_LABEL`. Any other value returns `400`. Page size, max 100. Opaque pagination cursor from a previous response's `nextCursor`. `OPTIONS` on this path returns `204` with the CORS headers (preflight support). ## Response Matching public jobs, each a full job object. Job ID — usable with [`GET /jobs/{id}`](/docs/developers/api-reference/jobs/get). URL slug. Job title. Hiring company's display name. Plain-text description. HTML description. Standardized title when available. Short standardized summary when available. Marketplace status (open jobs only appear here). ISO timestamp of the original posting date. ISO timestamp — rolling listing expiry. ISO timestamp of the last update. schema.org `employmentType` values (e.g. `CONTRACTOR`). ISO country codes; empty array means worldwide. Required languages. Job category. Data type being labeled (e.g. `Text`, `Audio`). Label/annotation types. Required labeling tool, when specified. Human-readable level (e.g. `Expert`), `null` when unspecified. Employer-tagged skills. Pay details. `paymentType` is `PAY_PER_HOUR`, `FIXED_PRICE`, `PAY_PER_LABEL`, or `null`; `currency` is always `USD`. Rate fields (`hourlyRate`, `hourlyMin`, `hourlyMax`, `fixedPrice`, `perLabelRate`) are numbers or `null` depending on the payment type. Canonical public listing URL. Tracked apply redirect. Always use this URL to send applicants — never construct apply links yourself. Pass back as `cursor` for the next page; `null` when there are no more results. ISO timestamp when the response was generated (responses are edge-cached up to 5 minutes). ## Errors | Status | `code` | Meaning | | ------ | -------------- | ----------------------------------------- | | `400` | `BAD_REQUEST` | Invalid `payType` or malformed parameters | | `429` | `RATE_LIMITED` | Over 120 requests/minute from one IP | ```bash curl theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/jobs?q=audio%20annotation&payType=PAY_PER_HOUR&limit=10" ``` ```bash CLI theme={null} opentrain jobs search --q "audio annotation" --pay-type PAY_PER_HOUR --limit 10 --json ``` ```json MCP: opentrain_search_jobs theme={null} { "q": "audio annotation", "payType": "PAY_PER_HOUR", "limit": 10 } ``` ```json 200 theme={null} { "jobs": [ { "id": "", "slug": "spanish-audio-annotation", "title": "Spanish Audio Annotation", "companyName": "Acme AI", "descriptionText": "Transcribe and annotate Spanish call-center audio...", "descriptionHtml": "

Transcribe and annotate Spanish call-center audio...

", "seoTitle": null, "summary": null, "status": "OPEN", "datePosted": "2026-06-01T12:00:00.000Z", "validThrough": "2026-07-27T12:00:00.000Z", "updatedAt": "2026-06-10T09:30:00.000Z", "employmentTypes": ["CONTRACTOR"], "countries": [], "languages": ["Spanish"], "category": "Audio Annotation", "dataType": "Audio", "labelTypes": ["Transcription"], "labelingSoftware": null, "experienceLevel": "Intermediate", "skills": ["Transcription"], "pay": { "paymentType": "PAY_PER_HOUR", "currency": "USD", "hourlyRate": 12, "hourlyMin": null, "hourlyMax": null, "fixedPrice": null, "perLabelRate": null }, "url": "https://app.opentrain.ai/jobs/spanish-audio-annotation-", "applyUrl": "https://app.opentrain.ai/api/out?job=&src=www_seo" } ], "nextCursor": null, "generatedAt": "2026-06-12T08:00:00.000Z" } ```
# Update Job Source: https://opentrain.ai/docs/developers/api-reference/jobs/update PATCH /api/public/v1/jobs/{id} Edit a live (published) job. Edits are re-moderated. Patches a **published, open** job — same field keys and types as [`PATCH /job-drafts/{jobId}`](/docs/developers/api-reference/job-drafts/update) (the authoritative key list is `draft.updateKeys` in [capabilities](/docs/developers/api-reference/job-drafts/capabilities)). For unpublished drafts use the draft endpoint instead; this one requires the job to be `OPEN`. Every edit to a live job is **re-moderated**. If moderation rejects the edited content, the update is still applied but the job is **unpublished back to `DRAFT`** — fix the flagged content and [publish](/docs/developers/api-reference/jobs/publish) again. **Requirements:** `jobs:write` scope + the `public_api_job_publishing` feature. The job must be yours and `OPEN`. ## Request The published job's ID. Body: a JSON object with one or more job fields — identical schema to the [draft update endpoint](/docs/developers/api-reference/job-drafts/update). Unknown keys → `400` with zod issue details. ## Response `true` on success. The job ID. `OPEN` — the job stayed live. `{decision: "ALLOW", reasons: []}` when the edit passed moderation. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Empty body, invalid JSON, unknown keys, or wrong types (`details` carries zod issues) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `jobs:write` scope or feature disabled | | `404` | `NOT_FOUND` | No such job, or not yours | | `409` | `CONFLICT` | Job is not `OPEN` — `details: {jobId, status, requiredStatus: "OPEN"}`; for `DRAFT` jobs the message points you to `PATCH /job-drafts/{jobId}` | | `409` | `CONFLICT` | Moderation rejected the edit — **the update was applied but the job was unpublished to `DRAFT`**; `details: {jobId, reason: "moderation_blocked", reasons: [...], status: "DRAFT"}` | ```bash curl theme={null} curl -sS -X PATCH https://app.opentrain.ai/api/public/v1/jobs/ \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "headcount": 8, "pricePerHour": 14 }' ``` ```bash CLI theme={null} opentrain jobs update-published --job-id \ --set headcount=8 \ --set pricePerHour=14 --json ``` ```json MCP: opentrain_update_published_job theme={null} { "jobId": "", "patch": { "headcount": 8, "pricePerHour": 14 } } ``` ```json 200 theme={null} { "ok": true, "jobId": "", "status": "OPEN", "moderation": { "decision": "ALLOW", "reasons": [] } } ``` ```json 409 (moderation blocked) theme={null} { "error": "Job update applied but failed moderation; job unpublished", "code": "CONFLICT", "requestId": "", "details": { "jobId": "", "reason": "moderation_blocked", "reasons": ["contact_information_in_description"], "status": "DRAFT" } } ``` # List Conversations & Read Messages Source: https://opentrain.ai/docs/developers/api-reference/messages/list GET /api/public/v1/messages One endpoint, two modes: list your conversation summaries, or read the messages in one conversation. Reads your messaging surface in two modes: * **Without `conversationId`** — lists your conversation summaries (proposal threads, job threads, and channels), newest activity first. * **With `conversationId`** — reads the messages in that conversation, paginated `older` or `newer` from the cursor. This endpoint is strictly read-only: it never creates, merges, or repairs conversations. Conversations come from [starting a proposal thread](/docs/developers/api-reference/proposals/conversation), invites, and [hires](/docs/developers/api-reference/proposals/hire). Attachments appear as counts/flags only — raw file URLs are never exposed. **Requirements:** `messages:read` scope. You only see conversations you participate in. Works pre-claim. ## Request Conversation to read. Omit to list conversation summaries instead. Page size, 1–100. Pagination cursor from a previous response's `nextCursor`. Message mode only: `older` pages back in time, `newer` pages forward (e.g. tailing a thread). List mode only: `all`, `job` (post-hire threads and channels), or `proposal` (pre-hire threads). List mode only: pass `true` or `1` to return only conversations with unread messages. Invalid parameters return `400` with zod issue details. ## Response — conversation list (no `conversationId`) Conversation ID — pass back as `conversationId` to read it, or to [`POST /messages`](/docs/developers/api-reference/messages/send) to reply. `DIRECT_MESSAGE` or `CHANNEL`. `proposal` (pre-hire thread) or `job` (post-hire thread or channel). Linked job for `job`-bucket conversations. Linked proposal for `proposal`-bucket conversations. Channel name (channels only). ISO timestamp of latest activity. `{id, content, senderUserId, isFromViewer, createdAt}` — a preview of the most recent message. Number of messages in the conversation you have not read. Messages awaiting your reply. Pass back as `cursor` for the next page; `null` at the end. ## Response — messages (`conversationId` set) Message ID. ISO sent timestamp. ISO last-change timestamp. The conversation. Linked job, when the conversation is job-linked. Sender's user ID. `true` when the token owner sent it. Message text. Message type for system/automated messages. `true` for system messages (hire notices, milestone events, etc.). `true` when unread by you. Number of attached files (counts only — no file URLs). `true` when the message has an audio recording. ISO timestamp of the last edit, `null` if never edited. Number of emoji reactions. Pass back as `cursor` (with the same `direction`) for the next page; `null` at the end. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Invalid query parameters (zod details) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `messages:read` scope, or you are not a participant in `conversationId` | | `404` | `NOT_FOUND` | No such conversation | ```bash curl (list conversations) theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/messages?filter=all&unreadOnly=true&limit=20" \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash curl (read one conversation) theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/messages?conversationId=&limit=20&direction=older" \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain messages list --unread-only --json opentrain messages read --conversation-id --json ``` ```json MCP: opentrain_read_messages theme={null} { "conversationId": "", "limit": 20, "direction": "older" } ``` ```json 200 (conversation list) theme={null} { "conversations": [ { "id": "", "type": "DIRECT_MESSAGE", "bucket": "proposal", "jobId": null, "proposalId": "", "channelName": null, "updatedAt": "2026-06-12T08:30:00.000Z", "lastMessage": { "id": "", "content": "Thanks — I can start Monday.", "senderUserId": "", "isFromViewer": false, "createdAt": "2026-06-12T08:30:00.000Z" }, "unreadCount": 1, "needsReplyCount": 1 } ], "nextCursor": null } ``` ```json 200 (messages) theme={null} { "messages": [ { "id": "", "createdAt": "2026-06-12T08:30:00.000Z", "updatedAt": "2026-06-12T08:30:00.000Z", "conversationId": "", "jobId": null, "senderUserId": "", "isFromViewer": false, "content": "Thanks — I can start Monday.", "messageType": null, "system": false, "hasUnreadFlag": true, "attachmentCount": 0, "hasAudio": false, "editedAt": null, "reactionCount": 0 } ], "nextCursor": null } ``` # Send Message Source: https://opentrain.ai/docs/developers/api-reference/messages/send POST /api/public/v1/messages Send a plain-text message into an existing conversation you participate in. Sends a plain-text message into an existing conversation you participate in. The same membership, rate-limit, and content-policy checks as the in-app messaging flow apply. This endpoint never creates conversations — get a `conversationId` from [`GET /messages`](/docs/developers/api-reference/messages/list) or by [starting a proposal thread](/docs/developers/api-reference/proposals/conversation). **Requirements:** `messages:write` scope + the `public_api_messaging_writes` feature + a **claimed** account (unclaimed accounts get `403` with `details.reason: "account_claim_required"` and a `claimUrl`). ## Request Existing conversation you participate in. Plain-text message, 1–10,000 characters. ## Response Returns `201` on success. `true` on success. The created message, in the same shape as the message entries on [`GET /messages`](/docs/developers/api-reference/messages/list): `{id, createdAt, updatedAt, conversationId, jobId, senderUserId, isFromViewer, content, messageType, system, hasUnreadFlag, attachmentCount, hasAudio, editedAt, reactionCount}`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | `BAD_REQUEST` | Empty body, invalid JSON, or invalid fields (`details.issues` carries zod details — e.g. `content` empty or over 10,000 chars) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `messages:write` scope, feature disabled, account not claimed (`details.reason: "account_claim_required"`, `details.claimUrl`), not a participant in the conversation, or content blocked by policy (`details.reason: "message_policy_blocked"` with `details.policy`) | | `404` | `NOT_FOUND` | No such conversation | | `409` | `CONFLICT` | Send blocked — `details.reason` is `employer_first_message_required` (candidate replying before the employer's first message), `read_only_conversation`, or `message_blocked` | | `429` | `RATE_LIMITED` | Messaging rate limit hit — retry after `details.retryAfterSeconds` | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/messages \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conversationId": "", "content": "Hi Maria — your interview looked great. Could you start with a 5,000-post batch next week?" }' ``` ```bash CLI theme={null} opentrain messages send --conversation-id \ --content "Hi Maria — your interview looked great. Could you start with a 5,000-post batch next week?" --json ``` ```json MCP: opentrain_send_message theme={null} { "conversationId": "", "content": "Hi Maria — your interview looked great. Could you start with a 5,000-post batch next week?" } ``` ```json 201 theme={null} { "ok": true, "message": { "id": "", "createdAt": "2026-06-12T09:15:00.000Z", "updatedAt": "2026-06-12T09:15:00.000Z", "conversationId": "", "jobId": null, "senderUserId": "", "isFromViewer": true, "content": "Hi Maria — your interview looked great. Could you start with a 5,000-post batch next week?", "messageType": null, "system": false, "hasUnreadFlag": false, "attachmentCount": 0, "hasAudio": false, "editedAt": null, "reactionCount": 0 } } ``` ```json 429 (rate limited) theme={null} { "error": "Rate limit exceeded. Try again in 42s.", "code": "RATE_LIMITED", "requestId": "", "details": { "conversationId": "", "retryAfterSeconds": 42 } } ``` # Approve Milestone Source: https://opentrain.ai/docs/developers/api-reference/milestones/approve POST /api/public/v1/milestones/{milestoneId}/approve Request payment release for a funded milestone — returns a pending approval a human must co-sign before payout. Requests payment release for an `ACTIVE_FUNDED` milestone — typically after the AI trainer has delivered the work. **This call never releases money.** It records a pending [approval](/docs/developers/concepts/human-approvals) (`type: "milestone_approve"`) and returns `202`; a signed-in human must open `approval.approvalUrl` and confirm in the OpenTrain app before the escrowed funds pay out. Approvals expire after \~72 hours. Re-requesting while a pending approval exists returns the same approval (idempotent). Learn the outcome by polling [`GET /approvals/{id}`](/docs/developers/api-reference/approvals/get) or watching for the `approval.confirmed` event on [`GET /updates`](/docs/developers/api-reference/updates/poll). The request has no body. **Requirements:** `payments:write` scope + the `public_api_payments_write` feature + a **claimed** account (unclaimed accounts get `403` with `details.reason: "account_claim_required"` and a `claimUrl`). The milestone must be `ACTIVE_FUNDED` — [fund it](/docs/developers/api-reference/milestones/fund) first. ## Request The milestone to release payment for. Must be `ACTIVE_FUNDED`, not cancelled, on an active contract you own. ## Response Returns `202` — the request is recorded, nothing has been released yet. The pending approval, in the same shape as [`GET /approvals/{id}`](/docs/developers/api-reference/approvals/get): `{id, type: "milestone_approve", status: "pending", contractId, milestoneId, jobId, proposalId: null, approvalUrl, expiresAt, resolvedAt, result, createdAt}`. Explains that a signed-in human must confirm the approval before any money moves. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:write` scope, `public_api_payments_write` disabled, or account not claimed (`details.reason: "account_claim_required"`, `details.claimUrl`) | | `404` | `NOT_FOUND` | No such milestone, or its contract is on another account | | `409` | `CONFLICT` | See the reason catalog below | ### `409` reason catalog | `details.reason` | Meaning | Extra `details` fields | | -------------------------- | ------------------------------------------------------------------------ | ---------------------- | | `milestone_not_funded` | Milestone is `NOT_FUNDED` — fund it before requesting release | `status` | | `milestone_not_releasable` | Milestone is in some other non-releasable state (e.g. already completed) | `status` | | `milestone_cancelled` | Milestone has been cancelled | — | | `contract_ended` | The contract has ended | `contractId` | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/milestones//approve \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain milestones approve --milestone-id --json ``` ```json MCP: opentrain_request_milestone_approval theme={null} { "milestoneId": "" } ``` ```json 202 theme={null} { "approval": { "id": "", "type": "milestone_approve", "status": "pending", "contractId": "", "milestoneId": "", "jobId": "", "approvalUrl": "https://app.opentrain.ai/approvals/", "expiresAt": "2026-06-15T10:00:00.000Z", "resolvedAt": null, "result": null, "createdAt": "2026-06-12T10:00:00.000Z" }, "message": "Release request recorded. A signed-in human must confirm this approval in the OpenTrain app before any money moves." } ``` ```json 409 (not funded yet) theme={null} { "error": "Milestone must be funded before it can be approved for release", "code": "CONFLICT", "requestId": "", "details": { "milestoneId": "", "status": "NOT_FUNDED", "reason": "milestone_not_funded" } } ``` # Fund Milestone Source: https://opentrain.ai/docs/developers/api-reference/milestones/fund POST /api/public/v1/milestones/{milestoneId}/fund Request escrow funding for an unfunded milestone — returns a pending approval a human must co-sign before money moves. Requests escrow funding for a `NOT_FUNDED` milestone. **This call never moves money.** It records a pending [approval](/docs/developers/concepts/human-approvals) (`type: "milestone_fund"`) and returns `202`; a signed-in human must open `approval.approvalUrl` and confirm in the OpenTrain app before the milestone is funded. Approvals expire after \~72 hours. Re-requesting while a pending approval exists returns the same approval (idempotent). Learn the outcome by polling [`GET /approvals/{id}`](/docs/developers/api-reference/approvals/get) or watching for the `approval.confirmed` event on [`GET /updates`](/docs/developers/api-reference/updates/poll). The request has no body. Funding is drawn per the account's billing setup — see [credits & billing](/docs/developers/concepts/credits-and-billing). **Requirements:** `payments:write` scope + the `public_api_payments_write` feature + a **claimed** account (unclaimed accounts get `403` with `details.reason: "account_claim_required"` and a `claimUrl`) + a payment method on file (`409` with `details.reason: "payment_method_required"` otherwise). ## Request The milestone to fund. Must be `NOT_FUNDED`, not cancelled, on an active contract you own. ## Response Returns `202` — the request is recorded, nothing has been charged yet. The pending approval, in the same shape as [`GET /approvals/{id}`](/docs/developers/api-reference/approvals/get): `{id, type: "milestone_fund", status: "pending", contractId, milestoneId, jobId, proposalId: null, approvalUrl, expiresAt, resolvedAt, result, createdAt}`. Explains that a signed-in human must confirm the approval before any money moves. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:write` scope, `public_api_payments_write` disabled, or account not claimed (`details.reason: "account_claim_required"`, `details.claimUrl`) | | `404` | `NOT_FOUND` | No such milestone, or its contract is on another account | | `409` | `CONFLICT` | See the reason catalog below | ### `409` reason catalog | `details.reason` | Meaning | Extra `details` fields | | ------------------------- | ----------------------------------------------------------------------- | ---------------------- | | `milestone_not_fundable` | Milestone is not `NOT_FUNDED` (e.g. already funded) | `status` | | `payment_method_required` | No payment method on file; a human must add a card in the OpenTrain app | `billingUrl` | | `milestone_cancelled` | Milestone has been cancelled | — | | `contract_ended` | The contract has ended | `contractId` | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/milestones//fund \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain milestones fund --milestone-id --json ``` ```json MCP: opentrain_request_milestone_funding theme={null} { "milestoneId": "" } ``` ```json 202 theme={null} { "approval": { "id": "", "type": "milestone_fund", "status": "pending", "contractId": "", "milestoneId": "", "jobId": "", "approvalUrl": "https://app.opentrain.ai/approvals/", "expiresAt": "2026-06-15T10:00:00.000Z", "resolvedAt": null, "result": null, "createdAt": "2026-06-12T10:00:00.000Z" }, "message": "Funding request recorded. A signed-in human must confirm this approval in the OpenTrain app before any money moves." } ``` ```json 409 (payment method required) theme={null} { "error": "No payment method on file. A human must add a card in the OpenTrain app before milestone funding can be requested.", "code": "CONFLICT", "requestId": "", "details": { "milestoneId": "", "reason": "payment_method_required", "billingUrl": "https://app.opentrain.ai/employer-settings?tab=billing" } } ``` # API Reference Overview Source: https://opentrain.ai/docs/developers/api-reference/overview Base URLs, authentication, error envelope, and a map of every OpenTrain Public API endpoint. Every page in this reference documents one endpoint by hand: parameters, response fields, scope and feature requirements, examples, and the errors you can actually hit. The machine-readable contract of record is the served spec — if a page and the spec ever disagree, the spec wins: ```text theme={null} GET https://app.opentrain.ai/api/public/v1/openapi.json ``` ## Base URL and Authentication ```text theme={null} https://app.opentrain.ai/api/public/v1 ``` All endpoints (except the [tokenless ones](#tokenless-endpoints) below) require a personal API token in the `Authorization` header: ```bash theme={null} curl -sS https://app.opentrain.ai/api/public/v1/auth/me \ -H "Authorization: Bearer $OT_API_TOKEN" ``` Tokens start with `ot_pat_` and come from [agent registration](/docs/developers/api-reference/agent-auth/register), the [token management API](/docs/developers/api-reference/tokens/create), or in-app settings. See [Authentication](/docs/developers/concepts/authentication) for the full lifecycle including the claim ceremony. The four **agent auth** endpoints sit outside `/v1` (at `/api/agent/...`) and use the OAuth wire shape (`{"error": "...", "error_description": "..."}`) instead of the envelope below. ## The Error Envelope Every non-2xx response from `/api/public/v1/...` has the same JSON shape: ```json theme={null} { "error": "Human-readable message", "code": "FORBIDDEN", "requestId": "9d1f3a8e-...", "details": { "reason": "account_claim_required", "claimUrl": "https://app.opentrain.ai/claim" } } ``` Include `requestId` when contacting support. The `code` enum, the `details.reason` catalog, cursor pagination, and rate limits are documented in [Errors, Pagination, and Limits](/docs/developers/concepts/errors-pagination-limits). Each endpoint page lists the errors specific to that endpoint. ## Requirements Stated Per Page At the top of each endpoint page you'll find what that call needs: | Requirement | Meaning | | ------------------- | ---------------------------------------------------------------------------------------------------------------------- | | **Scope** | The token scope checked for this call (`:write` scopes imply the matching `:read`) | | **Feature** | An account-level feature flag — probe at runtime via [capabilities](/docs/developers/api-reference/job-drafts/capabilities) | | **Claimed account** | The endpoint refuses unclaimed agent accounts with `403` + `details.reason = "account_claim_required"` | See [Scopes and Capabilities](/docs/developers/concepts/scopes-and-capabilities) for the full matrix. ## Endpoint Map | Family | Endpoints | What it covers | | ----------------------------------------------------------- | --------- | ------------------------------------------------------------------------------- | | [Agent auth](/docs/developers/api-reference/agent-auth/register) | 4 | Anonymous registration, claim ceremony, token polling, revocation | | [Auth](/docs/developers/api-reference/auth/me) | 1 | Who am I — token, scopes, account | | [Jobs](/docs/developers/api-reference/jobs/search) | 10 | Marketplace search, your jobs, publish/close/update, invites, per-job proposals | | [Job drafts](/docs/developers/api-reference/job-drafts/create) | 3 | Description-first drafting, gap-fill PATCH, capabilities probe | | [Proposals](/docs/developers/api-reference/proposals/get) | 4 | Proposal detail, AI interview transcript, pre-hire conversation, hire | | [Freelancers](/docs/developers/api-reference/freelancers/get) | 1 | Public profile by ID or slug | | [Messages](/docs/developers/api-reference/messages/list) | 2 | Read and send messages in your conversations | | [Contracts](/docs/developers/api-reference/contracts/list) | 4 | Contract reads, milestone creation, ending contracts | | [Milestones](/docs/developers/api-reference/milestones/fund) | 2 | Co-signed funding and approval | | [Approvals](/docs/developers/api-reference/approvals/get) | 1 | Track pending human approvals | | [Updates](/docs/developers/api-reference/updates/poll) | 1 | The pollable event delta feed | | [Credits](/docs/developers/api-reference/credits/balance) | 4 | Balance, ledger, Stripe top-ups | | [Payments](/docs/developers/api-reference/payments/pending) | 1 | Invoices awaiting action | | [Webhooks](/docs/developers/api-reference/webhooks/list) | 4 | Push-delivery subscriptions | | [Team](/docs/developers/api-reference/team/list) | 2 | Organization members and invites | | [Tokens](/docs/developers/api-reference/tokens/list) | 3 | Token management and rotation | ## Tokenless Endpoints Three marketplace read endpoints accept requests without any `Authorization` header (a token is still accepted and never hurts): * [`GET /jobs`](/docs/developers/api-reference/jobs/search) — search published jobs * [`GET /jobs/facets`](/docs/developers/api-reference/jobs/facets) — filter facets with counts * [`GET /jobs/changes`](/docs/developers/api-reference/jobs/changes) — jobs changed since a timestamp ## About the Playground Endpoint pages display requests and responses in read-only form — there is no authenticated "try it" console, because the API does not accept cross-origin `Authorization` headers from the docs site. Copy the curl examples instead; every page also shows the CLI and MCP equivalents where they exist. ## Related Zero to a live job with nothing but curl. The envelope, code enum, reason catalog, cursors, and rate limits. Token types, the claim ceremony, and rotation. Every machine-readable surface, including both served OpenAPI specs. # List Pending Payments Source: https://opentrain.ai/docs/developers/api-reference/payments/pending GET /api/public/v1/payments/pending List invoices awaiting action on your account: milestone funding you owe, or payouts in flight. Lists incomplete invoices visible to your account, newest first. This is a strictly read-only surface — it never releases funds, refunds charges, or changes payment settings. Each entry is shown from your perspective: `PENDING_FUNDING` means a funding invoice awaits payment on your side (as the employer), `PENDING_PAYOUT` means a payout to you is in flight (relevant when the token owner is also paid as an AI trainer). Use [`GET /approvals/{id}`](/docs/developers/api-reference/approvals/get) and [`GET /updates`](/docs/developers/api-reference/updates/poll) to track the co-sign approvals that resolve funding items. **Requirements:** `payments:read` scope. Works pre-claim. No parameters. ## Response Invoice ID. `PENDING_FUNDING` (you owe an escrow payment) or `PENDING_PAYOUT` (a payout to you is in flight). `FUNDING` or `PAYOUT`. Your role on the invoice: `EMPLOYER`, `FREELANCER`, or `BOTH`. Invoice amount in USD. ISO creation timestamp. Sequential invoice number. `{id, title}` of the linked job (`null` fields when not job-linked). `{id, name}` of the linked milestone (`null` fields when not milestone-linked). ## Errors | Status | `code` | Meaning | | ------ | -------------- | ----------------------------- | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `payments:read` scope | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/payments/pending \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain payments pending --json ``` ```json MCP: opentrain_list_pending_payments theme={null} {} ``` ```json 200 theme={null} { "payments": [ { "id": "", "status": "PENDING_FUNDING", "invoiceKind": "FUNDING", "viewerRole": "EMPLOYER", "amountUsd": 300, "createdAt": "2026-06-12T10:05:00.000Z", "invoiceNumber": 1042, "job": { "id": "", "title": "Spanish tweet sentiment labeling" }, "milestone": { "id": "", "name": "First labeling batch" } } ] } ``` # Start Proposal Conversation Source: https://opentrain.ai/docs/developers/api-reference/proposals/conversation POST /api/public/v1/proposals/{proposalId}/conversation Open (or reuse) the pre-hire direct-message thread with a candidate. Employer-first; idempotent. Opens the pre-hire direct-message thread for one of your proposals so you can talk to the candidate before hiring. Idempotent: if the thread already exists, you get the same `conversationId` back with `created: false`. Once you have the ID, send with [`POST /messages`](/docs/developers/api-reference/messages/send) and read with [`GET /messages`](/docs/developers/api-reference/messages/list). Proposal conversations are **employer-first**: only the job owner can open the thread, and the candidate cannot reply until the employer has sent the first message. This endpoint never creates any other kind of conversation — job threads come from [hiring](/docs/developers/api-reference/proposals/hire). **Requirements:** `messages:write` scope + the `public_api_messaging_writes` feature + a **claimed** account (unclaimed accounts get `403` with `details.reason: "account_claim_required"` and a `claimUrl`). The proposal must be on a job you own. ## Request The proposal whose thread to open. No body. ## Response `true` on success. The conversation to use with the [messages endpoints](/docs/developers/api-reference/messages/send). The proposal ID. The job the proposal belongs to. `true` when a new thread was created, `false` when an existing one was returned. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Missing `proposalId` | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `messages:write` scope, feature disabled, account not claimed (`details.reason: "account_claim_required"`, `details.claimUrl`), the proposal is on another account's job, or you are the candidate (`details.reason: "employer_first_message_required"` — only the employer opens the thread) | | `404` | `NOT_FOUND` | No such proposal | | `409` | `CONFLICT` | Proposal is not ready for messaging — `details.reason: "proposal_not_ready_for_messaging"` (e.g. missing invited user or linked job) | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/proposals//conversation \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain messages start-proposal-thread --proposal-id --json ``` ```json MCP: opentrain_start_proposal_conversation theme={null} { "proposalId": "" } ``` ```json 200 theme={null} { "ok": true, "conversationId": "", "proposalId": "", "jobId": "", "created": true } ``` # Get Proposal Source: https://opentrain.ai/docs/developers/api-reference/proposals/get GET /api/public/v1/proposals/{proposalId} Read one proposal in depth: bid, AI-interview score and summary, location/identity verification, assessment results, and contract state. Reads a single proposal in full — everything from [`GET /jobs/{id}/proposals`](/docs/developers/api-reference/jobs/list-proposals) plus the AI-interview block, location/identity verification, labeling-assessment results, and the contract created if you already hired. This is the deep-dive step of the [candidate evaluation flow](/docs/developers/guides/evaluate-candidates). Candidate identity stays masked pre-hire (first name + last initial). Resume files, personal emails, phone numbers, and payment details are never returned — see [privacy](/docs/developers/concepts/privacy-and-work-email). For the full interview transcript, use [`GET /proposals/{proposalId}/interview`](/docs/developers/api-reference/proposals/interview). **Requirements:** `proposals:read` scope. The proposal must be on a job you own (`403` otherwise). Works pre-claim. ## Request The proposal ID, e.g. from the [proposals list](/docs/developers/api-reference/jobs/list-proposals). ## Response Proposal ID. The job this proposal belongs to. Job title (denormalized for convenience). ISO submission timestamp. ISO last-change timestamp. `{raw, label}` — machine value (e.g. `SHORTLISTED`) plus display label. `{amountUsd, unit, labelerHourlyRateUsd}` — the candidate's asking rate and its unit. Privacy-safe candidate summary: `{id, profileSlug, displayName, firstName, lastNameInitial, profileTitle, profilePhotoUrl, countryCode, country, talentType, highestEarningsUsd, reviewCount}` — same shape as the [proposals list](/docs/developers/api-reference/jobs/list-proposals). Use `id` or `profileSlug` with [`GET /freelancers/{idOrSlug}`](/docs/developers/api-reference/freelancers/get) for the full profile. `{interviewScore, matchScore}` — AI-interview score and job-match score, `null` when not available. `null` when the candidate has not taken the AI interview. Normalized interview score. AI-written summary of the interview. `true` when a transcript exists — fetch it via [`GET /proposals/{proposalId}/interview`](/docs/developers/api-reference/proposals/interview). `{status, method, checkedAt}` — `status` is `PASSED`, `FAILED`, or `NOT_RECORDED`; `method` is `GPS`, `ID_DOCUMENT`, or `MIXED_SIGNALS` (`null` when `NOT_RECORDED`); `checkedAt` is an ISO timestamp or `null`. `{verified}` — `true` when the candidate completed identity verification. Latest result from OpenTrain's built-in labeling assessment, when the candidate completed one for this proposal: `{status, score, employerRating, completedAt}` (each `null`-able). `null` when no assessment exists. `{id, status}` with `status` of `ACTIVE` or `ENDED` when you already hired from this proposal — track it via [`GET /contracts/{id}`](/docs/developers/api-reference/contracts/get). `null` pre-hire. ## Errors | Status | `code` | Meaning | | ------ | -------------- | --------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Missing `proposalId` | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `proposals:read` scope, or the proposal is on another account's job | | `404` | `NOT_FOUND` | No such proposal | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/proposals/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain proposals get --proposal-id --json ``` ```json MCP: opentrain_get_proposal theme={null} { "proposalId": "" } ``` ```json 200 theme={null} { "proposal": { "id": "", "jobId": "", "jobTitle": "Spanish Sentiment Labeling — Social Media Posts", "createdAt": "2026-06-11T15:40:00.000Z", "updatedAt": "2026-06-12T09:05:00.000Z", "status": { "raw": "SHORTLISTED", "label": "Shortlisted" }, "bid": { "amountUsd": 0.06, "unit": "PER_LABEL", "labelerHourlyRateUsd": null }, "candidate": { "id": "", "profileSlug": "maria-g", "displayName": "Maria G.", "firstName": "Maria", "lastNameInitial": "G", "profileTitle": "Text Annotation Specialist", "profilePhotoUrl": "https://app.opentrain.ai/", "countryCode": "ES", "country": "Spain", "talentType": "FREELANCER", "highestEarningsUsd": 2400, "reviewCount": 7 }, "metrics": { "interviewScore": 86, "matchScore": 0.91 }, "aiInterview": { "score": 86, "summary": "Strong grasp of sentiment-labeling edge cases; clear communication; native Spanish speaker with prior social-media annotation experience.", "transcriptAvailable": true }, "verification": { "location": { "status": "PASSED", "method": "GPS", "checkedAt": "2026-06-11T15:38:00.000Z" }, "identity": { "verified": true } }, "openLabel": null, "contract": null } } ``` # Hire From Proposal Source: https://opentrain.ai/docs/developers/api-reference/proposals/hire POST /api/public/v1/proposals/{proposalId}/hire Request a hire — records a pending approval a human must co-sign in the OpenTrain app before the contract is created and any money moves. Requests a hire of the candidate behind one of your proposals. **This call never hires anyone or moves money.** It records a pending [approval](/docs/developers/concepts/human-approvals) (`type: "proposal_hire"`) and returns `202`; a signed-in human must open `approval.approvalUrl` and confirm in the OpenTrain app. Approvals expire after \~72 hours. When the human confirms, OpenTrain accepts the proposal, creates the contract, creates the first escrow milestone, and funds it — the milestone amount plus a **10% marketplace fee** plus a **\$9.95 contract initiation fee** (see [credits & billing](/docs/developers/concepts/credits-and-billing)). The human chooses the payment source (card or credit balance) at confirm time. Funds are held, not paid out; payout happens when you [approve the milestone](/docs/developers/api-reference/milestones/approve) later. Re-requesting with the same milestone terms while a pending approval exists returns the same approval (idempotent). Re-requesting with **different** terms supersedes the old approval, so the human only ever sees one live hire request per proposal. Learn the outcome by polling [`GET /approvals/{id}`](/docs/developers/api-reference/approvals/get) or watching for the `approval.confirmed` event on [`GET /updates`](/docs/developers/api-reference/updates/poll); once confirmed, the contract appears in [`GET /contracts`](/docs/developers/api-reference/contracts/list) with the post-hire job conversation. The AI trainer's identity stays masked — first name + last initial, never an email (see [privacy](/docs/developers/concepts/privacy-and-work-email)). **Requirements:** `proposals:write` scope + the `public_api_hiring` feature + a **claimed** account (unclaimed accounts get `403` with `details.reason: "account_claim_required"` and a `claimUrl`) + a payment method on file or a [credit balance](/docs/developers/api-reference/credits/balance) covering the full charge (`409` with `details.reason: "payment_method_required"` otherwise). The proposal must be on a job you own. ## Request The proposal to hire from. The first escrow milestone for the new contract. At least one of `name` or `description` must be non-empty. Short milestone name (max 500 chars). Description of the work to deliver (max 2,000 chars). Milestone amount in USD. Must be positive. The escrow hold at confirm time covers this amount plus fees. Optional due date (ISO 8601). Set `true` to confirm hiring a proposal you previously marked **Not a fit**, after receiving a `409` with `details.reason: "not_fit_confirmation_required"`. ## Response Returns `202` — the request is recorded; nothing has been hired or charged yet. The pending approval, in the same shape as [`GET /approvals/{id}`](/docs/developers/api-reference/approvals/get): `{id, type: "proposal_hire", status: "pending", contractId: null, milestoneId: null, jobId, proposalId, approvalUrl, expiresAt, resolvedAt, result, createdAt}`. `contractId` stays `null` until the human confirms and the contract is created. Explains that a signed-in human must confirm the approval before the freelancer is hired and any money moves. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | `BAD_REQUEST` | Empty body, invalid JSON, or invalid `milestone` (`details.issues` carries zod details — e.g. missing `amount`, or neither `name` nor `description` provided) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `proposals:write` scope, `public_api_hiring` disabled, account not claimed (`details.reason: "account_claim_required"`, `details.claimUrl`), or the proposal is on another account's job | | `404` | `NOT_FOUND` | No such proposal | | `409` | `CONFLICT` | Hire request blocked — see the `details.reason` catalog below | ### `409` reason catalog | `details.reason` | Meaning | Extra `details` fields | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | | `payment_method_required` | No payment method on file and the credit balance doesn't cover the full charge; a human must add a card or credits in the OpenTrain app | `billingUrl` | | `already_accepted` | The proposal was already hired | — | | `not_fit_confirmation_required` | Proposal is marked Not a fit; retry with `confirmNotFitOverride: true` | — | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/proposals//hire \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "milestone": { "name": "First labeling batch", "description": "Label the first 5,000 posts per the guidelines", "amount": 300, "dueDate": "2026-07-01" } }' ``` ```bash CLI theme={null} opentrain proposals hire --proposal-id \ --amount 300 \ --milestone-name "First labeling batch" \ --milestone-description "Label the first 5,000 posts per the guidelines" \ --due-date 2026-07-01 --json ``` ```json MCP: opentrain_hire_proposal theme={null} { "proposalId": "", "milestone": { "name": "First labeling batch", "description": "Label the first 5,000 posts per the guidelines", "amount": 300, "dueDate": "2026-07-01" } } ``` ```json 202 theme={null} { "approval": { "id": "", "type": "proposal_hire", "status": "pending", "contractId": null, "milestoneId": null, "jobId": "", "proposalId": "", "approvalUrl": "https://app.opentrain.ai/approvals/", "expiresAt": "2026-06-15T10:00:00.000Z", "resolvedAt": null, "result": null, "createdAt": "2026-06-12T10:00:00.000Z" }, "message": "Hire request recorded. A signed-in human must confirm this approval in the OpenTrain app before the freelancer is hired and any money moves." } ``` ```json 409 (payment method required) theme={null} { "error": "No payment method on file. A human must add a card or credits in the OpenTrain app before a hire can be requested.", "code": "CONFLICT", "requestId": "", "details": { "proposalId": "", "reason": "payment_method_required", "billingUrl": "https://app.opentrain.ai/employer-settings?tab=billing" } } ``` # Get Interview Transcript Source: https://opentrain.ai/docs/developers/api-reference/proposals/interview GET /api/public/v1/proposals/{proposalId}/interview Read the full sanitized AI-interview transcript for a proposal, message by message. Returns the complete AI-interview transcript for a proposal — every exchange between the AI interviewer and the candidate, oldest first — plus the score and summary. Check `aiInterview.transcriptAvailable` on [`GET /proposals/{proposalId}`](/docs/developers/api-reference/proposals/get) first; this endpoint returns `404` when no interview is recorded. Transcripts are sanitized before they leave the platform: contact details and other personal identifiers are scrubbed — see [privacy](/docs/developers/concepts/privacy-and-work-email). **Requirements:** `proposals:read` scope. The proposal must be on a job you own (`403` otherwise). Works pre-claim. ## Request The proposal ID. ## Response The proposal this interview belongs to. The interview record's ID. Normalized interview score (same value as `aiInterview.score` on the proposal detail). AI-written summary of the interview. Transcript entries ordered oldest first. Message ID. `interviewer` (the AI) or `candidate`. Sanitized message text. ISO timestamp. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ----------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Missing `proposalId` | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `proposals:read` scope, or the proposal is on another account's job | | `404` | `NOT_FOUND` | No such proposal, or no AI interview is recorded for this proposal (`details.proposalId`) | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/proposals//interview \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain proposals get --proposal-id --interview --json ``` ```json MCP: opentrain_get_proposal theme={null} { "proposalId": "", "includeInterview": true } ``` ```json 200 theme={null} { "interview": { "proposalId": "", "interviewId": "", "score": 86, "summary": "Strong grasp of sentiment-labeling edge cases; clear communication; native Spanish speaker with prior social-media annotation experience.", "messages": [ { "id": "", "role": "interviewer", "content": "Tell me about your experience labeling sentiment in social media posts.", "sentAt": "2026-06-11T15:20:00.000Z" }, { "id": "", "role": "candidate", "content": "I spent two years annotating Spanish-language tweets for a sentiment model, including sarcasm and mixed-sentiment cases.", "sentAt": "2026-06-11T15:21:00.000Z" } ] } } ``` ```json 404 (no interview) theme={null} { "error": "No AI interview is recorded for this proposal", "code": "NOT_FOUND", "requestId": "", "details": { "proposalId": "" } } ``` # Poll Updates Source: https://opentrain.ai/docs/developers/api-reference/updates/poll GET /api/public/v1/updates The authenticated delta feed: fetch every account event since a cursor in one call — proposals, messages, contracts, milestones, payments, approvals. Fetches everything that happened on your account since your last poll in one cheap call, instead of re-reading every resource. Events are returned oldest first; pass the returned `nextCursor` on the next poll to receive only newer events. Payloads carry **IDs only** — fetch details with the matching read endpoint. [Webhooks](/docs/developers/api-reference/webhooks/create) push these same events; the recommended architecture uses the webhook as the trigger and this feed as the source of truth — see [stay in sync](/docs/developers/guides/stay-in-sync). For tokenless public marketplace changes, use [`GET /jobs/changes`](/docs/developers/api-reference/jobs/changes) instead. **Requirements:** at least one of `proposals:read`, `messages:read`, or `payments:read` — each event type is visible only with its scope (below). Works pre-claim. ## Event types | `type` | Required scope | Key `data` fields | | ------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `proposal.received` | `proposals:read` | `proposalId`, `jobId` | | `proposal.status_changed` | `proposals:read` | `proposalId`, `status` | | `message.received` | `messages:read` | `conversationId`, `messageId` | | `contract.created` | `payments:read` | `contractId`, `jobId`, `proposalId` | | `milestone.status_changed` | `payments:read` | `milestoneId`, `contractId`, `status` | | `payment.pending` | `payments:read` | `invoiceId`, `milestoneId`, `contractId` | | `approval.confirmed` | `payments:read` | `approvalId`, `status` | | `contract.budget_state_changed` | `payments:read` | `contractId`, `previousState`, `state`, `paymentType`, `fundedVolume`, `consumedVolume`, `remainingVolume`, `consumedFraction` | Event types your token cannot read are silently filtered out of the feed; they are not an error. A token with none of the three scopes gets `403`. ## Request The `nextCursor` from your previous poll (a numeric event ID). Omit on the first poll to start from the beginning of your account's event history. Max events to return, 1–200. ## Response Monotonically increasing numeric event ID — this is what cursors point at. One of the eight event types above. `v1`. ISO timestamp of the event. ID of the primary affected resource (e.g. the proposal ID for `proposal.received`). The related job, when the event is job-linked. IDs-only payload (see the table above) — never message bodies or other content. Persist this and pass it as `cursor` on the next poll. When the page is empty it echoes your input cursor (`null` on a first poll with no events). `true` when more events already exist beyond this page — poll again immediately with `nextCursor`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------ | | `400` | `BAD_REQUEST` | `cursor` is not a numeric event ID (`details.field: "cursor"`), or `limit` outside 1–200 (`details.field: "limit"`) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Token has none of `proposals:read`, `messages:read`, `payments:read` (`details.requiredScopes`, `details.grantedScopes`) | ```bash curl theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/updates?cursor=$LAST_CURSOR&limit=50" \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain updates poll --cursor --json ``` ```json MCP: opentrain_poll_updates theme={null} { "cursor": "" } ``` ```json 200 theme={null} { "events": [ { "id": "1042", "type": "proposal.received", "apiVersion": "v1", "createdAt": "2026-06-12T10:00:00.000Z", "resourceId": "", "jobId": "", "data": { "proposalId": "", "jobId": "" } }, { "id": "1043", "type": "approval.confirmed", "apiVersion": "v1", "createdAt": "2026-06-12T10:05:00.000Z", "resourceId": "", "jobId": "", "data": { "approvalId": "", "status": "confirmed" } } ], "nextCursor": "1043", "hasMore": false } ``` # Create Webhook Source: https://opentrain.ai/docs/developers/api-reference/webhooks/create POST /api/public/v1/webhooks Subscribe an HTTPS endpoint to account events. The HMAC signing secret is returned once — store it immediately. Subscribes a URL to platform events — push delivery of the same events [`GET /updates`](/docs/developers/api-reference/updates/poll) serves. Each delivery is signed with HMAC-SHA256 via the `X-OpenTrain-Signature` header; verify it with the [signature guide](/docs/developers/guides/verify-webhook-signatures). **The `secret` is returned only in this response.** It cannot be retrieved later — if lost, [delete](/docs/developers/api-reference/webhooks/delete) the subscription and create a new one. **There is no backfill:** a new subscription starts at the current event high-water mark and only receives events created after it; older events remain reachable via [`GET /updates`](/docs/developers/api-reference/updates/poll). Accounts can hold at most **10** subscriptions. For the polling-vs-webhooks decision framework and the full delivery lifecycle, see [stay in sync](/docs/developers/guides/stay-in-sync). **Requirements:** `webhooks:manage` scope + the `public_api_webhooks` feature + the matching read scope for every subscribed event type (e.g. `proposal.received` needs `proposals:read`). ## Request Absolute `https` URL that will receive deliveries (max 2048 chars). Plain `http` is allowed only for `localhost` during development. Non-empty array of event types from the [event catalog](/docs/developers/api-reference/updates/poll): `proposal.received`, `proposal.status_changed`, `message.received`, `contract.created`, `milestone.status_changed`, `payment.pending`, `approval.confirmed`, `contract.budget_state_changed`. Duplicates are de-duplicated. ## Response The subscription: `{id, url, eventTypes, status: "ACTIVE", createdAt, disabledAt, disabledReason}` — same shape as [`GET /webhooks/{id}`](/docs/developers/api-reference/webhooks/get). The `whsec_…` signing secret. **Shown once — store it now.** Reminds you to store the secret. ## Deliveries Each event is a `POST` to your URL with `Content-Type: application/json`, `User-Agent: OpenTrain-Webhooks/1.0`, and headers `X-OpenTrain-Event` (the event type), `X-OpenTrain-Delivery` (unique delivery ID — dedupe on it), and `X-OpenTrain-Signature` (`t=,v1=`). The body is exactly one [`/updates` event record](/docs/developers/api-reference/updates/poll) — IDs only, never content. Respond with any `2xx` within 10 seconds. Failures retry up to 5 attempts with backoff (1m, 5m, 30m, 120m); after 10 consecutive exhausted deliveries the subscription is auto-disabled — recover by deleting and re-creating it. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Body not valid JSON; `url` missing/invalid/not https (`details.field: "url"`); `eventTypes` empty or contains an unsupported type (`details.supportedEventTypes`) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `webhooks:manage`, missing a subscribed event type's read scope, or `public_api_webhooks` disabled | | `409` | `CONFLICT` | Subscription limit reached (`details: {limit: 10}`) — delete an existing webhook first | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/webhooks \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"] }' ``` ```bash CLI theme={null} opentrain webhooks create \ --url https://example.com/hooks/opentrain \ --events proposal.received,message.received,approval.confirmed --json ``` ```json MCP: opentrain_create_webhook theme={null} { "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"] } ``` ```json 201 theme={null} { "webhook": { "id": "", "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"], "status": "ACTIVE", "createdAt": "2026-06-12T10:00:00.000Z", "disabledAt": null, "disabledReason": null }, "secret": "whsec_", "message": "Store the secret now — it is only returned once. Use it to verify the X-OpenTrain-Signature header on deliveries." } ``` ```json 409 (limit reached) theme={null} { "error": "Webhook subscription limit reached (10). Delete an existing webhook first.", "code": "CONFLICT", "requestId": "", "details": { "limit": 10 } } ``` # Delete Webhook Source: https://opentrain.ai/docs/developers/api-reference/webhooks/delete DELETE /api/public/v1/webhooks/{webhookId} Delete a webhook subscription — deliveries stop immediately and the delivery history is removed. Deletes a webhook subscription. Deliveries stop immediately and the subscription's delivery history is removed with it. Deletion is permanent — there is no undo. This is also the recovery path for two situations: * **Lost secret:** secrets are shown only once at [creation](/docs/developers/api-reference/webhooks/create). Delete the subscription and create a new one to get a fresh `whsec_…` secret (this is also how you rotate a secret). * **Auto-disabled subscription:** a `DISABLED` webhook cannot be re-enabled. Delete it, fix your endpoint, and [create](/docs/developers/api-reference/webhooks/create) a replacement — then catch up on events missed while disabled with [`GET /updates`](/docs/developers/api-reference/updates/poll), since new subscriptions have no backfill. **Requirements:** `webhooks:manage` scope + the `public_api_webhooks` feature. ## Request The webhook subscription ID. ## Response The subscription as it existed at deletion: `{id, url, eventTypes, status, createdAt, disabledAt, disabledReason}` — same shape as [`GET /webhooks/{id}`](/docs/developers/api-reference/webhooks/get). Always `true` on success. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------ | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `webhooks:manage` scope, or `public_api_webhooks` disabled | | `404` | `NOT_FOUND` | No such webhook, or it belongs to another account (`details: {resource: "webhooks", webhookId}`) | ```bash curl theme={null} curl -sS -X DELETE https://app.opentrain.ai/api/public/v1/webhooks/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain webhooks delete --json ``` ```json MCP: opentrain_delete_webhook theme={null} { "webhookId": "" } ``` ```json 200 theme={null} { "webhook": { "id": "", "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"], "status": "ACTIVE", "createdAt": "2026-06-12T10:00:00.000Z", "disabledAt": null, "disabledReason": null }, "deleted": true } ``` ```json 404 theme={null} { "error": "Webhook not found", "code": "NOT_FOUND", "requestId": "", "details": { "resource": "webhooks", "webhookId": "" } } ``` # Get Webhook Source: https://opentrain.ai/docs/developers/api-reference/webhooks/get GET /api/public/v1/webhooks/{webhookId} Read one webhook subscription — check whether it is ACTIVE or has been auto-disabled, and why. Reads one webhook subscription. The most common use is health-checking: if your endpoint has stopped receiving events, read the subscription and check `status` — a `DISABLED` webhook receives no deliveries, and `disabledReason` explains why (typically repeated delivery failures; see the [delivery lifecycle](/docs/developers/api-reference/webhooks/create)). The signing `secret` is **never** returned — it is shown only once at [creation](/docs/developers/api-reference/webhooks/create). If it is lost, [delete](/docs/developers/api-reference/webhooks/delete) the subscription and create a new one. Webhooks belonging to another account return `404`. **Requirements:** `webhooks:manage` scope + the `public_api_webhooks` feature. ## Request The webhook subscription ID. ## Response Webhook subscription ID. The destination URL receiving deliveries. Subscribed event types from the [event catalog](/docs/developers/api-reference/updates/poll). `ACTIVE` or `DISABLED`. ISO creation timestamp. When the subscription was disabled; `null` while `ACTIVE`. Why it was disabled. Recover by [deleting](/docs/developers/api-reference/webhooks/delete) and re-creating the subscription, then catch up on missed events with [`GET /updates`](/docs/developers/api-reference/updates/poll). ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------ | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `webhooks:manage` scope, or `public_api_webhooks` disabled | | `404` | `NOT_FOUND` | No such webhook, or it belongs to another account (`details: {resource: "webhooks", webhookId}`) | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/webhooks/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain webhooks get --json ``` ```json MCP: opentrain_get_webhook theme={null} { "webhookId": "" } ``` ```json 200 (active) theme={null} { "webhook": { "id": "", "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"], "status": "ACTIVE", "createdAt": "2026-06-12T10:00:00.000Z", "disabledAt": null, "disabledReason": null } } ``` ```json 200 (auto-disabled) theme={null} { "webhook": { "id": "", "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received"], "status": "DISABLED", "createdAt": "2026-05-30T08:00:00.000Z", "disabledAt": "2026-06-10T14:20:00.000Z", "disabledReason": "Disabled after repeated delivery failures" } } ``` # List Webhooks Source: https://opentrain.ai/docs/developers/api-reference/webhooks/list GET /api/public/v1/webhooks List every webhook subscription on the account — newest first, secrets never included. Lists every webhook subscription on your account, newest first. Use it to audit what is subscribed before [creating](/docs/developers/api-reference/webhooks/create) another (accounts cap at **10**) and to spot subscriptions that have been auto-disabled. Signing secrets are **never** included — they are shown only once, in the [create](/docs/developers/api-reference/webhooks/create) response. A lost secret cannot be recovered: [delete](/docs/developers/api-reference/webhooks/delete) the subscription and create a new one. **Requirements:** `webhooks:manage` scope + the `public_api_webhooks` feature. ## Request No parameters. The list is not paginated — an account holds at most 10 subscriptions. ## Response Webhook subscription ID. The destination URL receiving deliveries. Subscribed event types from the [event catalog](/docs/developers/api-reference/updates/poll). `ACTIVE` or `DISABLED`. Disabled subscriptions receive no deliveries. ISO creation timestamp. When the subscription was disabled; `null` while `ACTIVE`. Why it was disabled (e.g. repeated delivery failures). Recover by [deleting](/docs/developers/api-reference/webhooks/delete) and re-creating the subscription. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------ | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `webhooks:manage` scope, or `public_api_webhooks` disabled | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/webhooks \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain webhooks list --json ``` ```json MCP: opentrain_list_webhooks theme={null} {} ``` ```json 200 theme={null} { "webhooks": [ { "id": "", "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"], "status": "ACTIVE", "createdAt": "2026-06-12T10:00:00.000Z", "disabledAt": null, "disabledReason": null }, { "id": "", "url": "https://example.com/hooks/old-endpoint", "eventTypes": ["payment.pending"], "status": "DISABLED", "createdAt": "2026-05-30T08:00:00.000Z", "disabledAt": "2026-06-10T14:20:00.000Z", "disabledReason": "Disabled after repeated delivery failures" } ] } ``` # Authentication Source: https://opentrain.ai/docs/developers/concepts/authentication How OpenTrain agent authentication works: registration, personal API tokens, the claim ceremony, and revocation. OpenTrain authentication is built around one idea: **an agent can start working immediately, and a human takes ownership later.** Registration is anonymous and instant; identity-bearing and money-moving actions unlock when a human claims the account through a short verification ceremony. The canonical, in-band version of this protocol is served by the app itself at [`https://app.opentrain.ai/auth.md`](https://app.opentrain.ai/auth.md) — it is versioned with the deployed code and always describes live behavior. This page is the readable deep-dive; the two never disagree by design. You rarely need these endpoints by hand. The [CLI](/docs/developers/quickstart-cli) (`opentrain auth register`) and [MCP server](/docs/developers/quickstart-mcp) (`opentrain_register_agent`) wrap the entire lifecycle, and share credentials at `~/.config/opentrain/cli.json`. ## Token Types | Prefix | What it is | Where it comes from | | --------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `ot_pat_` | Personal API token — the bearer token for every API call | Registration, the claim exchange, `POST /tokens`, or in-app token settings | | `ot_clm_` | Claim token — held by the agent, exchanged for a post-claim `ot_pat_` once a human claims the account | Registration response | | `ot_cat_` | Claim attempt token — embedded in the verification URL the human opens | Claim start response | Send the personal API token on every request: ```text theme={null} Authorization: Bearer ot_pat_... ``` ## Endpoints | Endpoint | Purpose | | --------------------------------------------- | ------------------------------------------------- | | `POST /api/agent/identity` | Anonymous registration | | `POST /api/agent/identity/claim` | Start the claim ceremony | | `POST /api/agent/oauth/token` | Poll for the post-claim token | | `POST /api/agent/oauth/revoke` | Revoke a token (RFC 7009) | | `GET /.well-known/oauth-protected-resource` | RFC 9728 discovery | | `GET /.well-known/oauth-authorization-server` | RFC 8414 discovery + `agent_auth` extension block | ## Registration ```bash theme={null} curl -s -X POST https://app.opentrain.ai/api/agent/identity \ -H "Content-Type: application/json" \ -d '{ "identity_type": "anonymous", "agent_name": "Claude Code", "organization_name": "Acme Research" }' ``` All fields are optional. The response delivers both tokens: ```json theme={null} { "identity_type": "anonymous", "registration_id": "...", "access_token": "ot_pat_...", "token_type": "bearer", "scopes": ["jobs:read", "jobs:write", "proposals:read", "messages:read", "payments:read", "team:read"], "claim_token": "ot_clm_...", "claim_token_expires_at": "...", "claim_endpoint": "https://app.opentrain.ai/api/agent/identity/claim", "token_endpoint": "https://app.opentrain.ai/api/agent/oauth/token", "grant_type": "urn:opentrain:agent-auth:grant-type:claim" } ``` If registration is disabled you receive `{ "error": "anonymous_not_enabled" }`. ## Pre-Claim vs Post-Claim Scopes An unclaimed account can do real work — draft and publish jobs, read proposals, messages, and payment state. Claiming adds the identity-bearing write scopes: | | Scopes | | -------------- | ------------------------------------------------------------------------------------------ | | **Pre-claim** | `jobs:read`, `jobs:write`, `proposals:read`, `messages:read`, `payments:read`, `team:read` | | **Post-claim** | Everything above **plus** `proposals:write`, `messages:write`, `team:write` | Calling an endpoint that needs a claimed account returns `403` with `account_claim_required` and a `claimUrl` — see [Scopes and Capabilities](/docs/developers/concepts/scopes-and-capabilities) for the full matrix. ## The Claim Ceremony The claim ceremony ties the agent account to a human owner using a device-flow-style exchange: ```mermaid theme={null} sequenceDiagram participant A as Agent participant API as OpenTrain API participant H as Human owner A->>API: POST /api/agent/identity/claim {claim_token, email} API-->>A: user_code (6 digits), verification_uri, interval API-->>H: Email with verification link + code A->>H: Show verification_uri + user_code H->>API: Opens /claim, signs in, enters code loop every `interval` seconds A->>API: POST /api/agent/oauth/token (claim grant) API-->>A: 400 authorization_pending end A->>API: POST /api/agent/oauth/token (claim grant) API-->>A: 200 new ot_pat_ with post-claim scopes ``` ### Starting the Claim ```bash theme={null} curl -s -X POST https://app.opentrain.ai/api/agent/identity/claim \ -H "Content-Type: application/json" \ -d '{ "claim_token": "ot_clm_...", "email": "researcher@example.com" }' ``` ```json theme={null} { "user_code": "123456", "verification_uri": "https://app.opentrain.ai/claim?token=ot_cat_...", "expires_in": 1800, "interval": 5, "email_sent": true } ``` This is what your human sees when they open the `verification_uri`: OpenTrain claim page showing a card titled Claim your agent account. The text explains that Northstar Hiring Agent created an OpenTrain account and asks the visitor to sign in or create an account with the claim email to take ownership, then return to enter their 6-digit code. A dark Sign in to continue button sits below the text. Rules that matter in practice: * **Show the human both** the `verification_uri` and the 6-digit `user_code` yourself, even though OpenTrain emails the link (`email_sent` reports whether the email went out) — the email can land in spam. * The human signs in (or creates an OpenTrain account) **with that exact email**, then types the code on the claim page. * The email **must not already have an OpenTrain account** — you'll get `email_already_registered`; use a fresh address. * Posting to the claim endpoint again **restarts** the ceremony with a new code. * The **claim window lasts 24 hours** from registration; each claim attempt is valid for 30 minutes (`expires_in: 1800`). ### Polling for the Post-Claim Token ```bash theme={null} curl -s -X POST https://app.opentrain.ai/api/agent/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=urn:opentrain:agent-auth:grant-type:claim" \ --data-urlencode "claim_token=ot_clm_..." ``` | Response | Meaning | What to do | | ---------------------------------------- | --------------------- | -------------------------- | | `400 {"error": "authorization_pending"}` | Human hasn't finished | Keep polling at `interval` | | `400 {"error": "slow_down"}` | Polling too fast | Increase your interval | | `400 {"error": "expired_token"}` | Claim window over | Re-register | | `200` + new `access_token` | Claimed | Swap tokens (see below) | Two hard rules after a successful claim: 1. **All pre-claim tokens are revoked.** Replace your stored `access_token` with the new one immediately. 2. **The new token is delivered exactly once.** Subsequent polls return `invalid_grant` — if you lose it, mint a replacement via the token management API using a session from the in-app settings. ## Revocation ```bash theme={null} curl -s -X POST https://app.opentrain.ai/api/agent/oauth/revoke \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "token=ot_pat_..." ``` Always returns `200`, even for unknown tokens (RFC 7009). ## Token Management and Rotation Any valid token can manage the account's tokens via the Public API: ```bash theme={null} # List tokens (active, expired, revoked) curl -s https://app.opentrain.ai/api/public/v1/tokens \ -H "Authorization: Bearer $OT_API_TOKEN" # Mint a new token curl -s -X POST https://app.opentrain.ai/api/public/v1/tokens \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "ci-runner", "scopes": ["jobs:read", "proposals:read"], "expiresAt": "2027-01-01T00:00:00Z" }' # Revoke a token curl -s -X DELETE https://app.opentrain.ai/api/public/v1/tokens/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` * `name`, `scopes`, and `expiresAt` are all optional on `POST`. * Requested `scopes` must be a **subset** of the authenticating token's scopes — escalation returns `403`. * The plaintext token appears in the response **exactly once**. The rotation recipe: mint a replacement → switch your integration to it → revoke the old token. Zero downtime, no claim ceremony needed. ### Minting Tokens In-App Humans with a claimed account can also create and revoke API tokens from the OpenTrain app's settings — useful for handing a scoped token to a new integration without any API calls. ## Error Shape Agent-auth endpoints use the OAuth wire shape, distinct from the Public API's envelope: ```json theme={null} { "error": "code", "error_description": "..." } ``` Public API errors (`/api/public/v1/...`) use the structured envelope described in [Errors, Pagination, and Limits](/docs/developers/concepts/errors-pagination-limits). ## Related What each scope unlocks, the claimed-account gate, and runtime feature probing. The machine-readable surfaces (auth.md, well-known metadata) agents use to bootstrap. # Credits and Billing Source: https://opentrain.ai/docs/developers/concepts/credits-and-billing How API hiring is funded: credit balances, top-ups, escrow holds, and the credit ledger. Hiring through the Public API is funded by a **prepaid credit balance**. A human tops up the account with a card via Stripe; the API holds credits in escrow at hire time and captures them as milestones complete. This keeps card details and one-click spending out of agent hands entirely. Credits change how hires are *funded* — they do not bypass [human co-sign](/docs/developers/concepts/human-approvals). Every milestone fund and approve still pauses for a human confirmation. ## The Balance ```bash theme={null} curl -s https://app.opentrain.ai/api/public/v1/credits \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```json theme={null} { "credits": { "availableCents": 250000, "reservedCents": 56945, "currency": "usd", "recentEntries": [ ... ] } } ``` * `availableCents` — spendable right now; new hires draw from this. * `reservedCents` — held in escrow against active hires; released or captured as contracts progress. * `recentEntries` — the last 10 ledger entries, inlined for convenience. ## The Ledger `GET /credits/ledger` pages through the full history (cursor pagination, 50 per page, max 100). Every entry has a `type`: | Type | Direction | When | | -------------- | -------------------- | -------------------------------------------------------------------- | | `TOP_UP` | + available | A Stripe top-up completed | | `HOLD` | available → reserved | A hire reserved escrow | | `HOLD_RELEASE` | reserved → available | A hold was released (hire failed, contract ended with funds unspent) | | `CAPTURE` | − reserved | Reserved credits actually paid out for a milestone | | `REFUND` | + available | Money returned to the balance | | `ADJUSTMENT` | ± | Manual correction by OpenTrain | Each entry links to what caused it via `holdEntryId`, `jobofferId`, `contractId`, `milestoneId`, or `topUpId`, plus an optional `note`. ## Topping Up Top-ups are agent-initiated, human-paid: ```bash theme={null} curl -s -X POST https://app.opentrain.ai/api/public/v1/credits/top-ups \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "amountUsd": 500 }' ``` `amountUsd` must be between **$10 and $10,000**. Requires a [claimed account](/docs/developers/concepts/scopes-and-capabilities#pre-claim-vs-post-claim) and `payments:write`. The `201` response carries a Stripe Checkout link: ```json theme={null} { "topUpId": "", "checkoutUrl": "https://checkout.stripe.com/...", "expiresAt": "...", "topUp": { "id": "", "status": "PENDING", "amountCents": 50000, "createdAt": "...", "completedAt": null, "expiresAt": "..." }, "message": "Top-up created. Show checkoutUrl to your human..." } ``` The human opens `checkoutUrl` and pays with their card. The link expires after about 24 hours. ```bash theme={null} curl -s https://app.opentrain.ai/api/public/v1/credits/top-ups/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` `status` moves `PENDING → COMPLETED` when the payment lands (or `EXPIRED` / `CANCELED`). On completion a `TOP_UP` ledger entry posts and `availableCents` rises. ## What a Hire Costs When your human confirms a [hire approval](/docs/developers/api-reference/proposals/hire), OpenTrain funds the first milestone in escrow, covering: * the milestone amount, plus * a **10% marketplace fee**, plus * a **\$9.95 contract initiation fee**. The human chooses the payment source on the confirmation screen — a card on file or the credit balance. When credits are chosen, the hold moves that total from `availableCents` to `reservedCents` atomically — if anything later fails, a compensating `HOLD_RELEASE` returns it. Captures then draw from the reserve as milestones are approved. ## The 409 You Must Handle A hire **request** is rejected up front only when there's no way to pay: no card on file **and** a credit balance that can't cover the full charge (milestone + fees): ```json theme={null} { "error": "No payment method on file. A human must add a card or credits in the OpenTrain app before a hire can be requested.", "code": "CONFLICT", "requestId": "...", "details": { "proposalId": "...", "reason": "payment_method_required", "billingUrl": "https://app.opentrain.ai/employer-settings?tab=billing" } } ``` Hand your human `details.billingUrl` — or, if they prefer credits, create a [top-up](#topping-up), wait for `COMPLETED`, then re-request the hire. ## Feature Flag All credits endpoints sit behind the credits feature. If it's disabled for your account you'll get a structured error naming the flag — [probe capabilities](/docs/developers/concepts/scopes-and-capabilities#capabilities-runtime-feature-discovery) at startup to know in advance. ## Related The end-to-end flow that consumes these credits. The co-sign step that still gates every money movement. # Errors, Pagination, and Rate Limits Source: https://opentrain.ai/docs/developers/concepts/errors-pagination-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` | Agent-auth endpoints (`/api/agent/...`) use the OAuth wire shape `{"error": "...", "error_description": "..."}` instead — see [Authentication](/docs/developers/concepts/authentication#error-shape). ## 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 The three permission gates behind every 403. Polling /updates with persisted cursors, and when to add webhooks. # Human Approvals (Co-Sign) Source: https://opentrain.ai/docs/developers/concepts/human-approvals How money-moving API actions pause for human confirmation, and how to track approvals to completion. Agents operate; humans authorize spend. Any API action that would move money does not execute immediately — it returns `202 Accepted` with a pending **approval**, and a signed-in human confirms it in the OpenTrain app before anything happens. **Money never moves from an API call alone.** ## Which Actions Are Co-Signed | Action | Endpoint | Approval `type` | | ------------------------------------------------ | ---------------------------------------- | ------------------- | | Hire from a proposal (creates contract + escrow) | `POST /proposals/{proposalId}/hire` | `proposal_hire` | | Fund a milestone (escrow) | `POST /milestones/{milestoneId}/fund` | `milestone_fund` | | Approve a milestone (release payout) | `POST /milestones/{milestoneId}/approve` | `milestone_approve` | | End a contract that has funded milestones | `POST /contracts/{id}/end` | `contract_end` | Non-money writes — creating an *unfunded* milestone, or ending a contract with **no** funded milestones — execute directly with a `200`. Contract end is the dual-mode case: ```json theme={null} // No funded milestones → direct { "ok": true, "contractId": "...", "status": "ended" } ``` ```json theme={null} // Funded milestones exist → co-signed (202) { "approval": { "type": "contract_end", "...": "..." }, "message": "This contract has funded milestones, so a signed-in human must confirm ending it in the OpenTrain app." } ``` ## Anatomy of the 202 Response ```json theme={null} { "approval": { "id": "", "type": "milestone_fund", "status": "pending", "contractId": "...", "milestoneId": "...", "jobId": "...", "proposalId": null, "approvalUrl": "https://app.opentrain.ai/approvals/", "expiresAt": "...", "resolvedAt": null, "result": null, "createdAt": "..." }, "message": "A human must confirm this request before any money moves. Share the approvalUrl." } ``` Your job after a `202`: **surface the `approvalUrl` to your human** — in chat, a notification, wherever they'll see it. They open it, review the amount and context, and confirm or decline. Opening `/approvals/{approvalId}` for a pending `milestone_fund` drops the human into the normal contract view with the funding modal already open and pre-filled — the same interface they use for any other milestone, plus a banner noting the agent requested it: OpenTrain employer dashboard with the Fund and Activate Milestone modal open over the contract view. A banner reads Your agent requested this action, confirm below or decline, with a Decline button. The modal shows the AI trainer Marcus O., the milestone name Production batch 2, a due date, a description, and a button reading Deposit 450 dollars and Activate Milestone. ## The Flow ```mermaid theme={null} sequenceDiagram participant A as Agent participant API as OpenTrain API participant H as Human owner A->>API: POST .../milestones/{id}/fund API-->>A: 202 {approval: {approvalUrl, expiresAt}} A->>H: Share approvalUrl H->>API: Opens /approvals/{id}, signs in, confirms API->>API: Executes the action (funds escrow) API-->>A: approval.confirmed event (via /updates or webhook) A->>API: GET /approvals/{id} API-->>A: status: confirmed, result: {invoiceId, paymentIntentId} ``` ## Tracking an Approval Two complementary mechanisms: **Poll the approval directly:** ```bash theme={null} curl -s https://app.opentrain.ai/api/public/v1/approvals/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` **Watch for the `approval.confirmed` event** in [`/updates` or a webhook](/docs/developers/guides/stay-in-sync) — it fires when the approval resolves (whether confirmed, declined, or expired): ```json theme={null} { "approvalId": "...", "approvalType": "MILESTONE_FUND", "status": "confirmed", "contractId": "...", "milestoneId": "...", "jobId": "..." } ``` ### Status Lifecycle | `status` | Meaning | | ----------- | ----------------------------------------------------------- | | `pending` | Waiting for the human | | `confirmed` | Human approved — the action **has executed**; see `result` | | `declined` | Human rejected it — the action did **not** execute | | `expired` | Nobody acted within the window — the action did not execute | On confirmation, `result` carries the execution evidence: | Type | `result` | | ------------------- | ----------------------------------------------------------------------------------- | | `proposal_hire` | `{ "hired": true, "contractId": "...", "jobId": "...", "freelancerUserId": "..." }` | | `milestone_fund` | `{ "invoiceId": "...", "paymentIntentId": "..." }` | | `milestone_approve` | `{ "invoiceId": "...", "payoutTransactionId": "..." }` | | `contract_end` | `{ "contractEnded": true }` | ## Rules That Matter in Practice * **Approvals expire after \~72 hours.** An expired approval never executes; create a new request if the action is still wanted. * **A decline is final for that approval.** It resolves to `declined`, emits `approval.confirmed` with `status: "declined"`, and nothing executes. Talk to your human before re-requesting. * **Re-requesting is safe and idempotent.** Posting the same fund/approve/end/hire request while an approval is pending returns the **same** approval — no duplicates pile up for the human, and no money moves until a confirm. A hire re-requested with *different* milestone terms supersedes the old approval so only one live hire request exists per proposal. * **`approval.confirmed` fires for every terminal state** — check the `status` field in the payload; the event name does not mean "approved". * **Co-sign applies even with credits on balance.** A funded [credit balance](/docs/developers/concepts/credits-and-billing) changes *how* a hire or milestone is funded, not *whether* a human confirms it. The human picks the payment source — card or credit balance — on the confirmation screen. ## Why It Works This Way Unattended agents shouldn't be able to spend their owner's money on their own — a bug, a prompt injection, or a misread requirement could be expensive. The co-sign pattern keeps the agent in the driver's seat for everything operational (drafting, publishing, evaluating candidates, preparing the hire) while reserving the irreversible steps — hiring a person and money leaving the account — for an authenticated human with full context on one screen. ## Related The full hire → milestone → fund → approve walkthrough. How hires are funded: balances, holds, and top-ups. # Privacy and Work Email Source: https://opentrain.ai/docs/developers/concepts/privacy-and-work-email What identity data the API exposes at each stage, and why AI trainers are reachable only at their @opentrain.work Work Email. The Public API applies one privacy rule consistently: **names are always masked, and personal contact details are never revealed at all.** Every API surface — proposals, profiles, contracts, platform events — shows a first name and last initial, never a full last name. Personal emails you never see — all platform-mediated contact happens through OpenTrain messaging or, for platform integrations, a managed `@opentrain.work` Work Email. ## Masked Identity Everywhere Proposal candidates, AI trainer profiles, and contracts all expose a first name and last initial: ```json theme={null} { "candidate": { "id": "...", "displayName": "Ada L.", "firstName": "Ada", "lastNameInitial": "L.", "profileSlug": "ada-l-1a2b3c", "profileTitle": "Senior Data Annotator", "country": "United Kingdom", "countryCode": "GB" } } ``` If a name is missing entirely, the API falls back to `"Freelancer"` rather than exposing anything else. AI-interview transcripts are sanitized before they reach you — internal control markers are stripped, and an empty transcript reads `"Interview completed."`. ## Post-Hire: Still Masked Hiring does not unmask anyone. Contracts you're party to show the same masked name: ```json theme={null} { "freelancer": { "userId": "...", "displayName": "Ada L.", "country": "United Kingdom", "profilePath": "/profile/ada-l-1a2b3c" } } ``` Full names live only inside the OpenTrain app, where the platform controls how they're displayed — they never cross the API boundary. ## What Is Never Exposed **The Public API returns no email field anywhere.** Not pre-hire, not post-hire, not on profiles, proposals, contracts, messages, or team rosters. Personal email addresses, phone numbers, and off-platform contact details never leave the platform. To reach an AI trainer, use [OpenTrain messaging](/docs/developers/api-reference/messages/send) — it notifies them through their own verified channels. This is deliberate: it protects AI trainers from disintermediation and spam, and it means an agent integration can never accidentally leak a worker's personal contact information, because it never has it. ## Work Email: The Platform Exception The [Platform API](/docs/developers/annotation-platforms/overview) has one carefully-gated channel for direct contact. When an annotation platform provisions a hired AI trainer into its own workspace, it may receive that person's **Work Email** — a platform-issued address ending in `@opentrain.work` that OpenTrain manages and can revoke: ```json theme={null} { "contractId": "...", "participants": [ { "opentrainUserId": "...", "displayName": "Ada L.", "country": "United Kingdom", "profileUrl": "https://app.opentrain.ai/profile/ada-l-1a2b3c", "workEmail": "ada.1234@opentrain.work" } ] } ``` `workEmail` is present only when **both** gates pass: 1. The install holds the `participants:email` scope, **and** 2. The human granted **PII consent** when authorizing the install. Otherwise the request fails `403`: ```json theme={null} { "error": "Install has not granted PII consent for participant work emails", "code": "FORBIDDEN", "details": { "requiredScopes": ["participants:email"], "piiConsent": false } } ``` Even then, only addresses with active provisioning and the `@opentrain.work` suffix are ever returned — a personal address can never slip through this path. ## Designing Your Integration Around This * **Key your records on stable IDs** (`userId` / `opentrainUserId`), never on display names — masked names are not unique and can change. * **Don't build flows that assume you can email candidates.** Pre-hire outreach goes through the [proposal conversation](/docs/developers/guides/evaluate-candidates) endpoint; post-hire communication through contract messaging. * **Platforms: treat Work Email as account provisioning material** (workspace logins, assignment notifications), not as a general contact list. It's consent-gated for a reason, and addresses are deactivated when contracts end. * **Never store or forward personal contact details** an AI trainer might volunteer in a message — the platform guarantee works only if integrations uphold it too. ## Related Working with masked profiles, transcripts, and pre-hire conversations. Where Work Email fits in the platform provisioning lifecycle. # Scopes and Capabilities Source: https://opentrain.ai/docs/developers/concepts/scopes-and-capabilities Token scopes, claimed versus unclaimed accounts, and runtime feature discovery through the capabilities endpoint. Three independent gates decide whether an API call succeeds: 1. **Scopes** — what the token is allowed to do. 2. **Claim status** — whether a human has claimed the account (required for identity-bearing and money-moving writes). 3. **Capabilities** — which feature families are enabled for the account right now. A request must pass all three. This page covers each gate and how to check it before you burn a write attempt. ## The Scopes | Scope | Unlocks | | ----------------- | -------------------------------------------------------------------- | | `jobs:read` | Read your own jobs and drafts | | `jobs:write` | Create, update, publish, close jobs and drafts; invite AI trainers | | `proposals:read` | Read proposals, interview transcripts, AI trainer profiles | | `proposals:write` | Hire, start pre-hire conversations | | `messages:read` | Read conversations and messages | | `messages:write` | Send messages | | `payments:read` | Read contracts, milestones, approvals, credits, pending payments | | `payments:write` | Create/fund/approve milestones, end contracts, create credit top-ups | | `team:read` | Read team members | | `team:write` | Invite team members | | `webhooks:manage` | Create, list, and delete webhook subscriptions | **`:write` implies `:read` for the same resource.** A token with `jobs:write` can call every `jobs:read` endpoint; you never need to request both. A scope failure returns `403` with `code: "FORBIDDEN"` and `details` naming the resource you're missing. ## Pre-Claim vs Post-Claim Tokens from anonymous registration carry the pre-claim set. The [claim ceremony](/docs/developers/concepts/authentication#the-claim-ceremony) upgrades the account: | | Scopes | | -------------- | ------------------------------------------------------------------------------------------ | | **Pre-claim** | `jobs:read`, `jobs:write`, `proposals:read`, `messages:read`, `payments:read`, `team:read` | | **Post-claim** | Everything above **plus** `proposals:write`, `messages:write`, `team:write` | Beyond scopes, some endpoints additionally require that the account **is claimed** — having the scope isn't enough. These are the actions that bind a human's identity or move money: * Hiring a proposal * Inviting an AI trainer to a job * Sending messages and starting pre-hire conversations * Inviting team members * Creating credit top-ups Calling one of these from an unclaimed account returns: ```json theme={null} { "error": "A human must claim this agent account before it can hire AI trainers.", "code": "FORBIDDEN", "requestId": "...", "details": { "reason": "account_claim_required", "action": "hire AI trainers", "claimUrl": "https://app.opentrain.ai/claim" } } ``` When you see `account_claim_required`, start the [claim ceremony](/docs/developers/concepts/authentication#the-claim-ceremony) and retry after the human completes it. ## Scope → Endpoint Matrix | Endpoint family | Read | Write | Claimed? | | ----------------------------------------------------------------------------------- | -------------------------- | ----------------- | ---------------- | | Marketplace job search (`GET /jobs`, `/jobs/facets`, `/jobs/changes`, `/jobs/{id}`) | **None — tokenless** | — | — | | Job drafts (`/job-drafts`, capabilities) | `jobs:read` | `jobs:write` | No | | Your jobs (`/jobs/mine`, update, publish, close) | `jobs:read` | `jobs:write` | No | | Invite AI trainer (`POST /jobs/{id}/invites`) | — | `proposals:write` | **Yes** | | Proposals + interviews + profiles | `proposals:read` | — | No | | Hire (`POST /proposals/{id}/hire`) | — | `proposals:write` | **Yes** | | Pre-hire conversation (`POST /proposals/{id}/conversation`) | — | `messages:write` | **Yes** | | Messages | `messages:read` | `messages:write` | **Yes** (writes) | | Contracts (`GET /contracts`, `GET /contracts/{id}`) | `payments:read` | — | No | | Milestones (create, fund, approve) and contract end | — | `payments:write` | No\* | | Approvals (`GET /approvals/{id}`) | `payments:read` | — | No | | Credits balance + ledger + top-up status | `payments:read` | — | No | | Create top-up (`POST /credits/top-ups`) | — | `payments:write` | **Yes** | | Pending payments | `payments:read` | — | No | | Updates feed (`GET /updates`) | Scope-filtered (see below) | — | No | | Webhooks | `webhooks:manage` | `webhooks:manage` | No | | Team | `team:read` | `team:write` | **Yes** (writes) | | Tokens (`/tokens`) | Any valid token | Any valid token | No | \* Money-moving milestone actions don't require a claimed account at the API layer because they pause for [human co-sign](/docs/developers/concepts/human-approvals) instead — a signed-in human confirms every fund/approve before anything executes. ## Scope-Filtered Event Visibility `GET /updates` and webhook subscriptions only surface events your token can read: | Event type | Required scope | | ------------------------------------------------------------------------------------------------------------------------ | ---------------- | | `proposal.received`, `proposal.status_changed` | `proposals:read` | | `message.received` | `messages:read` | | `contract.created`, `milestone.status_changed`, `payment.pending`, `approval.confirmed`, `contract.budget_state_changed` | `payments:read` | Events for types you can't read are silently filtered out of the feed. A token with **zero** qualifying scopes gets `403` with `details.requiredScopes` listing what would qualify. The same rule applies at webhook-subscription time: subscribing to an event type requires its read scope. ## Capabilities: Runtime Feature Discovery Endpoint families sit behind feature flags that can be on or off per account: publishing, hiring, messaging writes, payments writes, credits, webhooks. Scopes tell you what the *token* may do; capabilities tell you what the *account* can do right now. ```bash theme={null} curl -s https://app.opentrain.ai/api/public/v1/job-drafts/capabilities \ -H "Authorization: Bearer $OT_API_TOKEN" ``` The response includes a `capabilities` object (for example `capabilities.publish`), the accepted job-draft input formats, and the field enums the draft parser understands. Probe capabilities at startup rather than assuming a feature is on. A disabled feature returns a structured error naming the missing flag, but checking first saves you the failed write. ## Daily Publish Limits Publishing through the API is rate-limited per account per rolling 24 hours: | Account | Publishes / 24h | | --------- | --------------- | | Claimed | 20 | | Unclaimed | 3 | Exceeding the limit returns `429` with `code: "RATE_LIMITED"`, a message like `Daily API publish limit reached (20 per 24 hours).`, and `details: { "limit": 20, "windowHours": 24 }`. Claiming the account is the immediate fix for the unclaimed limit. ## Checking Your Own Token `GET /api/public/v1/auth/me` reports the authenticated account, its granted scopes, and claim status in one call — the fastest way to debug a `403`: ```bash theme={null} curl -s https://app.opentrain.ai/api/public/v1/auth/me \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ## Related Where tokens come from, the claim ceremony, and token rotation. Why money-moving calls return 202 and how to track the human co-sign. # Evaluate Candidates Source: https://opentrain.ai/docs/developers/guides/evaluate-candidates Review proposals, AI interview results, freelancer profiles, and pre-hire conversations to choose who to hire. Once your job is live, AI trainers submit proposals. The evaluation surface gives your agent everything it needs to rank candidates without a human in the loop: the bid, an AI screening-interview score and transcript, the candidate's public profile, and a pre-hire message thread for follow-up questions. One privacy rule shapes everything here: **you always see a masked identity** — first name and last initial, no contact details, sanitized transcripts. Hiring doesn't unmask anyone (full names live only inside the OpenTrain app), and personal emails are never exposed through any API surface. See [Privacy and Work Email](/docs/developers/concepts/privacy-and-work-email). ## Step 1: List Proposals for Your Job Requires `proposals:read` (in the pre-claim scope set). ```bash theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/jobs//proposals?status=UNREVIEWED&limit=25" \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain proposals list --job-id "" --status UNREVIEWED --json ``` Call `opentrain_list_proposals`: ```json theme={null} { "jobId": "", "status": "UNREVIEWED" } ``` Filter by `status` (`UNREVIEWED`, `SHORTLISTED`, `HIRED`, `DECLINED`, ...) and page with `cursor`/`limit`. Each list entry is a compact version of the proposal detail below — enough to rank, with IDs to drill into. ## Step 2: Read a Proposal in Detail ```bash theme={null} curl -sS https://app.opentrain.ai/api/public/v1/proposals/ \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain proposals get --proposal-id "" --json ``` Call `opentrain_get_proposal` with `{ "proposalId": "" }`. ```json theme={null} { "proposal": { "id": "", "jobId": "", "jobTitle": "Safety labeling", "status": { "raw": "SHORTLISTED", "label": "Shortlisted" }, "bid": { "amountUsd": 42, "unit": "per_hour", "labelerHourlyRateUsd": 55 }, "candidate": { "id": "", "profileSlug": "alex-r", "displayName": "Alex R.", "firstName": "Alex", "lastNameInitial": "R.", "profileTitle": "Senior AI Trainer", "profilePhotoUrl": "https://...", "country": "USA", "talentType": "Individual", "highestEarningsUsd": 12000, "reviewCount": 9 }, "metrics": { "interviewScore": 8.6, "matchScore": 87 }, "createdAt": "...", "updatedAt": "..." } } ``` The two ranking signals: * **`metrics.interviewScore`** (0–10) — how the candidate performed in OpenTrain's automated screening interview for this job. * **`metrics.matchScore`** (0–100) — profile-to-job fit. What you will **not** find: resume files, email, phone number, identity-verification links, or any private contact data. Rank on what's here; talk through the conversation thread. ## Step 3: Read the AI Interview Transcript When the score alone isn't enough — say two candidates are close — pull the sanitized transcript of the screening interview: ```bash theme={null} curl -sS https://app.opentrain.ai/api/public/v1/proposals//interview \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain proposals get --proposal-id "" --interview --json ``` Call `opentrain_get_proposal` with: ```json theme={null} { "proposalId": "", "includeInterview": true } ``` The transcript alternates interviewer and candidate turns, sanitized to remove contact details. Use it to judge reasoning depth, domain familiarity, and communication quality — the things a single score compresses away. ## Step 4: Check the Public Profile Each candidate links to a public profile — the same one a human sees on the marketplace, with skills, work history, portfolio items, and stats: ```bash theme={null} curl -sS https://app.opentrain.ai/api/public/v1/freelancers/ \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain freelancers get --id "" --json ``` Call `opentrain_get_freelancer_profile` with `{ "idOrSlug": "alex-r" }`. Accepts either the user ID from `candidate.id` or the `profileSlug`. The profile uses the same masking as the public web page — never personal contact details. ## Step 5: Ask Follow-Up Questions (Pre-Hire Conversation) If you want to probe further before hiring, open the proposal's message thread. Two requirements beyond the read surface: the `messages:write` scope (post-claim) and the `public_api_messaging_writes` feature. **The employer side must message first** — that's a platform rule, not a suggestion. Opening the thread is idempotent get-or-create: ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/proposals//conversation \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```json theme={null} { "ok": true, "conversationId": "", "proposalId": "", "jobId": "", "created": true } ``` Then send into it: ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/messages \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conversationId": "", "content": "Thanks for your proposal! Have you worked with dashcam footage at night before?" }' | jq . ``` ```bash theme={null} opentrain messages start-proposal-thread --proposal-id "" --json opentrain messages send --conversation-id "" \ --content "Thanks for your proposal! Have you worked with dashcam footage at night before?" \ --json ``` Call `opentrain_start_proposal_conversation` with `{ "proposalId": "" }`, then `opentrain_send_message`: ```json theme={null} { "conversationId": "", "content": "Thanks for your proposal! Have you worked with dashcam footage at night before?" } ``` Read replies with `GET /messages?conversationId=...` (CLI: `opentrain messages read`; MCP: `opentrain_read_messages`), or get notified by the `message.received` event in [`/updates` or a webhook](/docs/developers/guides/stay-in-sync). Keep the conversation on-platform. Asking candidates for personal email addresses or off-platform contact details violates the platform rules — and the API's sanitization will strip contact data from what you can read anyway. Post-hire, every AI trainer gets a managed `@opentrain.work` [Work Email](/docs/developers/concepts/privacy-and-work-email) for legitimate external tooling needs. ## A Practical Ranking Loop ```text theme={null} 1. proposals list (status=UNREVIEWED) → collect bid, interviewScore, matchScore 2. Sort by your own weighting (e.g. 0.5·interview + 0.3·match + 0.2·price fit) 3. For the top N: read the interview transcript + profile 4. Ask each finalist 1–2 job-specific questions in the proposal thread 5. Present the shortlist (with evidence) to your human, or proceed to hire ``` When you've picked a winner, move on to [Hire and Pay](/docs/developers/guides/hire-and-pay) — and note that hiring requires a claimed account, while everything on this page works pre-claim except sending messages. ## Related Turn the winning proposal into a contract with an escrowed first milestone. Exactly what's masked pre-hire and what unlocks after. React to proposal.received and message.received events instead of polling each job. Field-level detail for the proposal endpoints. # Hire and Pay Source: https://opentrain.ai/docs/developers/guides/hire-and-pay Request a hire, manage milestones, and move money — every step that hires someone or moves money is co-signed by a human in the OpenTrain app. Hiring is where the agent surface meets real money, so it layers two safety mechanisms: the account must be [claimed](/docs/developers/concepts/authentication), and every action that hires a person or moves money pauses for a [human co-sign](/docs/developers/concepts/human-approvals) — including the hire itself. Within those rails, your agent runs the entire lifecycle: request the hire, scope milestones, request funding and payouts, and end the contract. ```mermaid theme={null} sequenceDiagram participant A as Agent participant API as OpenTrain API participant H as Human owner A->>API: POST /proposals/{id}/hire {milestone} API-->>A: 202 approval (pending, type proposal_hire) A->>H: Share approvalUrl H->>API: Confirms → contract created, first milestone funded API-->>A: approval.confirmed event (carries contractId) A->>API: POST /contracts/{id}/milestones (more scope, unfunded) A->>API: POST /milestones/{id}/fund API-->>A: 202 approval (pending) H->>API: Confirms → escrow funded Note over A,API: ...work happens, milestone delivered... A->>API: POST /milestones/{id}/approve API-->>A: 202 approval (pending) H->>API: Confirms → payout released A->>API: POST /contracts/{id}/end API-->>A: 202 if funded milestones remain, else 200 ``` ## Preconditions Checklist Before your first hire attempt, verify all of these — each failure mode is a distinct error you'd otherwise meet one at a time: | Requirement | How to check | Failure if missing | | --------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------ | | Claimed account | `GET /auth/me` | `403` + `account_claim_required` + `claimUrl` | | `proposals:write` scope (hire) | `GET /auth/me` → `scopes` | `403 FORBIDDEN` with `requiredScopes` | | `payments:write` scope (milestones/funding) | `GET /auth/me` → `scopes` | `403 FORBIDDEN` | | `public_api_hiring` + `public_api_payments_write` features | [capabilities probe](/docs/developers/concepts/scopes-and-capabilities) | `403 FORBIDDEN` | | Payment method on file, or a credit balance covering milestone + fees | `GET /credits` (balance); a human checks billing in the app | `409` + `payment_method_required` + `billingUrl` | ## Step 1: Request the Hire (Co-Signed) The hire request **never hires anyone or moves money**. It records a pending approval (`type: "proposal_hire"`) with your proposed **first escrow milestone** and returns `202`. When your human confirms in the OpenTrain app, OpenTrain accepts the proposal, creates the contract, and funds that milestone — the amount plus a 10% marketplace fee plus a \$9.95 contract initiation fee. The human picks the payment source (card or [credit balance](/docs/developers/concepts/credits-and-billing)) on the confirmation screen. ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/proposals//hire \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "milestone": { "name": "Batch 1", "description": "First 10k dashcam images with bounding boxes", "amount": 450, "dueDate": "2026-07-15" } }' | jq . ``` ```bash theme={null} opentrain proposals hire --proposal-id "" \ --amount 450 \ --milestone-name "Batch 1" \ --milestone-description "First 10k dashcam images with bounding boxes" \ --due-date 2026-07-15 \ --json ``` Call `opentrain_hire_proposal`: ```json theme={null} { "proposalId": "", "milestone": { "name": "Batch 1", "description": "First 10k dashcam images with bounding boxes", "amount": 450, "dueDate": "2026-07-15" } } ``` Success is a `202` — nothing has been hired or charged yet: ```json theme={null} { "approval": { "id": "", "type": "proposal_hire", "status": "pending", "contractId": null, "milestoneId": null, "jobId": "", "proposalId": "", "approvalUrl": "https://app.opentrain.ai/approvals/", "expiresAt": "...", "resolvedAt": null, "result": null, "createdAt": "..." }, "message": "Hire request recorded. A signed-in human must confirm this approval in the OpenTrain app before the freelancer is hired and any money moves." } ``` **Your job: get `approvalUrl` in front of your human.** Re-requesting with the same milestone terms returns the same pending approval (idempotent); re-requesting with **different** terms supersedes it, so the human only ever sees one live hire request per proposal. On confirmation, `result` carries `{"hired": true, "contractId": "...", "jobId": "...", "freelancerUserId": "..."}` — the human's confirmed values win if they adjusted the milestone before confirming. ### Hire Request Conflicts (`409`) All hire-time conflicts use the standard [error envelope](/docs/developers/concepts/errors-pagination-limits) with a machine-readable `details.reason`: | `details.reason` | Meaning | What to do | | ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | `payment_method_required` | No card on file and the credit balance doesn't cover milestone + fees; includes `billingUrl` | Send your human to `billingUrl` to add a card or credits, then retry | | `already_accepted` | Proposal was already hired | Read the contract instead (`GET /contracts?jobId=...`) | | `not_fit_confirmation_required` | Proposal was marked "Not a fit" in review | Re-send with `"confirmNotFitOverride": true` if intentional | ## Step 2: Read the Contract Once the human confirms, pull `contractId` from the approval's `result` (poll [`GET /approvals/{id}`](/docs/developers/api-reference/approvals/get) or watch the `approval.confirmed` event), then read the contract: ```bash theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/contracts?status=active" \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . curl -sS https://app.opentrain.ai/api/public/v1/contracts/ \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain contracts list --status active --json opentrain contracts get --contract-id "" --json ``` Call `opentrain_list_contracts` (optional `jobId`, `status`), then `opentrain_get_contract` with `{ "contractId": "" }`. ```json theme={null} { "contract": { "id": "", "status": "active", "title": "Image Segmentation Contract", "jobId": "", "proposalId": "", "paymentType": "FIXED", "rateUsd": 500, "hasActiveMilestone": true, "freelancer": { "userId": "...", "displayName": "Ada L.", "country": "United States", "profilePath": "/profile/ada-l-1a2b3c" }, "milestones": [ { "id": "...", "name": "Batch 1", "status": "ACTIVE_FUNDED", "amountUsd": 450 } ], "jobDmConversationId": "" } } ``` Two things to notice: * **Identity stays masked post-hire** — first name + last initial and a profile path, never a full last name or a personal email (see [privacy](/docs/developers/concepts/privacy-and-work-email)). * **`jobDmConversationId`** is the post-hire 1:1 thread with your new AI trainer. Use it directly with `GET`/`POST /messages` — no separate "create conversation" step. ## Step 3: Add Milestones (Direct — No Money Moves) Break the remaining work into milestones as you go. Creating one is a direct write — it starts `NOT_FUNDED`: ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/contracts//milestones \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Batch 2", "description": "Batch 2: 5k bounding boxes, night-time footage", "amountUsd": 250 }' | jq . ``` ```bash theme={null} opentrain milestones create --contract-id "" \ --name "Batch 2" \ --description "Batch 2: 5k bounding boxes, night-time footage" \ --amount 250 \ --json ``` Call `opentrain_create_milestone`: ```json theme={null} { "contractId": "", "name": "Batch 2", "description": "Batch 2: 5k bounding boxes, night-time footage", "amountUsd": 250 } ``` Returns `201` with the unfunded milestone. `description` is required; `amountUsd` is required on fixed-price contracts. A `409 contract_ended` means the contract is no longer accepting milestones. ## Step 4: Fund a Milestone (Co-Signed) Funding moves money into escrow, so it returns `202` with a pending approval instead of executing: ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/milestones//fund \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain milestones fund --milestone-id "" --json ``` Call `opentrain_request_milestone_funding` with `{ "milestoneId": "" }`. ```json theme={null} { "approval": { "id": "", "type": "milestone_fund", "status": "pending", "approvalUrl": "https://app.opentrain.ai/approvals/", "expiresAt": "..." }, "message": "A human must confirm this request before any money moves. Share the approvalUrl." } ``` **Your job: get `approvalUrl` in front of your human.** They review and confirm in the OpenTrain app; the approval expires after \~72 hours. Re-requesting while one is pending returns the same approval (idempotent), so retries are safe. The full anatomy, status lifecycle, and tracking patterns are on [Human Approvals](/docs/developers/concepts/human-approvals). Funding-specific conflicts: `409 milestone_not_fundable` (already funded), `409 payment_method_required` (no card and credits don't cover it — `billingUrl` included), `409 milestone_cancelled` / `contract_ended`. ## Step 5: Release Payment (Co-Signed) When the work is delivered and you're satisfied, request the payout. Same co-sign shape; the milestone must be `ACTIVE_FUNDED` (`409 milestone_not_funded` otherwise): ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/milestones//approve \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain milestones approve --milestone-id "" --json ``` Call `opentrain_request_milestone_approval` with `{ "milestoneId": "" }`. ### Tracking Either Approval to Completion Poll the approval, or watch for the `approval.confirmed` event (it fires on **every** terminal state — confirmed, declined, and expired): ```bash theme={null} curl -sS https://app.opentrain.ai/api/public/v1/approvals/ \ -H "Authorization: Bearer $OT_API_TOKEN" | jq '.approval | {status, result}' ``` ```bash theme={null} opentrain approvals get --approval-id "" --json ``` Call `opentrain_get_approval` with `{ "approvalId": "" }`. On confirmation, `result` carries execution evidence (`invoiceId`, `paymentIntentId` / `payoutTransactionId`). Pending invoices also appear in `GET /payments/pending`. ## Step 6: End the Contract Ending is **dual-mode** — it only needs a co-sign when funded milestones are at stake: ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/contracts//end \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain contracts end --contract-id "" --json ``` Call `opentrain_end_contract` with `{ "contractId": "" }`. * **No funded milestones** → executes directly: `200 {"ok": true, "contractId": "...", "status": "ended"}`. * **Funded milestones exist** → `202` with an approval of type `contract_end` — same human-confirm flow as funding, because ending decides what happens to escrowed money. ## Related The 202 pattern in depth: anatomy, lifecycle, expiry, idempotent re-requests. Balances, holds, ledger entries, and the top-up flow that feeds hiring. contract.created, milestone.status\_changed, payment.pending, approval.confirmed. Field-level detail for every endpoint used here. # Post a Job Source: https://opentrain.ai/docs/developers/guides/post-a-job Draft a job from a natural-language description, resolve validation prompts, and publish it to the marketplace. 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: ```mermaid theme={null} flowchart LR A[Send description] --> B[Draft created
+ validation report] B --> C{publishReady?} C -- "no — missingFields" --> D[Ask your human
each prompt] D --> E[PATCH answers
via updateKeys] E --> B C -- yes --> F[Publish] F --> G[Moderation runs] --> H[Live on marketplace] ``` ## Prerequisites * A [personal API token](/docs/developers/concepts/authentication) 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`](/docs/developers/concepts/scopes-and-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. ```bash theme={null} 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 . ``` ```bash theme={null} opentrain jobs draft create \ --description "We need 10 experienced annotators for bounding boxes on 50,000 dashcam images. Pay is \$18/hour. English required. Work runs about six weeks." \ --external-id acme-batch-7 \ --idempotency-key acme-batch-7-v1 \ --json ``` For long descriptions, use `--description-file ` instead. Call `opentrain_create_job_draft`: ```json theme={null} { "jobDescription": "We need 10 experienced annotators for bounding boxes on 50,000 dashcam images. Pay is $18/hour. English required. Work runs about six weeks.", "externalId": "acme-batch-7", "idempotencyKey": "acme-batch-7-v1" } ``` `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: ```json theme={null} { "ok": true, "jobId": "", "status": "DRAFT", "draftUrl": "https://app.opentrain.ai/job-post?job=", "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: | Field | What to do with it | | ------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `validation.publishReady` | `true` means you can publish now | | `missingFields[]` | One entry per gap — each is a ready-made question for your human | | `missingFields[].prompt` | The question to ask, verbatim | | `missingFields[].type` + `enumValues` | Input type; for enums, offer exactly these values | | `missingFields[].updateKeys` | The 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`) | | `normalizedFields` | What 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. ```bash theme={null} curl -sS -X PATCH https://app.opentrain.ai/api/public/v1/job-drafts/ \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "experienceLevel": "INTERMEDIATE", "dataVolumeUnit": "NUMBER_OF_FILES", "dataVolume": 50000 }' | jq '.validation' ``` ```bash theme={null} opentrain jobs draft update --job-id "" \ --set experienceLevel=INTERMEDIATE \ --set dataVolumeUnit=NUMBER_OF_FILES \ --set dataVolume=50000 \ --json ``` Call `opentrain_update_job_draft_fields`: ```json theme={null} { "jobId": "", "patch": { "experienceLevel": "INTERMEDIATE", "dataVolumeUnit": "NUMBER_OF_FILES", "dataVolume": 50000 } } ``` 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 ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/jobs//publish \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain jobs publish --job-id "" --json ``` Call `opentrain_publish_job` with `{ "jobId": "" }`. 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](/docs/developers/concepts/authentication). Exceeding the limit returns `429` with `code: "RATE_LIMITED"` — see [Errors, Pagination, and Limits](/docs/developers/concepts/errors-pagination-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. ```bash theme={null} curl -sS -X PATCH https://app.opentrain.ai/api/public/v1/jobs/ \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jobDescription": "Updated scope: now includes night-time driving footage."}' \ | jq '.moderation' ``` ```bash theme={null} opentrain jobs update-published --job-id "" \ --set jobDescription="Updated scope: now includes night-time driving footage." \ --json ``` Call `opentrain_update_published_job` with `{ "jobId": "", "patch": { "jobDescription": "..." } }`. ### Invite Specific AI Trainers If you already know who you want (from a [profile read](/docs/developers/guides/evaluate-candidates) or a past contract), invite them directly — invited candidates see the job even before they'd find it in search: ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/jobs//invites \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"freelancerId": ""}' | jq . ``` ```bash theme={null} opentrain jobs invite --job-id "" --freelancer-id "" --json ``` Call `opentrain_invite_freelancer` with `{ "jobId": "", "freelancerId": "" }`. 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: ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/jobs//close \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain jobs close --job-id "" --json ``` Call `opentrain_close_job` with `{ "jobId": "" }`. Closing is idempotent: `200 {"ok": true, "jobId": "...", "status": "ARCHIVED", "alreadyClosed": false}`. Closing does **not** end existing [contracts](/docs/developers/guides/hire-and-pay) on the job. ## Marketplace Reads Three read endpoints let you see the public marketplace the way candidates do. They require **no token at all**: ```bash theme={null} # 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: | `format` | What it is | | ------------------------ | ----------------------------------------------------------------------------------------- | | `text` | Natural-language description (the default path above) | | `opentrain_canonical` | OpenTrain's own structured job object (`{"format": "opentrain_canonical", "job": {...}}`) | | `schema_org_job_posting` | A [schema.org JobPosting](https://schema.org/JobPosting) JSON object | | `indeed_xml` | Indeed XML feed entry | | `hr_xml` | HR-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`. ## Related Proposals start arriving once you're live — score, interview, and chat. Probe which features are enabled before relying on them. Get notified the moment a proposal arrives. Full parameter-level detail for every endpoint used here. # Stay in Sync Source: https://opentrain.ai/docs/developers/guides/stay-in-sync Track everything that happens on your account with the updates feed and webhooks. Things happen on your account while your agent isn't looking: proposals arrive, candidates reply, a human confirms an approval, a payment falls due. OpenTrain gives you two complementary ways to find out — a pollable delta feed and push webhooks — built on the **same event stream**. ## The Decision Framework | | `GET /updates` (poll) | Webhooks (push) | | --------------------- | ------------------------------------------------------- | ----------------------------------------------------------- | | Infrastructure needed | None | A public HTTPS endpoint | | Latency | Your poll interval | Seconds after the event | | Delivery guarantee | You always get every event your cursor hasn't passed | At-least-once, with retries — but your endpoint can be down | | History | Full backlog from any cursor | **Only events after the subscription was created** | | Best for | Source of truth; simple agents; catch-up after downtime | Waking up an idle agent; low-latency reactions | **The recommended architecture uses both: webhook as the trigger, `/updates` as the source of truth.** When a delivery arrives, don't process its payload as gospel — just poll `/updates` from your saved cursor. That makes missed or duplicate deliveries irrelevant: the feed is the ledger, the webhook is the doorbell. ## The 8 Event Types Both surfaces carry the same `PlatformEvent` records. Visibility is scope-filtered — you only see (or can subscribe to) event types your token can read: | Event type | Fires when | Required scope | | ------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------- | | `proposal.received` | A new proposal lands on one of your jobs | `proposals:read` | | `proposal.status_changed` | A proposal moves status (shortlisted, hired, declined...) | `proposals:read` | | `message.received` | Someone sends a message in a conversation you're in | `messages:read` | | `contract.created` | A hire completes and the contract exists | `payments:read` | | `milestone.status_changed` | A milestone changes status (created, funded, paid, cancelled) | `payments:read` | | `payment.pending` | An invoice is waiting on action | `payments:read` | | `approval.confirmed` | A [co-sign approval](/docs/developers/concepts/human-approvals) reaches any terminal state | `payments:read` | | `contract.budget_state_changed` | A contract's [budget](/docs/developers/api-reference/contracts/get) moves between `OK` / `LOW` / `DEPLETED` | `payments:read` | **Payloads carry IDs only — never content.** A `message.received` event tells you which conversation to read, not what was said. Fetch the actual resource through its endpoint, which applies the full privacy and masking rules: ```json theme={null} { "id": "1042", "type": "proposal.received", "apiVersion": "v1", "createdAt": "2026-06-12T09:30:00.000Z", "resourceId": "", "jobId": "", "data": { "proposalId": "", "jobId": "" } } ``` ## Polling `/updates` One cheap call answers "what changed since I last looked?": ```bash theme={null} curl -sS "https://app.opentrain.ai/api/public/v1/updates?cursor=$LAST_CURSOR&limit=50" \ -H "Authorization: Bearer $OT_API_TOKEN" | jq . ``` ```bash theme={null} opentrain updates poll --cursor "$LAST_CURSOR" --json ``` Call `opentrain_poll_updates`: ```json theme={null} { "cursor": "" } ``` ```json theme={null} { "events": [ { "id": "1042", "type": "proposal.received", "...": "..." } ], "nextCursor": "1042", "hasMore": false } ``` The rules that make polling reliable: * Events are ordered by `id` **ascending**; the cursor is the last event ID you processed. * **Persist `nextCursor` durably** after processing each page — it's your position in the stream. Omit `cursor` on the very first poll to start from the beginning of your account's history. * `limit` is 1–200 (default 50). If `hasMore` is `true`, keep paging immediately before sleeping. * Polling is idempotent and cheap. A sensible idle cadence is every 1–5 minutes; `429 RATE_LIMITED` tells you if you're overdoing it. ## Webhooks Webhook management needs the `webhooks:manage` scope and the `public_api_webhooks` feature. ### Subscribe ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/webhooks \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"] }' | jq . ``` ```bash theme={null} opentrain webhooks create \ --url https://example.com/hooks/opentrain \ --events proposal.received,message.received,approval.confirmed \ --json ``` Call `opentrain_create_webhook`: ```json theme={null} { "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"] } ``` ```json theme={null} { "webhook": { "id": "", "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"], "status": "ACTIVE", "disabledAt": null, "disabledReason": null }, "secret": "whsec_...", "message": "Store the secret now — it is only returned once. ..." } ``` ### Subscription Rules * **The `secret` appears exactly once** — in the create response. Store it; you need it to [verify signatures](/docs/developers/guides/verify-webhook-signatures). List/get never return it. * **URLs must be `https`** (`http://localhost` is allowed for local development). Violations are `400` with `details.field = "url"`. * **Per-event-type scope check at subscribe time**: subscribing to `message.received` with a token lacking `messages:read` is a `403`. Unknown event types are `400` with `details.supportedEventTypes`. * **Maximum 10 subscriptions** per account (`409` with `details.limit`). * **No backfill.** A new subscription starts at the current event high-water mark — events that already happened never arrive by webhook. If you need history, that's what `/updates` is for. This is the most common integration surprise: *subscribe first, then trigger the things you want to hear about.* Manage subscriptions with `GET /webhooks`, `GET /webhooks/{id}`, `DELETE /webhooks/{id}` (CLI: `opentrain webhooks list|get|delete`; MCP: `opentrain_list_webhooks`, `opentrain_get_webhook`, `opentrain_delete_webhook`). ### What a Delivery Looks Like Each event is a `POST` to your URL: ```text theme={null} Content-Type: application/json User-Agent: OpenTrain-Webhooks/1.0 X-OpenTrain-Event: proposal.received X-OpenTrain-Delivery: X-OpenTrain-Signature: t=,v1= ``` The body is exactly the `/updates` event record shown above. **Verify the signature before trusting anything** — see [Verify Webhook Signatures](/docs/developers/guides/verify-webhook-signatures). ### Retries and Auto-Disable * Respond with any `2xx` within 10 seconds. Do the real work async — acknowledge first, process after. * A failed delivery retries up to 5 attempts with backoff: **1m, 5m, 30m, 120m**. * After **10 consecutive** deliveries exhaust their retries, the subscription is auto-disabled: `status: "DISABLED"` with a `disabledReason`. Recovery: fix your endpoint, then **delete and re-create** the subscription (you'll get a new secret). Your `/updates` cursor bridges the gap — nothing is lost while the webhook was down. ## The Agent Loop Putting both halves together: ```text theme={null} on startup: cursor = load_saved_cursor() # durable storage catch_up() on webhook delivery (or poll timer): verify signature; respond 200 immediately catch_up() def catch_up(): loop: page = GET /updates?cursor={cursor}&limit=200 for event in page.events: handle(event) # fetch resources by ID, act cursor = event.id save_cursor(cursor) if not page.hasMore: break def handle(event): match event.type: proposal.received -> evaluate the new candidate proposal.status_changed -> refresh proposal state message.received -> read conversation, maybe reply contract.created -> start milestone planning milestone.status_changed -> update work tracking payment.pending -> surface to human if action needed approval.confirmed -> check approval.status: confirmed/declined/expired contract.budget_state_changed -> if LOW/DEPLETED, propose funding the next milestone ``` Because the cursor — not the webhook — is the source of truth, this loop survives missed deliveries, duplicate deliveries, downtime, and webhook auto-disable without any special-case code. ## Related HMAC verification in Node.js and Python — required before trusting deliveries. Cursor rules, rate limits, and the error envelope. What approval.confirmed means and how to read its payload. Field-level detail for the updates feed. # Verify Webhook Signatures Source: https://opentrain.ai/docs/developers/guides/verify-webhook-signatures Authenticate OpenTrain webhook deliveries with HMAC signature verification, in Node.js and Python. Every webhook delivery from OpenTrain is signed with the subscription's secret (the `whsec_...` value returned **once**, when the subscription was created). Verifying the signature proves the delivery came from OpenTrain and wasn't tampered with — never act on an unverified delivery. This scheme is shared by the [Public API](/docs/developers/guides/stay-in-sync) and the [Platform API](/docs/developers/annotation-platforms/webhooks): one verifier works for both. ## The Headers ```text theme={null} X-OpenTrain-Event: proposal.received X-OpenTrain-Delivery: X-OpenTrain-Signature: t=,v1= ``` The signature header has two comma-separated parts: | Part | Meaning | | ---- | -------------------------------------------------------- | | `t` | Unix timestamp (seconds) of when the delivery was signed | | `v1` | Hex-encoded `HMAC-SHA256(secret, ".")` | The signed message is the timestamp, a literal `.`, and the **raw request body bytes** — exactly as received, before any JSON parsing. ## The Verification Recipe 1. Read the **raw body bytes**. Not `JSON.parse`-then-re-stringify — key ordering and whitespace differences will change the bytes and the verification will fail (or worse, falsely pass a forged body if you re-serialize attacker-controlled JSON). 2. Parse `t` and `v1` from `X-OpenTrain-Signature`. 3. Reject if `|now − t|` exceeds your tolerance window (5 minutes is a good default) — this bounds replay attacks. 4. Compute `HMAC-SHA256(secret, ".")` and hex-encode it. 5. Compare with `v1` using a **constant-time** comparison. ### Node.js / TypeScript ```ts theme={null} import { createHmac, timingSafeEqual } from 'node:crypto'; export function verifyOpenTrainSignature( secret: string, signatureHeader: string, rawBody: string | Buffer, toleranceSeconds = 300 ): boolean { const parts = Object.fromEntries( signatureHeader.split(',').map((kv) => kv.split('=') as [string, string]) ); const timestamp = Number(parts.t); if (!Number.isFinite(timestamp)) return false; if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; const expected = createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) .digest('hex'); const provided = String(parts.v1 ?? ''); if (provided.length !== expected.length) return false; return timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex')); } ``` Wired into a minimal HTTP server that preserves the raw body: ```ts theme={null} import { createServer } from 'node:http'; const SECRET = process.env.OPENTRAIN_WEBHOOK_SECRET!; // the whsec_... value createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const rawBody = Buffer.concat(chunks); const signature = String(req.headers['x-opentrain-signature'] ?? ''); if (!verifyOpenTrainSignature(SECRET, signature, rawBody)) { res.writeHead(400).end('invalid signature'); return; } // Acknowledge first, process async. res.writeHead(200).end('ok'); const event = JSON.parse(rawBody.toString('utf8')); const deliveryId = String(req.headers['x-opentrain-delivery'] ?? ''); void handleEvent(event, deliveryId); }); }).listen(8080); ``` In frameworks that parse JSON for you (Express with `express.json()`, Next.js API routes, Fastify), you must opt into the raw body — e.g. `express.raw({ type: 'application/json' })` on the webhook route, or the `verify` callback that captures `req.rawBody`. Verifying a re-serialized body is the single most common webhook-verification bug. ### Python ```python theme={null} import hashlib import hmac import time def verify_opentrain_signature( secret: str, signature_header: str, raw_body: bytes, tolerance_seconds: int = 300, ) -> bool: parts = dict(kv.split("=", 1) for kv in signature_header.split(",")) try: timestamp = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - timestamp) > tolerance_seconds: return False signed_payload = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest() return hmac.compare_digest(parts.get("v1", ""), expected) ``` For example with Flask (`request.get_data()` returns the raw bytes): ```python theme={null} from flask import Flask, request app = Flask(__name__) SECRET = os.environ["OPENTRAIN_WEBHOOK_SECRET"] @app.post("/hooks/opentrain") def opentrain_webhook(): if not verify_opentrain_signature( SECRET, request.headers.get("X-OpenTrain-Signature", ""), request.get_data(), ): return "invalid signature", 400 delivery_id = request.headers.get("X-OpenTrain-Delivery", "") enqueue_for_processing(request.get_json(), delivery_id) # process async return "ok", 200 ``` ## Operational Rules * **Respond `2xx` within 10 seconds.** Acknowledge immediately and do real work asynchronously. A slow handler is indistinguishable from a failing one and burns your retry budget. * **Return `5xx` (or time out) to request a retry.** Deliveries retry up to 5 attempts with 1m/5m/30m/120m backoff. Return `2xx` for deliveries you choose to skip — a `4xx` still counts as a failure toward [auto-disable](/docs/developers/guides/stay-in-sync#retries-and-auto-disable). * **Dedupe by `X-OpenTrain-Delivery`.** Retries reuse the delivery ID; treat it as an idempotency key. (If you follow the [trigger-then-poll pattern](/docs/developers/guides/stay-in-sync#the-decision-framework), duplicates are naturally harmless.) * **A `400` on signature failure is fine** — but log it. Repeated signature failures usually mean a body-parsing middleware is mangling the raw bytes, or you rotated the subscription (new secret) without updating your handler. * **Secrets are per-subscription.** If you run multiple subscriptions, key your secrets by webhook ID. Deleting and re-creating a subscription issues a new secret. ## Testing Your Verifier You don't need to wait for a real event. Compute a signature yourself and POST it: ```bash theme={null} SECRET="whsec_test" BODY='{"id":"1","type":"proposal.received","apiVersion":"v1","resourceId":"x","jobId":null,"data":{}}' T=$(date +%s) SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //') curl -sS -X POST http://localhost:8080/ \ -H "Content-Type: application/json" \ -H "X-OpenTrain-Event: proposal.received" \ -H "X-OpenTrain-Delivery: test-1" \ -H "X-OpenTrain-Signature: t=$T,v1=$SIG" \ -d "$BODY" ``` Your handler should accept this, reject a tampered body (change one character), and reject a stale timestamp (`T=$(($(date +%s) - 3600))`). ## Related Subscribing, the event catalog, retries, and the agent loop around deliveries. The platform-side delivery surface — same signature scheme, plus redelivery. # Developer Platform Overview Source: https://opentrain.ai/docs/developers/overview Connect your coding agent to your OpenTrain account, build agents that hire and manage AI trainers end to end, or integrate OpenTrain talent into your annotation platform. OpenTrain is built so that software — including fully autonomous coding agents — can hire, manage, and pay AI trainers end to end. Everything a human employer can do through the OpenTrain app, an agent can do through the API: post jobs, evaluate candidates, message applicants, request hires, fund milestones, and close out contracts. ## Already on OpenTrain? Connect Your Coding Agent If you (or your human) already have an OpenTrain account, connecting an agent takes about two minutes: 1. **Mint a token** in the app at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys). Pick the scopes you want the agent to have; the token (`ot_pat_...`) is shown once. 2. **Add the MCP server** to your coding agent with the token. For Claude Code: ```bash theme={null} claude mcp add opentrain --env OPENTRAIN_PERSONAL_API_TOKEN=ot_pat_... -- npx -y @opentrain-ai/mcp ``` 3. **Ask your agent to take it from there** — it can verify the connection with one tool call (`opentrain_auth_status`) and then post jobs, review proposals, and manage contracts on your behalf. Setup guides for each agent: [Claude Code](/docs/developers/agents/claude-code), [Codex CLI](/docs/developers/agents/codex-cli), [Cursor](/docs/developers/agents/cursor), [Antigravity](/docs/developers/agents/antigravity), [Grok Build](/docs/developers/agents/grok-build), [Copilot CLI](/docs/developers/agents/copilot-cli), [OpenCode](/docs/developers/agents/opencode), [Aider](/docs/developers/agents/aider), and [any other MCP client](/docs/developers/agents/other-mcp-agents). Because your account is already claimed, everything works immediately — and anything that hires someone or moves money still pauses for [your approval in the app](/docs/developers/concepts/human-approvals). ## Choose Your Interface All three interfaces talk to the same API and share the same account, tokens, and permissions. Use whichever fits your environment — or mix them. | | MCP server | CLI | Raw HTTP | | --------------- | -------------------------------------------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------- | | **Best for** | Coding agents — MCP is the standard protocol agents speak, and the deepest integration | Scripts, CI, terminal workflows | Any language, full control | | **Install** | `npx -y @opentrain-ai/mcp` | `npm i -g @opentrain-ai/cli` | None | | **Auth setup** | Token env var, or one tool call to self-register | Token env var, or `opentrain auth register` | `Authorization: Bearer ot_pat_...` | | **Surface** | 41 tools | \~43 commands, 15 namespaces | Full REST API + [OpenAPI spec](https://app.opentrain.ai/api/public/v1/openapi.json) | | **Get started** | [MCP quickstart](/docs/developers/quickstart-mcp) | [CLI quickstart](/docs/developers/quickstart-cli) | [HTTP quickstart](/docs/developers/quickstart-http) | The MCP server and CLI share a credentials file (`~/.config/opentrain/cli.json`), so an agent that registers over MCP can immediately use the CLI with the same account, and vice versa. Building with an autonomous agent? Point it at the [agent discovery surfaces](/docs/developers/agent-discovery) — OpenTrain serves machine-readable onboarding instructions (`/auth.md`), an `llms.txt` index, and full OpenAPI specs directly from the app, so agents can bootstrap without reading this site at all. ## Agents Starting From Zero An agent with no OpenTrain account can bootstrap itself — no email, no password, no human in the loop: 1. **Register** — `POST /api/agent/identity` returns a bearer token (`ot_pat_...`). 2. **Check your account** — `GET /api/public/v1/auth/me` confirms identity, scopes, and claim status. 3. **Draft a job from plain English** — `POST /api/public/v1/job-drafts` with a text description. The parser normalizes it into a structured job and tells you exactly what is missing. 4. **Fill the gaps** — `PATCH /api/public/v1/job-drafts/{jobId}` answers the validation prompts until `publishReady` is `true`. 5. **Publish** — `POST /api/public/v1/jobs/{jobId}/publish` puts the job live on the marketplace. ```text theme={null} Base URL: https://app.opentrain.ai Auth: Authorization: Bearer ot_pat_... ``` Unclaimed agents can post jobs and read proposals; hiring, messaging candidates, and spending money require a human to claim the account through the short [claim ceremony](/docs/developers/concepts/authentication#the-claim-ceremony). Each quickstart walks this exact loop in your interface of choice. ## The Job Lifecycle ```mermaid theme={null} flowchart TD A[Connect or register agent] --> B[Draft job] B --> C{publishReady?} C -- "no: answer prompts" --> B C -- yes --> D[Publish job] D --> E[Proposals arrive] E --> F[Evaluate candidates] F --> G[Request hire — human co-signs] G --> H[Fund milestones — human co-signs] H --> I[Work + approve payout] I --> J[End contract] ``` After publishing, AI trainers apply with proposals that include bids, AI-interview scores, and verification status. You [evaluate candidates](/docs/developers/guides/evaluate-candidates), [request hires and pay](/docs/developers/guides/hire-and-pay) through escrow-backed milestones, and [stay in sync](/docs/developers/guides/stay-in-sync) with polling or webhooks. ## The Safety Model OpenTrain lets agents operate autonomously while keeping humans in control of identity and money: * **The claim ceremony** ties an agent account to a human owner. Tokens minted in the app belong to a claimed account from the start; self-registered agents start unclaimed and can post jobs and read proposals, but hiring, messaging candidates, and spending money unlock only after a human claims the account through a short verification-code flow. See [Authentication](/docs/developers/concepts/authentication). * **Co-signed hiring and spending** means hiring someone, funding a milestone, and approving a payout all return `202 Accepted` with an approval URL a human confirms in the browser. Agents request; humans authorize. See [Human Approvals](/docs/developers/concepts/human-approvals). * **Cards never touch the API.** The human picks the payment source — a card on file or the prepaid credit balance — on the confirmation screen in the app. Credits are topped up via Stripe Checkout, which a human completes in the browser. See [Credits and Billing](/docs/developers/concepts/credits-and-billing). * **Privacy by default**: AI trainer identities are masked before *and* after hiring — first name and last initial everywhere — and AI trainers are only ever reachable through their `@opentrain.work` Work Email. Personal emails are never exposed. See [Privacy and Work Email](/docs/developers/concepts/privacy-and-work-email). ## Building an Annotation Platform? If you run a data labeling platform — or internal annotation tooling at an AI lab or enterprise — the Platform API lets your customers hire vetted OpenTrain AI trainers who are automatically provisioned into your workspace when a contract starts, with usage reporting and budget sync back to OpenTrain. Start at the [Annotation Platforms overview](/docs/developers/annotation-platforms/overview). ## Explore the Docs Authentication, scopes, approvals, credits, errors, and privacy — the mental model behind every endpoint. Task-oriented walkthroughs with curl, CLI, and MCP examples side by side. Every endpoint, every field, every error — hand-written and kept in sync with the served OpenAPI spec. Integrate OpenTrain hiring, auto-provisioning, and usage sync into your own labeling platform. # Quickstart: CLI Source: https://opentrain.ai/docs/developers/quickstart-cli Install the OpenTrain CLI, register an agent account, and publish your first job from the terminal. [`@opentrain-ai/cli`](https://www.npmjs.com/package/@opentrain-ai/cli) wraps the full OpenTrain Public API in \~43 commands. Every command supports `--json` for machine-readable output, which makes the CLI equally usable by humans in a terminal and agents in a script. ## Install ```bash theme={null} npm install -g @opentrain-ai/cli opentrain --help ``` The binary is `opentrain`. Commands are grouped into 15 namespaces: `auth`, `jobs`, `proposals`, `freelancers`, `contracts`, `milestones`, `approvals`, `messages`, `updates`, `credits`, `webhooks`, `tokens`, `team`, `payments`, and `whoami`. ## Authenticate Mint a token at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) in the app (shown once), then log the CLI in with it: ```bash theme={null} opentrain auth login --api-key ot_pat_xxxxxxxxxxxx ``` The token is saved to `~/.config/opentrain/cli.json` (mode `0600`) and used automatically from then on. The same file is shared with the [MCP server](/docs/developers/quickstart-mcp). Tokens minted in the app belong to your claimed account, so everything works immediately. No account yet? Create a fresh agent account in one command — no sign-up form: ```bash theme={null} opentrain auth register --agent-name "Acme Data Agent" --org-name "Acme AI" ``` Self-registered accounts start unclaimed — hiring, messaging, and money movement unlock after the [claim ceremony](#claim-the-account-for-a-human) below. Verify with: ```bash theme={null} opentrain auth status --json ``` ## Publish a Job ```bash theme={null} opentrain jobs draft create \ --description "We need 3 AI trainers to label 10,000 product images into 12 categories. Pay per label, around $0.05 each. English required, prior image annotation experience preferred. Two-week project." \ --json ``` The response includes the draft `jobId` and a `validation` object. If `publishReady` is `false`, `missingFields` lists each gap with a `prompt`, the expected `type`, any `enumValues`, and the `updateKeys` to set. Longer descriptions can come from a file with `--description-file ./job.txt`. Answer the validation prompts with `--set key=value` pairs: ```bash theme={null} opentrain jobs draft update --job-id \ --set experienceLevel=INTERMEDIATE \ --set dataVolume=10000 \ --set dataVolumeUnit=NUMBER_OF_FILES \ --json ``` For larger patches, pass `--patch-file ./patch.json` or `--patch-json '{...}'` instead. Repeat until the output shows `"publishReady": true`. ```bash theme={null} opentrain jobs publish --job-id --json ``` The job goes through moderation and appears on the marketplace. Watch for proposals with `opentrain proposals list --job-id --json`. ## Claim the Account for a Human *(Self-registered accounts only — tokens minted in the app are claimed from the start.)* Hiring, messaging candidates, and money movement require a human-claimed account: ```bash theme={null} # 1. Start the claim — prints a 6-digit code + verification URL, # and emails the link to the owner opentrain auth claim --email owner@example.com # 2. The human opens the URL, signs in, and enters the code # 3. Block until the claim completes (auto-upgrades the saved token) opentrain auth claim-status --wait --timeout 600 ``` See [Authentication](/docs/developers/concepts/authentication) for the full lifecycle. ## Conventions Worth Knowing * **`--json` everywhere** — every command emits structured JSON for scripting; omit it for human-readable output. * **Credential precedence**: explicit CLI flags > environment variables (`OT_API_TOKEN` / `OPENTRAIN_API_TOKEN`, `OT_API_BASE_URL` / `OPENTRAIN_API_BASE_URL`) > the saved credentials file. * **Retries**: read commands retry automatically on `502`/`503`/`504` (up to 3 attempts with exponential backoff). Writes are not retried blindly — see [idempotent writes](/docs/developers/concepts/errors-pagination-limits). * **Timeouts**: 30 seconds per request, extended to 120 seconds for publish and hire. ## Next Steps Every command with flags, examples, and the endpoint it wraps. From proposal to funded milestone, including the human co-sign step. # Quickstart: HTTP API Source: https://opentrain.ai/docs/developers/quickstart-http Mint or register a token, draft a job, and publish it with plain HTTP requests. No SDK required: the entire OpenTrain agent surface is plain HTTPS + JSON. This quickstart goes from a bare token to a published marketplace job using nothing but `curl`. ```text theme={null} Base URL: https://app.opentrain.ai Auth: Authorization: Bearer $OT_API_TOKEN ``` ## Step 1: Get a Token **Already have an OpenTrain account?** Mint a token in the app at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) — pick the scopes, copy the `ot_pat_...` value (shown once), and export it: ```bash theme={null} export OT_API_TOKEN="ot_pat_..." ``` Tokens minted in the app belong to your claimed account, so every feature your scopes allow works immediately. Skip to [Step 2](#step-2-verify-and-discover). **Starting from zero?** One anonymous call creates an agent account and returns credentials. All body fields are optional: ```bash theme={null} curl -s -X POST https://app.opentrain.ai/api/agent/identity \ -H "Content-Type: application/json" \ -d '{ "identity_type": "anonymous", "agent_name": "Acme Data Agent", "organization_name": "Acme AI" }' ``` ```json theme={null} { "identity_type": "anonymous", "registration_id": "...", "access_token": "ot_pat_...", "token_type": "bearer", "scopes": ["jobs:read", "jobs:write", "proposals:read", "messages:read", "payments:read", "team:read"], "claim_token": "ot_clm_...", "claim_token_expires_at": "...", "claim_endpoint": "https://app.opentrain.ai/api/agent/identity/claim", "token_endpoint": "https://app.opentrain.ai/api/agent/oauth/token", "grant_type": "urn:opentrain:agent-auth:grant-type:claim" } ``` Store both tokens securely: * **`access_token`** (`ot_pat_...`) works immediately against `/api/public/v1` with the pre-claim scopes — enough to draft and publish jobs and read proposals, messages, and payments. * **`claim_token`** (`ot_clm_...`) is what you exchange later when a human claims the account. ```bash theme={null} export OT_API_TOKEN="ot_pat_..." export OT_CLAIM_TOKEN="ot_clm_..." ``` ## Step 2: Verify and Discover ```bash theme={null} curl -s https://app.opentrain.ai/api/public/v1/auth/me \ -H "Authorization: Bearer $OT_API_TOKEN" curl -s https://app.opentrain.ai/api/public/v1/job-drafts/capabilities \ -H "Authorization: Bearer $OT_API_TOKEN" ``` `auth/me` confirms who you are, your scopes, and whether the account is claimed. `capabilities` reports which features are enabled for your account and the formats and fields job drafting accepts — [always probe it](/docs/developers/concepts/scopes-and-capabilities) rather than assuming a feature is on. ## Step 3: Draft a Job from Plain English Don't hand-assemble OpenTrain's structured job payload. Send a description and let the parser normalize it: ```bash theme={null} curl -s -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", "text": "We need 3 AI trainers to label 10,000 product images into 12 categories. Pay per label, around $0.05 each. English required, prior image annotation experience preferred. Two-week project." } }' ``` The response is the validation envelope that drives the whole flow: ```json theme={null} { "jobId": "", "status": "DRAFT", "draftUrl": "https://app.opentrain.ai/...", "reviewUrl": "https://app.opentrain.ai/...", "validation": { "publishReady": false, "missingFields": [ { "field": "experienceLevel", "label": "Experience level", "message": "Experience level is required before publishing.", "code": "missing_required_field", "prompt": "What experience level should applicants have?", "type": "enum", "enumValues": ["ENTRY_LEVEL", "INTERMEDIATE", "EXPERT", "ANY_EXPERIENCE_LEVEL"], "updateKeys": ["experienceLevel"], "hint": "Pick one of the supported enum values." } ] }, "normalizedFields": { "...": "what the parser extracted" }, "warnings": [], "unmappedFields": [], "lowConfidenceFields": [], "import": { "rawSourcePreserved": true, "autoPublished": false }, "parser": { "...": "parser metadata" } } ``` Read it like this: * **`validation.publishReady`** — your loop condition. `false` means keep patching. * **`validation.missingFields[]`** — one entry per gap. Each carries a human/agent-readable `prompt` (ask your user, or answer it yourself), the expected `type`, the exact `enumValues` where applicable, and the `updateKeys` to set in your PATCH. * **`lowConfidenceFields`** — fields the parser extracted but isn't sure about; review before publishing. * **`unmappedFields` / `warnings`** — content the parser couldn't place, and non-blocking issues. Structured sources work too: the endpoint also accepts `{"format": "opentrain_canonical", "job": {...}}` and `{"rawJobDescription": "..."}`, with parsers for `schema_org_job_posting`, `indeed_xml`, and `hr_xml`. ## Step 4: Patch Until Publish-Ready Answer each prompt using its `updateKeys`: ```bash theme={null} curl -s -X PATCH https://app.opentrain.ai/api/public/v1/job-drafts/ \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "experienceLevel": "INTERMEDIATE", "dataVolume": 10000, "dataVolumeUnit": "NUMBER_OF_FILES" }' ``` Every PATCH returns the same envelope with refreshed validation. Repeat until `"publishReady": true`. ## Step 5: Publish ```bash theme={null} curl -s -X POST https://app.opentrain.ai/api/public/v1/jobs//publish \ -H "Authorization: Bearer $OT_API_TOKEN" ``` The job goes through moderation and appears live on the marketplace. Proposals arrive as AI trainers apply — list them with `GET /api/public/v1/jobs//proposals`. ## Step 6: Hand the Account to a Human *(Self-registered accounts only — tokens minted in the app are claimed from the start.)* Hiring, messaging candidates, and money movement require a claimed account. The claim ceremony is a device-flow-style exchange: ```bash theme={null} curl -s -X POST https://app.opentrain.ai/api/agent/identity/claim \ -H "Content-Type: application/json" \ -d '{ "claim_token": "'$OT_CLAIM_TOKEN'", "email": "owner@example.com" }' ``` ```json theme={null} { "user_code": "123456", "verification_uri": "https://app.opentrain.ai/claim?token=ot_cat_...", "expires_in": 1800, "interval": 5, "email_sent": true } ``` OpenTrain emails the human the link and code, but show them BOTH the `verification_uri` and the 6-digit `user_code` yourself — the email can land in spam. The email address must not already have an OpenTrain account (`email_already_registered`). Posting again restarts the ceremony with a new code. The human opens the link, signs in (or creates an account) with that exact email, and enters the code. Poll every `interval` seconds: ```bash theme={null} curl -s -X POST https://app.opentrain.ai/api/agent/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=urn:opentrain:agent-auth:grant-type:claim" \ --data-urlencode "claim_token=$OT_CLAIM_TOKEN" ``` | Response | Meaning | | ---------------------------------------- | ----------------------------------------------- | | `400 {"error": "authorization_pending"}` | Human hasn't finished — keep polling | | `400 {"error": "slow_down"}` | You're polling too fast — increase the interval | | `400 {"error": "expired_token"}` | Claim window over — re-register | | `200` with new `access_token` | Claimed — post-claim scopes granted | The post-claim token adds `proposals:write`, `messages:write`, and `team:write`. When the claim completes, **all pre-claim tokens are revoked** — replace your stored token. The new token is delivered exactly once; further polls return `invalid_grant`. ## Next Steps Token types, scope sets, revocation, rotation, and the full claim lifecycle. Every endpoint with parameters, response fields, and error tables. The drafting loop in depth: low-confidence fields, moderation, edits after publish, and invites. The error envelope, cursor pagination, rate limits, and retry guidance. # Quickstart: MCP Server Source: https://opentrain.ai/docs/developers/quickstart-mcp Connect a coding agent to your OpenTrain account with the official MCP server and publish your first job. The official MCP server, [`@opentrain-ai/mcp`](https://www.npmjs.com/package/@opentrain-ai/mcp), exposes the OpenTrain Public API as 41 tools any MCP client can call — Claude Code, Cursor, or your own agent runtime. This quickstart connects an agent to your OpenTrain account and publishes a job. (No account yet? An agent can [register itself](#or-start-from-zero) too.) ## Prerequisites * Node.js 18+ (the server runs via `npx`, no install step) * An MCP client (the examples use Claude Code — see the [per-agent setup pages](/docs/developers/agents/claude-code) for others) ## Connect Your Existing Account Mint a token in the app at [Settings → API keys](https://app.opentrain.ai/employer-settings?tab=api-keys) — pick the scopes you want the agent to have; the token (`ot_pat_...`) is shown once. Then add the server with the token in its environment: ```bash theme={null} claude mcp add opentrain --env OPENTRAIN_PERSONAL_API_TOKEN=ot_pat_... -- npx -y @opentrain-ai/mcp ``` Add this to your client's MCP server configuration: ```json theme={null} { "mcpServers": { "opentrain": { "command": "npx", "args": ["-y", "@opentrain-ai/mcp"], "env": { "OPENTRAIN_PERSONAL_API_TOKEN": "ot_pat_..." } } } } ``` Verify with one tool call — `opentrain_auth_status` reports the account, scopes, and claim state. Because the token comes from your own account, it is already claimed: everything works immediately, and anything that hires someone or moves money still pauses for [your approval in the app](/docs/developers/concepts/human-approvals). Skip ahead to [publish a job](#publish-a-job). ## Or Start From Zero An agent with no account can create one — no token needed up front: ```bash theme={null} claude mcp add opentrain -- npx -y @opentrain-ai/mcp ``` ```json theme={null} { "mcpServers": { "opentrain": { "command": "npx", "args": ["-y", "@opentrain-ai/mcp"] } } } ``` Then call `opentrain_register_agent`: ```json theme={null} { "agentName": "Acme Data Agent", "organizationName": "Acme AI" } ``` This creates a brand-new agent account and saves its API token to the shared credentials file (`~/.config/opentrain/cli.json`). The raw token is never echoed back into the conversation, so it cannot leak into agent transcripts. Every subsequent tool call authenticates automatically. Self-registered accounts start unclaimed — see [hand the account to a human](#hand-the-account-to-a-human) below for what that gates. ## Publish a Job Call `opentrain_create_job_draft` with a natural-language description — don't hand-assemble structured payloads: ```json theme={null} { "description": "We need 3 AI trainers to label 10,000 product images into 12 categories. Pay per label, around $0.05 each. English required, prior image annotation experience preferred. Two-week project." } ``` The response contains the draft `jobId`, the normalized fields the parser extracted, and a `validation` object listing exactly what is still missing. Each entry in `validation.missingFields` includes a human-readable `prompt`, the expected `type`, any `enumValues`, and the `updateKeys` to set. For each missing field, call `opentrain_update_job_draft_fields` with the `updateKeys` from the validation prompts: ```json theme={null} { "jobId": "", "fields": { "experienceLevel": "INTERMEDIATE", "dataVolume": 10000, "dataVolumeUnit": "NUMBER_OF_FILES" } } ``` Repeat until the response reports `validation.publishReady: true`. This prompt-driven loop is the intended integration pattern: the API tells you what it needs, you answer. ```json theme={null} { "jobId": "" } ``` Call `opentrain_publish_job`. The job goes through moderation and appears on the marketplace. The response includes the public job URL. Proposals from AI trainers will start arriving — list them with `opentrain_list_proposals`. ## Hand the Account to a Human *(Self-registered accounts only — tokens minted in the app are claimed from the start.)* Unclaimed agent accounts can post jobs and read proposals, but hiring, messaging candidates, and spending money require a human owner. The claim ceremony links your agent account to a person: Call `opentrain_claim_account` with the human's email: ```json theme={null} { "email": "owner@example.com" } ``` The response contains a 6-digit `userCode`, the `verificationUri` the human opens, the polling `interval`, and `expiresIn`. OpenTrain also emails the link to the address you provided. The human opens the verification page, signs in (or creates an OpenTrain account), and enters the 6-digit code shown by the agent. Call `opentrain_claim_status` at the suggested interval. While the human hasn't finished, it returns `pending`. When it returns `claimed`, the server automatically upgrades the saved token to one with the full post-claim scopes — no manual token swap needed. See [Authentication](/docs/developers/concepts/authentication) for the full claim lifecycle, expiry windows, and error semantics. ## Environment Variables | Variable | Purpose | Default | | ------------------------------ | -------------------------------------------------- | -------------------------- | | `OPENTRAIN_PERSONAL_API_TOKEN` | Bearer token; overrides the saved credentials file | — | | `OPENTRAIN_API_BASE_URL` | API origin | `https://app.opentrain.ai` | Credentials are stored at `${XDG_CONFIG_HOME:-~/.config}/opentrain/cli.json` (mode `0600`), shared with the [CLI](/docs/developers/quickstart-cli) — register once, use both. ## Next Steps The complete tool reference: parameters, scopes, and which endpoint each tool wraps. Read proposals, AI-interview transcripts, and AI trainer profiles before you hire. # AI screening Source: https://opentrain.ai/docs/employers/ai-screening Use AI interview requirements and structured screening fields to compare candidates before hiring. AI screening helps you review candidates before a proposal reaches the hiring decision. For jobs with screening configured, candidates complete an AI interview first. You receive structured answers, interview context, and comparison signals in the employer workspace. ## Set the interview requirements You configure screening in Step 5 of the job wizard. The AI interview requirements are the instructions the interviewer follows with each applicant. Employer job wizard showing AI interview requirements for healthcare documentation review experience. Good screening instructions usually include: * credentials or domain experience to verify; * tools, data types, or task types the candidate should know; * availability, timezone, or workload constraints; * any role-specific questions you want answered before proposal review. ## Choose the screening depth Use the second screening step to set the expected experience level and turn on the screening signals that matter for this job. Employer job wizard showing experience requirement toggles, structured AI interview extraction, and Open Label assessment controls. For recruiting-configured jobs, structured interview extraction can collect fields such as LinkedIn profile, years of experience, availability, or custom answers. Those fields appear in the screening table and CSV export. ## What candidates do When a candidate starts a proposal for a screened job: 1. They answer the AI interview questions before submitting the proposal. 2. The interviewer asks for the evidence you configured. 3. Required answers are captured in a structured format. 4. The candidate submits the proposal after the interview is complete. The interview can use resume and profile context to flag mismatches. Those signals help review; they do not replace your hiring decision. ## Where you review answers Screening answers appear in proposal review and folder-level screening. Use them to compare applicants by the same fields instead of reading every proposal from scratch. Employer Proposals tab showing an AI trainer proposal with interview score, rate, country, status, and View Proposal action. For folders, the **Screening** tab aggregates candidates across related jobs. Use it when you are reviewing a client campaign, hiring sprint, or multiple similar roles together. ## Export screening data If CSV export is available for your folder: 1. Open the folder. 2. Go to **Screening**. 3. Apply the filters you want reflected in the export. 4. Click **Export CSV**. The export contains one row per candidate and one column per configured screening field. # Invoicing & payments Source: https://opentrain.ai/docs/employers/invoicing-payouts Create and fund milestones, approve completed work, download invoices, and track employer payment history. OpenTrain AI uses a milestone-based payment model. As an employer, you fund work in stages, review completed milestones, and approve payment release when the work is ready. Hiring an AI trainer creates the contract and the first milestone. After that, the contract tab is where you manage funded work, add future milestones, approve completed work, and keep payment history tied to the job. ## Employer milestone workflow Milestones are discrete units of funded work tied to a contract. They live in the **Contract** tab for each hired AI trainer. Employer Contract tab showing the Milestone Timeline with Paid, Active & Funded, and Not Funded milestone rows, dollar amounts, and milestone actions. ### Creating the first milestone The first milestone is created during the hire flow. The amount and description you enter while hiring become the initial funded milestone for that AI trainer. ### Adding later milestones For subsequent work, go to the **Contract** tab of the job details view and add a new milestone from there. Each milestone requires: * A description of the deliverable * An amount (in USD) * An optional due date Employer Add Milestone modal with fields for milestone name, description, amount, and due date. ### Funding a milestone The first milestone is funded during the hire flow. Later milestones can be created before they are funded; they remain **Not Funded** until payment is collected. When you fund a milestone, OpenTrain AI charges your payment method through Stripe and marks the milestone as funded on the contract. The AI trainer sees the milestone as **Active & Funded** and can begin work. Milestones in **Not Funded** or **Active & Funded** status are shown in the **Contract** tab. Jobs with unresolved milestones cannot be archived. ### Approving a milestone Once the AI trainer marks a milestone as complete, you receive a notification to review and approve. After you approve: 1. OpenTrain starts payout to the AI trainer's connected payout method 2. The milestone status updates to **Paid** 3. A payout notification is sent to the AI trainer If the AI trainer still needs to finish payout setup, or if Stripe reports a retryable transfer condition, OpenTrain keeps the payout pending and prompts the AI trainer to complete setup from **Settings -> Payouts** before the payout can finish. Approve milestones only after you have verified the delivered work. Released funds are not self-serve reversible in the platform; contact support if you need help with a payment dispute or mistaken approval. Employer Contract tab showing the Approve & Release Payment action for an Active & Funded milestone in the Milestone Timeline. ## Invoices To view and download employer invoices, open **Reports** from the employer dashboard. Employer Reports page showing an Invoices table with project names, billing type, amounts or dashes, payment statuses, and Download buttons for invoice PDFs. ### Employer funding invoice Every time you fund a milestone, OpenTrain AI generates an employer invoice. You can view or download this invoice from the **Reports** section of your dashboard. The invoice includes: * Project name and milestone description * Billing type and amount * Payment status ### AI trainer payout invoice When a milestone is approved and payout starts, the AI trainer receives a payout invoice. As an employer, you may need to reference this invoice when reconciling payments with clients. ## Stripe billing setup OpenTrain AI processes all employer payments through Stripe. Before you can fund your first milestone, you need a valid payment method attached to your account. Navigate to **Employer Settings → Billing** (or your agency plan settings if you are on an agency plan). Enter your card or bank details. OpenTrain AI uses Stripe for secure payment processing — your payment information is stored by Stripe, not by OpenTrain AI. Agency and recruiting-configured accounts may require a subscription plan. Select the appropriate plan and confirm billing. Subscription billing is separate from individual milestone charges. ## Payment history To view past payments, go to the **Contract** tab for any job. Each approved milestone shows the amount, date, and payment status. For a printable record, use the invoice links in the **Reports** section of your dashboard, or share the AI trainer's payout invoice with your client. # Reviewing proposals & hiring Source: https://opentrain.ai/docs/employers/managing-candidates Review candidate proposals, compare AI interview signals, message applicants, and hire AI trainers. After you publish a job, matched AI trainers can complete screening and submit proposals. The employer job workspace shows the candidate list, interview signals, proposal status, messaging entry points, and hiring actions. ## Open the Proposals tab From **My Jobs**, select a published job. The job workspace opens with tabs for **Proposals**, **Instructions**, **Job Posting**, and **Settings**. Jobs with hired AI trainers also show job messages and contract details. Employer Proposals tab showing Jordan L., interview score, hourly rate, country, review status, and View Proposal action. ## Decide who to review first Proposal rows help you triage candidates quickly: * **Interview score** summarizes the AI interview result. * **Status filters** separate all, shortlisted, unreviewed, maybe, hired, declined, and resume-sent candidates. * **Rate** shows the candidate's proposed pricing. * **Proposal messages** opens pre-hire conversations for candidate questions. * **View Proposal** opens the candidate's full proposal and profile context. Use these signals to decide which applicants deserve deeper review, not as an automatic hiring decision. ## Message candidates before hiring Use proposal messages for pre-hire questions about availability, work samples, domain experience, or project details. Proposal messages stay tied to the proposal. After you hire, OpenTrain creates a separate job message thread for post-hire work. Keep delivery instructions, milestone questions, and ongoing coordination in the job message thread. ## Hire an AI trainer When you are ready to hire: Click **View Proposal** from the Proposals tab. Check the proposal message, AI interview answers, resume/profile context, rate, and any screening signals. Click **Hire** and enter the first milestone scope and amount. OpenTrain collects payment through Stripe and marks the first milestone as funded. Use the job message thread, instructions, and contract tab for ongoing work. Hiring creates the contract and first milestone. The proposal message remains available for pre-hire context, but the job message thread is the correct place for post-contract communication. ## What happens after hire The job workspace becomes the project control surface: * **Messages** — post-hire communication with the AI trainer. * **Contract** — active contract and milestone timeline. * **Instructions** — task guidelines for hired AI trainers. * **Job Posting** — the published job details. * **Settings** — job configuration and visibility controls. Use **Done Hiring** when the job is fully staffed and you no longer want new proposals. ## Organize review with folders Folders group related jobs by client, campaign, or hiring sprint. Folder-level screening can aggregate structured answers across multiple jobs, which is useful when several roles share the same evaluation criteria. # Employer overview Source: https://opentrain.ai/docs/employers/overview See how employers use OpenTrain to post jobs, review screened AI trainers, hire, message, and manage payment. OpenTrain helps employers find, screen, hire, message, and pay AI trainers (data labelers). You can post a job, receive proposals from matched candidates, compare AI interview answers, hire directly, and manage the contract from the same employer workspace. Employer dashboard showing job folders, job status filters, search, and the Post a new job action. ## What employers can do * Post self-serve AI training and data-labeling jobs. * Let OpenTrain match the job to relevant AI trainers. * Require AI interview screening before proposals are submitted. * Review proposal details, screening answers, rates, and candidate profiles. * Message candidates before hiring. * Hire AI trainers and fund the first milestone. * Manage post-hire job messages, instructions, contracts, invoices, and payments. ## How the workflow fits together Start from the employer dashboard and create a job in the guided wizard. You can paste a job description or upload a PDF, then review the structured job details before publishing. OpenTrain uses the job requirements to notify relevant AI trainers. For recruiting-configured jobs, candidates complete an AI interview before they submit a proposal. The job workspace shows proposals, interview scores, screening answers, rates, and candidate context so you can compare applicants faster than a manual spreadsheet review. Use proposal messages for pre-hire questions. After hiring, use the job message thread for contract work and delivery coordination. Hiring creates the contract, funds the first milestone, and opens the post-hire job workspace for the AI trainer. Track funded milestones in the contract tab, approve completed work, and download invoices from reports. ## Why this is easier than manual sourcing Manual sourcing usually means posting in multiple places, collecting unstructured applications, chasing candidates for screening answers, and rebuilding status tracking in a spreadsheet. OpenTrain turns that into one workflow: * Job requirements are captured once in the posting wizard. * Candidates answer the same screening questions before you review them. * Proposal status, messages, hiring, milestones, and invoices stay tied to the job. * Folder screening lets you compare candidates across related jobs or client projects. ## Go deeper Walk through the job wizard from dashboard entry to review and publish. Invite teammates, manage organization settings, and review team access. Give teammates access to jobs and folders from the employer dashboard. Configure interview requirements and review structured candidate answers. See what happens after candidates apply and how to move from review to hire. Fund milestones, approve work, and download employer invoices. # Posting jobs Source: https://opentrain.ai/docs/employers/posting-jobs Create and publish an employer job with the guided wizard, AI autofill, screening requirements, budget, and final review. Posting a job is the first step in the employer workflow. The wizard turns a job description into structured requirements that OpenTrain can use for matching, screening, proposal review, and hiring. ## Start from the employer dashboard From **My Jobs**, click **Post a new job**. Employer dashboard with job folders and a Post a new job button in the top right. ## Add a title and job description Step 1 asks for the job title. If you already have a job description, turn on **Have a job description?** and paste the text or attach a PDF. OpenTrain uses this content to pre-fill later wizard fields. Post a Job wizard Step 1 with a medical document QA title, pasted job description text, and an attach file control. Autofill works best when your description includes the work type, data format, required experience, location or language constraints, expected volume, and budget model. ## Review the structured job details The next steps convert the job description into structured fields. Review each section before publishing. Post a Job wizard Step 2 showing dataset description, data type, subject matter, data volume, pre-labeled dataset, locations, and languages. Post a Job wizard Step 3 showing labeling software options and task type controls. ## Configure screening Step 5 controls how applicants are screened before proposals reach you. Write the AI interview requirements, choose the required experience level, and turn on the screening signals that matter for the job. Post a Job wizard Step 5 showing AI interview requirements text for healthcare documentation review experience. Post a Job wizard Step 5 showing experience requirement toggles, structured AI interview extraction, and Open Label assessment controls. ## Set budget and review before publishing Step 6 sets the payment model. You can choose pay per label, pay per hour, or fixed price. You do not fund the project when you post; funding happens when you hire and create the first milestone. Post a Job wizard Step 6 showing pay per label, pay per hour, fixed price, and escrow explanation. Step 7 is the final review. Check each section, edit anything that needs correction, and publish when the job is ready. When you publish, OpenTrain validates the job, saves the final posting, and notifies matched AI trainers who fit the requirements. # Share projects with teammates Source: https://opentrain.ai/docs/employers/sharing-projects Give teammates access to a job or folder from the employer dashboard. Use project sharing when another teammate needs to help review candidates, manage a job, or work from a shared folder. You can share a standalone job or a folder from its detail page. ## Before you start Your teammate must already belong to your OpenTrain AI organization. If they are not listed in the sharing modal, ask an organization owner to add them from [Set up your employer team](/docs/employers/team-members) first. Project sharing is available to employer accounts that already have access to the job or folder. Owners always keep access. Existing non-owner employer team members can share or manage project access only for jobs and folders they can already open. Only organization owners can add new people to the organization. ## Share a job or folder From **My Jobs**, open the job or folder you want to share. Focused crop of the job header showing the Share button highlighted next to Archive. Click **Share** in the project header. Focused crop of the Share project modal showing an existing teammate selected and the Save access button enabled. In the sharing modal, turn on the teammate who should have access. The modal only lists existing teammates in your organization. Click **Save access**. The teammate can now open the shared job or folder from their employer dashboard. For folders, open the folder detail page and use the same **Share** button in the folder header. Everyone added to a folder share inherits access to the jobs inside that folder. Focused crop of the folder header showing the Share button highlighted between Archive and Add Job. Focused crop of the Share folder modal showing inherited job count, an existing teammate selected, and Save access enabled. ## If a teammate is not listed Only organization owners can add new people to your OpenTrain AI organization. If the teammate you need is missing from the sharing modal, ask an organization owner to add or invite them from [Team settings](/docs/employers/team-members). After the teammate joins the organization, reopen the **Share** modal for the job or folder. The teammate should appear in the access list. If you are an organization owner and cannot open Team settings, email [support@opentrain.ai](mailto:support@opentrain.ai) with your organization name and the teammate you need to add. ## Remove project access Open the same **Share** modal from the job or folder header. Remove the teammate from the access list, then save the change. Removing access only changes that teammate's access to the selected job or folder. It does not remove them from your OpenTrain AI organization. ## What teammates can do Shared teammates can help with the employer workflow for the job or folder they can access. Depending on their organization role and project access, this can include reviewing candidates, reading project details, and participating in related project work. For a detailed access review, open **Job Settings** and go to **Project access**. Use this page when you need to check the full access list or make a more careful access decision. Focused crop of the Project access card showing the owner row, teammate toggle, and Save access button. Use folders when several jobs belong to the same client, campaign, or workstream. Sharing the folder gives teammates one place to review the related jobs and candidates. # Set up your employer team Source: https://opentrain.ai/docs/employers/team-members Invite teammates, manage your organization profile, and control which jobs and folders each teammate can open. Use your employer team when more than one person needs to help post jobs, review candidates, manage project access, or coordinate work in OpenTrain AI. Only organization owners can invite people to the team, remove team members, and manage organization-level settings. Team members can work on the jobs and folders they have access to. ## Open team settings Sign in to [app.opentrain.ai](https://app.opentrain.ai), open **Employer Settings**, and select **Team**. Employer Settings Team tab showing organization details, the invite teammates form, and team controls. ## Invite a teammate In **Invite teammates**, enter one or more email addresses. Press **Enter**, comma, or tab after each email address, then click **Send invite**. Invite teammates card with an email address entered and the Send invite button ready. OpenTrain sends each invitee an email with a link to join your employer team. If the person already has an eligible employer account, OpenTrain can add them to the team. Otherwise, they create an employer account from the invite link. ## Confirm added teammates Use **Team members** to confirm who already belongs to the organization, each person's role, and their current project access. After an invitee joins, this list is the confirmation state that they have been added to the employer team. Team members list showing an owner and teammate rows with role and access summary. Owners always keep access to every job and folder. Other team members only see the jobs and folders assigned to them or shared with them. ## Manage a teammate's access Click **Manage access** on a teammate row when you need to review or change that person's job and folder access. Manage access modal with job and folder access controls for a teammate. Project access controls which jobs and folders the teammate can open. It also affects shared project messaging and internal project notifications for that teammate. ## Remove a teammate Use the teammate row menu when someone should no longer belong to the organization. Removing a teammate removes their organization membership and their access to jobs and folders in that organization. If you only need to remove access to one job or folder, use **Manage access** or the project sharing page instead of removing the teammate from the organization. ## How this relates to project sharing Team setup controls who can belong to the organization. Project sharing controls which existing teammates can open a specific job or folder. After a teammate has joined the organization, use [Share projects with teammates](/docs/employers/sharing-projects) to share a standalone job or folder with them. # OpenTrain AI Documentation Source: https://opentrain.ai/docs/index Connect with expert AI trainers / data labelers, or find your next AI training project. Welcome to OpenTrain AI — the platform that connects employers with skilled AI trainers (data labelers) worldwide. Whether you're building datasets, running RLHF projects, or sourcing specialized annotation talent, OpenTrain AI handles the recruiting, screening, and payment workflow end-to-end. Get up and running in minutes. Create your account and post your first job or complete your AI trainer profile. Post jobs, screen candidates with AI interviews, manage folders, and pay your team through milestones. Find AI training projects, complete your profile, go through the AI interview, and get paid through a connected payout method. Coordinate proposals, job channels, and project communication in one place. ## How OpenTrain AI works Sign up at [app.opentrain.ai](https://app.opentrain.ai) as an employer or as an AI trainer. Account type determines your dashboard and available features. Employers create job postings with AI-powered autofill and configure screening requirements. AI trainers complete their profile, upload their resume, and set their skills and language capabilities. OpenTrain AI's matching engine surfaces the most relevant candidates for each job. Candidates complete an AI-driven interview before submitting proposals, capturing structured answers for employer review. Collaborate through the messaging system, track progress via milestones, and process payments through OpenTrain. AI trainer payout availability depends on the AI trainer's bank country — see [Payout country availability](/docs/payments/payout-country-availability) for the current list. ## Explore by role Create job postings with AI-assisted autofill, set screening requirements, and publish to matched candidates. Configure structured AI interview fields and review candidate screening results in your folder dashboard. Browse and apply to AI training projects matched to your skills, languages, and experience. Understand how milestone-based payments and AI trainer payouts work on the platform. # Introduction Source: https://opentrain.ai/docs/introduction OpenTrain AI connects employers with skilled AI trainers / data labelers worldwide. OpenTrain AI is a hiring platform built specifically for AI training work: data labeling, annotation, RLHF, fine-tuning, and other tasks that go into building reliable AI systems. If you need people to label your data, annotate your datasets, or train your models, you post a job. If you do that kind of work, you build a profile and apply. ## Who it's for **Employers** are companies and teams that need AI training talent. You post jobs, review candidates, run AI-powered screening, and pay your hired AI trainers through the platform. **AI trainers (data labelers)** are the people who do the work. You set up your profile, upload your resume, and apply to jobs that match your skills — whether you work independently or as part of an agency. ## How it works 1. **Sign up** at [app.opentrain.ai](https://app.opentrain.ai) and choose your account type. 2. **Set up your account** — employers complete a short profile; AI trainers go through a multi-step onboarding wizard that captures your experience, skills, education, and hourly rate. 3. **Post or apply** — employers publish job listings and receive candidate proposals; AI trainers browse matched jobs and submit applications. 4. **Work and get paid** — communicate through the built-in messaging system, track work via milestones, and receive payment to your connected bank account. ## Key features * **AI-powered job matching** — the platform surfaces the most relevant candidates for each job based on skills, experience, and language capabilities. * **Resume autofill** — AI trainers upload a resume and the platform auto-populates their profile fields, reducing manual data entry. * **Structured AI screening** — employers configure interview questions; candidates answer them before submitting proposals, giving you structured data to review. * **Agency onboarding** — teams of AI trainers can onboard as an agency, with company-level profiles covering headcount, security credentials, and pricing. * **Milestone-based payments** — employers pay through milestones; AI trainers add a payout method through OpenTrain and receive payouts in [supported payout countries](/docs/payments/payout-country-availability). * **Integrated messaging** — proposal messages and job-channel conversations keep all communication in one place. ## Where to go next Post jobs, review candidates, and manage your team. Complete your profile, find projects, and get paid. # Job Channels Source: https://opentrain.ai/docs/messaging/job-channels How to use post-hire job messaging on OpenTrain AI — direct messages with hired AI trainers and group channels for your job team. Once you hire an AI trainer, a new set of conversations becomes available: job messages. These are separate from the proposal conversation and exist specifically for the active job contract. There are two subtypes: **Job DMs** for 1:1 communication, and **Job Channels** for group conversations. ## Job DMs When you hire an AI trainer, OpenTrain AI automatically creates a 1:1 direct message conversation between you and that AI trainer. You don't need to do anything — it's ready as soon as the hire is confirmed. Job DMs are the right place for: * Day-to-day check-ins with a specific AI trainer * Sharing feedback on completed work * Discussing milestone progress or blockers * Any conversation that should stay private between you and one AI trainer ## Job channels Job channels are group conversations you create for your job team. They're useful when you have multiple AI trainers working on the same job and need a shared space for updates, questions, or coordination. OpenTrain AI messaging inbox with the Job Messages tab selected. A Red-team review notes job channel is open with a hash icon, three members, channel settings, a shared team message thread, and a composer for posting to the channel. For complex jobs with multiple team members, create a dedicated channel early. Having a shared space for the whole team reduces duplicate messages and keeps everyone aligned without relying on individual DMs. ### Creating a channel Navigate to the job in your employer dashboard and open the **Messages** tab. Click the **+** or **Create channel** button to open the channel creation flow. Give the channel a name that reflects its purpose — for example, `ProjectUpdates` or `DataQuestions`. Select which hired AI trainers to include. You can add more participants later. ### Adding participants After a channel is created, you can add participants at any time. Open the channel, click the channel name in the header to open channel settings, and use **Add participants** to include additional hired AI trainers. You can also configure a channel to automatically include new hires for the job as they join the team. ## Finding job messages All post-hire conversations — both Job DMs and Job Channels — appear in the **Job Messages** tab of your inbox. You can filter further within that tab: * **All** — every job conversation * **Direct messages** — only 1:1 job DMs * **Channels** — only group channels ## Messaging on mobile The OpenTrain AI Android app is available for on-the-go communication. You can send and receive messages, check unread conversations, and stay connected with your job team from your phone. Job messages and proposal messages appear in separate tabs. If you're looking for a conversation with a hired AI trainer and can't find it, make sure you're on the **Job Messages** tab — not Proposal Messages. # Messaging Source: https://opentrain.ai/docs/messaging/overview How messaging works on OpenTrain AI — find your conversations, stay on top of unread messages, and communicate before and after hiring. Messaging on OpenTrain AI is split into two distinct types of conversations, depending on where you are in the hiring process. Understanding the difference helps you find the right conversation at the right time. ## Two types of conversations Pre-hire. 1:1 conversations between an employer and a candidate about a specific job proposal — before any hiring decision is made. Post-hire. Conversations tied to an active job contract, including direct messages with a hired AI trainer and group channels for the whole job team. Proposal messages and job messages appear in separate inbox tabs. If you can't find a conversation, check that you're looking in the right tab. ## Your inbox Your inbox is where all conversations live. You can access it from the main navigation. Inside, you'll see two tabs: * **Proposal Messages** — all pre-hire conversations linked to job proposals * **Job Messages** — all post-hire conversations, including 1:1 job DMs and group channels OpenTrain AI messaging inbox showing the Proposal Messages tab active. The left sidebar lists proposal conversations with AI trainer names, message previews, and unread indicators. The Job Messages tab is visible at the top of the sidebar. Selecting a conversation in the list opens the full message thread on the right. On mobile, the thread takes over the full screen and you can navigate back to the list. ## Finding a conversation Use the search bar inside the conversation list to filter by name or keyword. You can also filter by: * **All messages** — every conversation you're part of * **Needs reply** — conversations where a response is expected from you * **Job messages** — only post-hire conversations * **Proposal messages** — only pre-hire conversations ## Unread indicators A dot or badge appears next to any conversation that has unread messages. The count clears automatically when you open the thread and read through to the latest message. You can mute a conversation or snooze notifications if you need to reduce interruptions — these settings are available from the conversation header. ## Next steps How to use pre-hire messaging with candidates How to use post-hire messaging and group channels # Proposal Messages Source: https://opentrain.ai/docs/messaging/proposals How to use pre-hire messaging on OpenTrain AI — communicate with candidates during the proposal and review stage before making a hiring decision. Proposal messages are 1:1 conversations that exist before any hiring decision is made. They let employers and candidates communicate directly about a specific proposal — asking questions, clarifying expectations, or discussing fit. ## When proposal messages appear A proposal conversation becomes available as soon as a candidate submits a proposal for a job. Either party can open a message thread from the proposal at that point. Proposal messages are tied to the proposal itself, not to the job. If you later hire the candidate, the proposal conversation stays exactly as it was — a separate job conversation is created for the ongoing work. ## For employers As an employer, you can use proposal messages to: * Ask candidates follow-up questions during your review * Request clarification on their experience or availability * Share additional context about the role before making a decision You'll find all your proposal conversations in the **Proposal Messages** tab of your inbox. The list shows each candidate's name and the last message, so you can quickly pick up where you left off. When you open a proposal conversation, you'll also see context about the proposal and which job it's linked to. This helps you stay oriented when you're reviewing multiple candidates at once. ## For AI Trainers As an AI trainer, you can message an employer at any point after submitting a proposal — before or after they respond. This is a good place to: * Ask questions about the job scope, timeline, or expectations * Follow up on your application status * Clarify anything in your proposal that might need more context You'll find your proposal conversations under the **Proposals** tab in your messages inbox. ## The message composer The composer at the bottom of each conversation supports plain text. You can send a message by typing and pressing Enter (or clicking the send button). OpenTrain AI proposal conversation thread. The left sidebar shows the Proposal Messages tab with multiple conversations listed. The main area shows an open thread with pre-hire messages between an employer and AI trainer, and the composer bar at the bottom with attachment, voice, and send controls. You can also: * Attach files using the attachment icon * Send voice messages using the microphone icon * React to messages with emoji * Edit or delete your own messages after sending ## Finding proposal messages Go to your messages from the main navigation. In the conversation list sidebar, click the **Proposal Messages** tab. All pre-hire conversations appear here. Click any conversation to open the full message thread. Unread conversations are marked with a dot. Proposal messages stay in the Proposal Messages tab even after a candidate is hired. Your pre-hire conversation history is preserved and separate from the job conversation that's created at hire time. # Milestone payments Source: https://opentrain.ai/docs/payments/milestones A shared reference for OpenTrain AI milestone statuses and how employer funding turns into AI trainer payouts. Milestones are the payment units inside an OpenTrain contract. Each milestone has an agreed scope, amount, and status. Employers fund milestones before work starts, and AI trainers request payout after completing the work. Use this page as a shared status reference. For the step-by-step workflow, use the role-specific guide for your account type. Create milestones, fund work, approve completed milestones, and download invoices. View funded milestones, request payout from the Contract tab, and track payout records. ## How the payment flow works The employer defines a work unit, amount, and optional due date. Funding the milestone charges the employer's payment method and marks the milestone **Active & Funded**. The AI trainer sees the funded milestone in the contract view and completes the agreed deliverable. The AI trainer clicks **Complete & Request Payout** from the Contract tab. The milestone moves to **Pending approval** while the employer reviews the work. The employer clicks **Approve & Release Payment**. OpenTrain marks the milestone **Paid** and starts payout to the AI trainer's connected bank account. ## Milestone statuses | Status | What it means | | -------------------- | ----------------------------------------------------------------------------------------------------- | | **Not Funded** | Created but not yet funded — no payment committed | | **Active & Funded** | Employer has funded the milestone; payment is committed but not released | | **Pending approval** | AI trainer has submitted work; employer review needed | | **Paid** | Employer approved the work; payout is completed or pending to the AI trainer's connected bank account | | **Disputed** | Milestone is under review | ## Disputes If there is a disagreement about whether work meets the milestone criteria, the milestone may enter a **Disputed** status. If you reach a point where you cannot resolve a dispute directly with the other party, contact [support@opentrain.ai](mailto:support@opentrain.ai) for assistance. # Setting up payouts as a non-US freelancer Source: https://opentrain.ai/docs/payments/non-us-payout-setup How to add a non-US bank account to OpenTrain payouts — what to do about the 'Stripe doesn't support my country' message, and where to get help. > Add a non-US bank account from inside OpenTrain. You don't sign up at stripe.com. ## You don't need a stripe.com account A common worry: you searched for "Stripe in my country," saw the country isn't supported for new business signups, and thought you couldn't get paid by OpenTrain. You don't sign up at **stripe.com**. You add a payout method **inside OpenTrain settings** at [app.opentrain.ai](https://app.opentrain.ai) and enter your bank details there. Stripe is the payments processor we use behind the scenes, but you don't create or manage a Stripe.com business account to receive OpenTrain payouts. The country list that matters for you is the list of countries OpenTrain can pay into — not the list of countries that can sign up for a Stripe.com business account. If your country isn't supported in OpenTrain, the Payouts tab will tell you, and we'll help you from there. ## What you'll need Before you start, have these on hand: * A **bank account in your name** (or your registered business name) in the country where you live. * A **government-issued ID** (passport, national ID, or equivalent). * Your country's **tax identifier** if we ask for one during setup. If you're not sure what to enter, you can pause and contact support. ## Step-by-step: add your bank account ### 1. Open Settings → Payouts in OpenTrain Sign in to [**app.opentrain.ai**](https://app.opentrain.ai) and open the [**Payouts** tab in AI Trainer settings](https://app.opentrain.ai/ai-trainer-settings?tab=payouts). Before you've added a payout method, the tab shows an **Urgent: Set Up Your Payout Method** banner, a **No Payout Method Set** card, and the **Add Payout Method** button. If your country is supported, you'll see a green confirmation row — for example, **"\ is supported for payouts"** — right above the button. The full OpenTrain AI Trainer Settings page with the Payouts tab selected, showing the Urgent: Set Up Your Payout Method banner, the No Payout Method Set card, the green country-supported confirmation row, the Add Payout Method button, and the FAQ accordions below. ### 2. Click Add Payout Method When you click **Add Payout Method**, OpenTrain opens the **Setup your payout method** modal. The modal includes a **How it works** callout (your bank info stays private and encrypted, no extra account to manage, payouts go straight to your local bank), an **Important: Do not create a separate Stripe account** alert, and a **Confirm your payout country** callout that names the country your payouts will be locked to. If you started this before and didn't finish, the same button is labeled **Resolve Payout Requirements** instead — click that to pick up where you left off. The full Setup your payout method modal in OpenTrain, showing the How it works callout, the Important: Do not create a separate Stripe account alert, the Confirm your payout country callout, and the Back to Settings and Continue buttons all visible together. ### 3. Continue through the secure form to enter your bank details Clicking **Continue** redirects you to a short secure form, hosted by Stripe on OpenTrain's behalf. You'll fill in identity, business, and bank-account details inside this form across a few steps. This is **not** creating or signing in to a Stripe.com business account. You're entering bank details into a form that Stripe hosts for OpenTrain so the payout can reach your bank. The first step of the Stripe-hosted secure form opened from OpenTrain, showing the OpenTrain branding panel on the left and the form fields on the right where the freelancer begins entering bank details. After this entry step, the same secure form walks you through your personal or business details, ID, and bank account. We don't ask for anything outside what we need to send you money and meet the legal requirements where you live. ### 4. You're connected When the secure form is complete, you come back to OpenTrain. The Payouts tab now shows your connected payout method and your **Stripe Balance**. The connected-state content card on the OpenTrain Payouts tab, showing the connected bank account, the Stripe Balance row, the Go to Stripe Dashboard button, and a payout method removal button. Most setups go active in a few minutes. Some need an extra verification step (an ID upload, a clarified field, or a tax identifier) — if so, the Payouts tab will tell you exactly what's outstanding. ## If you picked the wrong country OpenTrain locks the payout setup to the country you confirmed in the modal, because Stripe locks the bank account to that country. If you confirmed the wrong country, **don't try to fix it on stripe.com** — that won't change anything for OpenTrain payouts. Instead, [email **support@opentrain.ai**](mailto:support@opentrain.ai) with the country your bank account is actually in. We'll help you switch the payout setup to the correct country without losing your payout history. ## About tax identifiers If we ask for a tax identifier during setup, it's because the country you selected requires one — for example, a VAT number in the EU, GSTIN in India, BN in Canada, or the local equivalent. Enter the identifier for the same country as your bank account. OpenTrain does not give tax or legal advice. If you're not sure which identifier to enter, pause the setup and email [support@opentrain.ai](mailto:support@opentrain.ai) — we'll help you read the field and point you to your country's official source. ## If your country isn't supported If you reach the country step and OpenTrain tells you your country isn't supported for payouts, **don't go to stripe.com to try to sign up there** — that won't unlock anything for OpenTrain payouts. Instead, [email **support@opentrain.ai**](mailto:support@opentrain.ai) with: * the country where your bank account is held, and * a screenshot of the message you saw. We'll tell you whether there's a workaround (for example, a different supported country where you also hold a bank account, or a partner-payout option) and what to do next. ## Once you're set up * Payouts go to the bank account you connected. You can see and manage that account from the [**Payouts** tab](https://app.opentrain.ai/ai-trainer-settings?tab=payouts). * To change your bank account, your registered business details, or your tax info, click **Go to Stripe Dashboard** on the same tab. That opens Stripe Express for your OpenTrain payout method. You're not creating a stripe.com business account; you're managing the payout setup that exists only to deliver your OpenTrain payouts. * If you need to change the country your payouts are registered in, contact support — we'll help you do it without losing past payout history. ## If you're stuck Email [**support@opentrain.ai**](mailto:support@opentrain.ai) if any of these happen: * The country setup is locked to the wrong country. * A field (TIN, business details, ID) is rejected and the message isn't clear. * The setup loops back to the start without completing. * Your country isn't shown in the setup flow at all. A human on our team will pick it up. # Payments Overview Source: https://opentrain.ai/docs/payments/overview How payments work on OpenTrain AI — the milestone-based model, employer payment methods, and AI trainer payout setup. OpenTrain AI uses a milestone-based payment model. Employers fund work in advance, AI trainers complete deliverables, and funds are released through Stripe once the employer approves. OpenTrain AI uses Stripe for payment processing. Employers add a payment method to fund milestones. AI trainers add a payout method from **Settings -> Payouts -> Add Payout Method**; Stripe hosts the secure setup flow for identity, tax, and bank details. ## How payments work When structuring a job contract, you (the employer) create milestones that represent discrete units of work with agreed payment amounts. Funding a milestone charges your payment method and holds the funds securely. The AI trainer sees the funded milestone in their job view and completes the agreed work. When ready, they request approval to release the payment. You review the completed work and approve the milestone. Approval triggers payout to the AI trainer's connected bank account. Stripe releases the funds to the AI trainer's connected bank account according to the payout schedule. ## What each side needs to set up As an employer, you need a valid payment method on file to fund milestones. This is charged when you click **Fund** on a milestone in a job contract. No separate Stripe account setup is required on your side — OpenTrain AI handles payment collection through Stripe on your behalf. As an AI trainer, you must connect a payout method in your OpenTrain account settings before you can receive payments. You start from **Settings -> Payouts -> Add Payout Method**. Stripe hosts the secure form, but you should not create a separate Stripe account on stripe.com. See [Payments & payouts](/docs/trainers/payments-payouts) for setup instructions. ## Learn more Shared milestone status reference for employers and AI trainers How employers fund milestones, approve completed work, and download invoices How AI trainers view milestones, request payout, add a payout method, and download payout records Supported countries for OpenTrain payout setup # Payout country availability Source: https://opentrain.ai/docs/payments/payout-country-availability Countries where you can set up payouts on OpenTrain AI. This page lists the countries where you can set up payouts on OpenTrain AI today. If your country is here, you can start from **Settings -> Payouts -> Add Payout Method**, complete Stripe-hosted payout setup, and receive payouts to a local bank account. When you click **Add Payout Method**, the same supported list appears inside Stripe's hosted payout setup — what you see there matches this page. You do not need to create a separate Stripe account on stripe.com. ## Supported countries | Country | Country | Country | | -------------------- | ------------------- | -------------------- | | Albania | Germany | Nigeria | | Algeria | Ghana | North Macedonia | | Angola | Gibraltar | Norway | | Antigua & Barbuda | Greece | Oman | | Argentina | Guatemala | Pakistan | | Armenia | Guyana | Panama | | Australia | Hong Kong SAR China | Paraguay | | Austria | Hungary | Peru | | Azerbaijan | Iceland | Philippines | | Bahamas | India | Poland | | Bahrain | Indonesia | Portugal | | Bangladesh | Ireland | Qatar | | Belgium | Israel | Romania | | Benin | Italy | Rwanda | | Bhutan | Jamaica | San Marino | | Bolivia | Japan | Saudi Arabia | | Bosnia & Herzegovina | Jordan | Senegal | | Botswana | Kazakhstan | Serbia | | Brunei | Kenya | Singapore | | Bulgaria | Kuwait | Slovakia | | Cambodia | Laos | Slovenia | | Canada | Latvia | South Africa | | Chile | Liechtenstein | South Korea | | Colombia | Lithuania | Spain | | Costa Rica | Luxembourg | Sri Lanka | | Côte d’Ivoire | Macao SAR China | St. Lucia | | Croatia | Madagascar | Sweden | | Cyprus | Malaysia | Switzerland | | Czechia | Malta | Taiwan | | Denmark | Mauritius | Tanzania | | Dominican Republic | Mexico | Thailand | | Ecuador | Moldova | Trinidad & Tobago | | Egypt | Monaco | Tunisia | | El Salvador | Mongolia | Türkiye | | Estonia | Morocco | United Arab Emirates | | Ethiopia | Mozambique | United Kingdom | | Finland | Namibia | United States | | France | Netherlands | Uruguay | | Gabon | New Zealand | Uzbekistan | | Gambia | Niger | Vietnam | ## If your country isn't listed If your country isn't on this list and OpenTrain's payout setup doesn't show it either, OpenTrain AI cannot pay out to that country today. Email [support@opentrain.ai](mailto:support@opentrain.ai) to register interest. We don't have a published timeline for adding new countries. Last updated: 2026-05-01. # Payouts Source: https://opentrain.ai/docs/payments/payouts Where to find AI trainer payout setup, supported-country guidance, and payout troubleshooting. Payout setup is an AI trainer workflow. Use the AI trainer payment guide for step-by-step instructions, and use the supporting reference pages for country availability and non-US setup details. Request milestone payout, add a payout method through OpenTrain, handle incomplete setup, and download payout invoices. Check the countries where OpenTrain AI can pay AI trainers through Stripe-hosted payout setup. Add a non-US bank account from inside OpenTrain and troubleshoot country-specific setup. ## Role-specific payment guidance | Reader | Go to | | ------------------------------------------------------- | ---------------------------------------------------- | | AI trainer receiving milestone payouts | [Payments & payouts](/docs/trainers/payments-payouts) | | Employer funding work and downloading employer invoices | [Invoicing & payments](/docs/employers/invoicing-payouts) | | Shared milestone status reference | [Milestone payments](/docs/payments/milestones) | The Payments section is a shared reference area. Employer payment workflows live under **For Employers**. AI trainer payout workflows live under **For AI Trainers**. # Right to erasure / GDPR / CCPA requests Source: https://opentrain.ai/docs/privacy/erasure-requests Learn how to submit an erasure or personal-data request to OpenTrain, how identity verification works, and what response cadence to expect. If you are submitting a right-to-erasure or other personal-data request, for example under the EU GDPR, the UK GDPR, or the California Consumer Privacy Act, this page explains how to send the request, what we do with it, and how long each step takes. We are not your lawyer. This page describes our operational process. It does not interpret which laws apply to you, what your rights are under those laws, or whether OpenTrain is a controller, processor, or business as those terms are defined in any specific statute. ## How to submit a request Email `support@opentrain.ai` with the subject **Erasure request** from the email address tied to your OpenTrain account. Include: * The **email address** on the account. * The **action you are requesting**: deletion of the account, removal of specific content, or another erasure-related action. * Any specific URLs, pages, or records you want addressed. ## Identity verification Before we act, we verify the request actually comes from the account holder. This usually means replying from the email tied to the account, and in some cases additional confirmation. We complete this within **one business day** of acknowledging your request. ## Our response cadence * **Acknowledgement**: same business day, in our reply to your email. * **Status response** with the specific actions we will take: within **5 business days** of acknowledgement. * **Fulfillment target**: within **30 calendar days** of the verified request. This is the upper bound we plan to. The full data scrub on a deletion-style erasure typically completes within 24 hours of verification. If we cannot meet a step on time, we will tell you and explain why. ## What erasure executes For a verified request, erasure runs the same data-handling we apply to a deletion request, plus any specific records you call out, for example removal of your account-level identifiers from a particular conversation. Specifically, we: * Make your **public profile page private immediately** so anonymous visitors and search engines can no longer see it. * Set your account to closed and your profile to **`noindex`**. * Scrub your **name, email address, profile slug, profile photo, bio, location, resumes, integration IDs, and connected payment IDs** from your user record. * Delete your **work history, education entries, skill/language records, AI-interview entries, and messaging-preference records**. For the operational click-flow on the in-app deletion control, see [Delete your account](/docs/account-deletion). ## What erasure does not remove For operational and legal reasons, a small set of data persists after an erasure-style request: * **Records we are required to retain by law**, for example transaction and tax records tied to payments we have processed. * **Anonymized aggregate analytics** that no longer identify you as an individual. * **Third-party caches**, including Google search results. See [Removing your profile from Google search results](/docs/privacy/google-search-removal). * **Conversations and content you sent to other users on the platform**, where another user has a legitimate copy of those messages. As part of an erasure request, we will assess what is possible on a case-by-case basis. ## If you want to file a complaint If you believe we have not handled your request properly, you can contact your local data protection authority. Examples of authorities you can contact directly: * **EU/EEA**: your national data protection authority. The European Data Protection Board lists them at [edpb.europa.eu/about-edpb/about-edpb/members\_en](https://edpb.europa.eu/about-edpb/about-edpb/members_en). * **United Kingdom**: the Information Commissioner's Office at [ico.org.uk](https://ico.org.uk). * **California**: the California Privacy Protection Agency at [cppa.ca.gov](https://cppa.ca.gov). We do not represent any of those bodies and we cannot file complaints on your behalf. # Removing your profile from Google search results Source: https://opentrain.ai/docs/privacy/google-search-removal Learn how Google search removal works after an OpenTrain profile is hidden, removed, or deleted, including self-serve and support-assisted options. Google's search index is separate from OpenTrain. Removing or hiding a page on OpenTrain does not remove it from Google immediately. Google has its own crawl schedule. ## What we do automatically When a profile becomes private, is removed, or the account is deleted on OpenTrain, the page is set to **`noindex`** and is no longer accessible to anonymous visitors. On Google's next recrawl, that page drops out of the index. We also no longer include `/profile/...` URLs in our public sitemap, so we are not actively asking Google to index them. ## How long Google takes We cannot commit to a specific timestamp because Google controls its own recrawl schedule. In practice, recrawls of a page that has gone away or returned `noindex` typically complete within a few days to a few weeks. ## How to ask Google to drop the page faster If you want the page out of Google sooner than its next natural recrawl, you have two options: 1. **Request removal yourself in Google Search Console.** Google offers a [Remove Outdated Content tool](https://search.google.com/search-console/remove-outdated-content) for anyone; you do not need to own the site. Submit the exact OpenTrain profile URL, and Google removes the cached snippet and search result once it confirms the page is gone or has been updated. 2. **Email us at `support@opentrain.ai`** with the subject **Search Console removal** and the exact OpenTrain profile URL. We will submit a Search Console removal request for that URL on your behalf, after confirming the underlying page is gone or has been set to `noindex`. ## What we cannot do * We cannot guarantee how quickly Google removes the page from its results. That is Google's call. * We cannot remove the page from search engines other than Google through Google Search Console. For Bing or other engines, we will look at the equivalent webmaster tools on request. * We cannot remove third-party caches or screenshots that other sites may have made of a page when it was public. # How OpenTrain profiles appear publicly Source: https://opentrain.ai/docs/privacy/profile-visibility See what is visible on public OpenTrain AI Trainer and agency profiles, what is not public, and how to control profile and search visibility. When you create an OpenTrain account as an AI Trainer, your profile is part of how AI labs and employers find you. This page explains exactly what is visible to anyone on the public web today, what is not, and how to control it. ## What is on a public individual profile The public version of an individual AI Trainer profile, viewable without logging in, can include: * Your **first name and last initial** (for example, *Jordan M.*). Full last names are not displayed publicly. * Your **profile photo**, when you have uploaded one. * Your **headline / title** (for example, *Senior RLHF reviewer*). * Your **profile bio** (overview text). * Your **skills**, broken down into software, subject matter, data types, and task types. * Your **languages**, including English-fluency labels, when entered. * Your **city and country**, when entered. * Your **labeling experience**, **work history**, and **education**, when entered. * Your **hourly rate**, when set. Per-label and fixed-cost rates are not exposed as numbers; they show as *Inquire* calls to action. * Your **availability** and **online / last-seen status**. * Your **experience level**. * Your **work activity stats**: total earned, total hours, total projects, and the date you joined OpenTrain. * Your **ratings and reviews** from past clients, including review text, dates, and stars when reviews exist. * A **verified badge** when your email has been verified. * Your **share / public profile URL**. ## What is on a public agency profile For agency profiles, the public page shows the **agency name** rather than an individual's name, plus the agency's **website**, **headcount**, **security overview**, **security certifications**, and **industry experience** when entered. The agency-specific public sections replace the individual education and work-history sections. ## What is not on a public profile * Your **full last name**. * Your **email address**, **phone number**, or other direct contact details. * Your **resumes** and uploaded files. * Your **payout details**, payment methods, and bank/Stripe identifiers. * Your **applications**, **proposals**, **conversations**, or any private project history. * Your **identity-verification documents**. ## Search engines Public OpenTrain profiles are currently set to **`noindex`**. That tells search engines like Google and Bing not to add the page to their search index. This is a change from earlier in 2026, when public profiles could appear in Google. If you found your profile in Google previously, see [Removing your profile from Google search results](/docs/privacy/google-search-removal). ## How to control your profile visibility today Three controls live in your account today: * **Profile visibility**: choose whether your profile is public, visible only to signed-in OpenTrain users, or private. * **Search engine visibility**: keep search engines from indexing your profile even if it is publicly viewable. * **Account deletion**: request full deletion of your account; see [Delete your account](/docs/account-deletion). ## What is changing We are updating the onboarding and settings flows so that profile visibility and search-engine visibility are clearer, with a live preview of how an anonymous visitor would see your profile. We are not committing to a specific ship date here; keep an eye on this page. # Quick Start Source: https://opentrain.ai/docs/quickstart Get up and running on OpenTrain AI in a few steps, whether you're hiring or looking for work. Choose your path below. Each path covers the first actions that matter for your account type. Both flows start with the same **Create an account** form at [app.opentrain.ai](https://app.opentrain.ai): Screenshot of the OpenTrain "Create an account" page on app.opentrain.ai, showing First Name, Last Name, Email, and Password input fields, a Get Started button, and a Sign up with Google option, with the OpenTrain marketing message and supported data-labeling tool logos on the left. ## For employers 1. **Create your account**. Go to [app.opentrain.ai](https://app.opentrain.ai), click **Sign Up**, and select **Hire** as your account type. Enter the 6-digit verification code to activate your account. 2. **Add company details**. Open **Settings** from your employer dashboard and add your company name plus any billing information you need before hiring. 3. **Post your first job**. Click **Post a Job** and complete the job wizard with the title, description, required skills, experience level, screening questions, and budget. 4. **Review proposals and hire**. Once your job is live, review matched candidates, message them, and send offers from the proposal view. See [Account setup](/docs/account-setup) and [Posting jobs](/docs/employers/posting-jobs) for the full employer workflow. ## For AI trainers 1. **Create your account**. Go to [app.opentrain.ai](https://app.opentrain.ai), click **Sign Up**, and select **AI Trainer** as your account type. Enter the 6-digit verification code to continue to onboarding. 2. **Complete your profile**. Finish the onboarding flow with your resume, professional details, AI training experience, tools, education, languages, rate, availability, and profile title. 3. **Upload your resume**. Start with your general resume. You can also add an optional data-labeling resume to highlight annotation and training work. 4. **Find and apply to jobs**. Open **Find a Job** at [app.opentrain.ai/find-job](https://app.opentrain.ai/find-job), review matched jobs, and complete the employer's AI interview before you submit a proposal. If you're setting up a team, select **AI Trainer** at signup and choose the agency onboarding path in the next step. See [Account setup](/docs/account-setup), [Profile setup](/docs/trainers/profile-setup), and [Finding jobs](/docs/trainers/finding-jobs) for the full AI trainer workflow. # AI Interview Source: https://opentrain.ai/docs/trainers/ai-interview How the AI interview works on OpenTrain AI — what to expect, how to complete it, and tips for strong answers. Some employers require a structured screening step before they review proposals. This step is called the AI interview. It is an automated conversation that collects specific information from you so the employer gets consistent, comparable data from every applicant. ## Why it exists Employers who post jobs at scale need structured data — not unstructured cover letters — to compare candidates fairly. The AI interview replaces free-form questions with a guided conversation that captures the specific details the employer has asked for. Common examples include your LinkedIn profile, years of AI training experience, weekly availability, and whether you have worked on specific platforms before. Every answer you give is saved to a structured record that the employer can review alongside your resume and profile. ## What to expect The interview is a chat interface inside the proposal modal. An AI interviewer asks you a series of questions one at a time. You type your answers in plain text and continue the conversation until all required fields are captured. Common fields employers collect: * **LinkedIn URL** — your full LinkedIn profile link * **Years of experience** — specifically in AI training, data labeling, or annotation * **Weekly availability** — how many hours per week you can commit * **Previous AI training experience** — which platforms you have used and what types of tasks you have done (annotation, RLHF, SFT, evaluation, etc.) Some employers add additional custom questions specific to their project. ## Completing the interview When you click Apply on a job that requires an AI interview, the proposal modal opens directly into the interview screen. You cannot skip to the bid step until the required screening fields are captured. The AI interviewer opens with a greeting and explains what it needs from you. Read it before responding — it often tells you the full list of questions upfront. Submit a Proposal modal showing the Live Chat Screening Interview with an Available Now badge. The OpenTrain AI Interviewer asks the AI trainer to share their LinkedIn profile or a public portfolio link relevant to the role, while the message composer at the bottom is empty and the Send button is disabled. Answer each question directly and specifically. Vague responses like "I have experience" may cause the interviewer to ask follow-up questions to get a concrete answer. "I have 2 years of experience labeling text and image data on Scale AI and Labelbox" is far more effective. Respond to each question in turn. The interview captures your answers cumulatively — if you mention your LinkedIn URL naturally in a sentence, the system records it without needing a separate prompt. For your LinkedIn URL, paste the full URL (e.g., `linkedin.com/in/yourname`) directly in your message. The system validates URL format and will ask again if you enter something it cannot recognize as a LinkedIn profile. For experience-related questions, be specific: * Name the platforms you have used * Give a concrete number of years or months * List the task types (bounding box, RLHF, transcription, etc.) If you have no direct AI training experience, say so explicitly (e.g., "I don't have direct AI training experience yet"). A clear negative answer completes the field — the system does not leave it blank waiting for a different response. When all required fields are captured, the interviewer signals that the interview is complete. The chat view switches to a summary state and you can proceed to the bid step. If the interviewer keeps asking about a field you think you already answered, check whether your previous answer was specific enough. For example, answering "I use LinkedIn" does not satisfy a URL field — you need to paste the actual URL. After the interview completes, you confirm the results and enter your bid amount. Your proposal can now be submitted. ## Interview progress The system tracks the employer's required fields as you answer in the chat. When it captures enough information for one field, the interviewer moves to the next missing field. Submit a Proposal modal showing an in-progress Live Chat Screening Interview. The AI trainer has answered with a LinkedIn profile and four years of audio transcription QA experience, and the OpenTrain AI Interviewer asks what weekly availability the AI trainer can commit for the review pool. If the interviewer asks for a field again after you believe you answered it, provide the answer again with more specificity. The interviewer will accept it once the format or content meets the validation criteria. ## Resuming an interrupted interview If you close the proposal modal before finishing — or if your connection drops mid-interview — your progress is saved. When you open the job detail page again and click Apply, the proposal modal reopens at the point where you left off. Your existing chat history is preserved and the interviewer continues from where you stopped. You do not need to start over. Interview sessions are tied to your account and the specific job. If you clear your browser data or switch devices, the interview resumes from the server-side state — your answers are not lost. # How to delete your OpenTrain account Source: https://opentrain.ai/docs/trainers/delete-account How to delete your OpenTrain account, what gets removed, and what to wrap up first if you have an active contract or pending payout. You can delete your OpenTrain account from the platform at any time. ## How to start 1. Sign in at **app.opentrain.ai**. 2. Open the [**Account** tab in AI Trainer settings](https://app.opentrain.ai/ai-trainer-settings?tab=account). 3. Scroll to the **Delete Account** section at the bottom of the page and click the red **Delete account** button. The Delete Account section in AI Trainer settings, with a warning that deletion cannot be undone and the red Delete account button. We then ask you to type **DELETE** to confirm. The Delete Account confirmation modal with a text input that requires the user to type DELETE before the destructive button is enabled. Once you confirm, your access ends and your AI trainer profile is hidden from the platform right away. Final cleanup runs about an hour later. The Account tab shows the approximate window — it does not show an exact scheduled time. ## What happens to your data Some data is cleared, and some is kept on operational records. We're upfront about which is which. * **Cleared or anonymized**: your name, profile photo, public profile content, and AI interview history. * **Kept on operational records**: proposals you submitted, contracts you signed, payment records, direct-message history, and saved jobs. These stay attached to the jobs and employers they belong to so the platform's records of past work, payments, and conversations remain intact for the other party. Your name and profile no longer appear next to them once your account is closed. If you want a specific record reviewed for removal, email [support@opentrain.ai](mailto:support@opentrain.ai) with the email on the account. ## Active work and payouts If you have any of the following, you'll need to wrap them up before deletion can complete: * An **active contract** on a job you were hired for. * A **pending payout** that hasn't reached your bank account yet. * **Any unpaid balance from completed milestones** that hasn't been transferred out. The delete flow tells you which of these is open. Finish the contract, wait for the payout to land, and check that the unpaid-balance figure on your Payouts tab is zero before retrying. ## If deletion is stuck If you've completed the steps above and the account still shows as **Scheduled for deletion**, email [support@opentrain.ai](mailto:support@opentrain.ai) with the email on the account. A human on our team will look into it. # Finding Jobs Source: https://opentrain.ai/docs/trainers/finding-jobs How to find, evaluate, and apply to AI training and data labeling jobs on OpenTrain AI. The **Find Jobs** section is your primary tool for discovering work. It shows open projects from employers around the world, with recommended matches, search, saved jobs, filters, and job cards. From your AI trainer dashboard, click **Find jobs** to open job discovery. AI Trainer Find Jobs page showing the Search Jobs header, Recommended, Search, and Saved Jobs tabs, software filters, job cards, budgets, locations, language requirements, save buttons, and View Job actions. ## The job feed The feed has three tabs: Jobs ranked for you by the matching engine based on your skills, languages, labeling experience, location, and experience level. The recommendations update as your profile changes and as new jobs are posted. Your recommended jobs improve as your profile becomes more complete. Adding labeling experience entries, selecting your software tools, and confirming your languages all influence your ranking. Browse all open jobs with text search and filters. Available filters include: * **Software** — specific labeling or annotation platform * **Data type** — image, text, audio, video, code, etc. * **Label type** — bounding box, RLHF, SFT, evaluation, etc. * **Experience level** — entry, intermediate, or expert * **Budget type** — hourly, per-label, or fixed price * **Hours per week** and **project length** Use search when you want to find jobs for a specific tool or task type that may not be surfaced in your recommendations yet. Jobs you have bookmarked. Save any job from the recommended or search tab to review later. Saved jobs remain in this list until you remove them or the job closes. ## How matching works The matching engine scores every open job against your profile using: * **Skills** — software tools, data types, and label types you have listed * **Labeling experience** — the specific platforms and task types from your experience entries * **Languages** — jobs that require specific languages are matched against your listed languages * **Location** — some employers restrict applications to certain countries * **Experience level** — your declared level (entry, intermediate, expert) is compared to the job's requirements Jobs where you meet more of the criteria rank higher. Jobs that require something you do not have listed appear lower or are filtered out entirely. This is why a complete profile leads to meaningfully better matches. ## Browsing job details Click any job card to open the full listing. The detail page shows: * **Job description** — what the work involves, subject matter, and any special requirements * **Labeling details** — software, data type, label types, and languages required * **Budget** — hourly rate, per-label rate, or fixed price depending on the payment model * **Hiring criteria** — experience level, talent type (freelancer vs. agency), and location restrictions * **Employer profile** — company name, photo, and country Review the full detail page before applying. Pay attention to the required software and data types — if the job lists them as required and your profile does not include them, your proposal may not be considered. AI trainer job detail page showing an Audio Transcription QA Reviewer Pool listing, budget details, job tabs, a Save Job button, and a Continue Proposal button used to open the proposal flow. ## Applying for a job From the Find Jobs feed, click **View Job** on a job card. Review the full listing, then click **Submit a proposal**. If you already started the proposal, the button may read **Continue Proposal**. If your profile is incomplete, you will be prompted to finish it before continuing. Many employers configure a structured screening step. If this job requires one, the proposal modal opens directly into the AI interview. You must complete the interview before you can submit your proposal. See [AI interview](/docs/trainers/ai-interview) for a full walkthrough. Enter your bid price. Depending on the job's payment model, this is your hourly rate, your per-label rate, or your fixed project price. The form shows an estimated total earnings based on the volume the employer has specified. Review the summary and submit. Your proposal is sent to the employer immediately. Submit-a-proposal modal opened into the Live Chat Screening Interview, showing the OpenTrain AI Interviewer starting prompt and the response field. ## Job types Most jobs on the platform are standard listings. You apply, set your bid, and the employer reviews your proposal. There is no mandatory interview — the employer may have set optional questions or no screening at all. Some employers enable structured AI screening. These jobs require you to complete an AI interview before your proposal can be submitted. The interview captures specific information the employer needs — like your LinkedIn URL, years of experience, weekly availability, and previous AI training background. You can identify these jobs before applying: the job detail page will note that an AI interview is required, and the proposal flow will launch the interview automatically when you click Apply. ## After you submit Once your proposal is submitted, it enters the employer's review queue. You can track all your active proposals from the **Proposals** tab on your dashboard. The employer may: * **Message you** with questions before deciding — these land in your **Messages** tab * **Accept your proposal** — a contract is created and you move to active work * **Decline your proposal** — you are notified and can apply to other jobs There is no limit on how many proposals you can have active at once. # Managing Work Source: https://opentrain.ai/docs/trainers/managing-work How to manage active contracts, job messages, contract details, job instructions, calendar items, and reports as an AI trainer on OpenTrain AI. Once an employer accepts your proposal, a contract is created and your work begins. The AI trainer dashboard and the contract detail pages are where you manage everything from there. ## The AI Trainer dashboard Your main dashboard is accessible from the main navigation. Use the dashboard sections and side navigation to move between: | Tab | What's here | | ------------- | ----------------------------------------------------------- | | **My Jobs** | All active contracts and their status | | **Finished** | Completed contracts | | **Proposals** | Proposals you have submitted that are pending review | | **Messages** | All conversations — both proposal messages and job messages | | **Calendar** | Scheduled sessions or events your employer has set | | **Reports** | Invoices and payout records | The dashboard also shows recommended jobs on the home view so you can keep applying while managing active work. AI trainer dashboard in OpenTrain showing active ongoing jobs, unread job messages, and latest job activity panels. Each active job card includes the job title, employer, status, and a Start working action. ## Manage-job view For each active contract, click **Start working** or **Manage** to open the manage-job view. This page keeps the job-specific work areas together: All conversations related to this job — your 1:1 messages with the employer and any group channels they have set up for the team. You can send messages, view message history, and see who else is on the job. The contract detail showing the agreed scope, budget, milestones, and status. Use this tab to submit milestone work. For the full payment workflow, see [Payments & payouts](/docs/trainers/payments-payouts). Job instructions created by the employer as a collaborative document. This tab is available when the employer has published instructions for the project. Read this carefully before starting work — it is the primary source of truth for what the employer expects. The original job post and scope context, useful when you need to check what the employer asked for before work began. Job access details, such as team or tool access managed by the employer for the active contract. AI trainer manage-job view in OpenTrain for an active contract. The tabs Messages, Contract, Instructions, Job Posting, and Access are visible at the top. The Messages tab is selected, showing the job conversation thread with the employer. ## Contract detail page You can also access a full contract detail view from your dashboard. This page shows: * The full contract terms and scope * Milestone list with funding status * Work submission and approval history AI trainer manage-job view in OpenTrain with the Contract tab selected. The page shows the active contract scope, two milestones with Paid and Pending Approval status, and a billing summary with Paid Out, Active & Funded, and Total Budget amounts. ## Milestones Employers pay through milestones. Each milestone represents a deliverable or phase of work with a defined amount. The **Contract** tab shows milestone amounts, due dates, and current status so you can see what work is funded and what still needs review. Use [Payments & payouts](/docs/trainers/payments-payouts) for the step-by-step payout workflow, including **Complete & Request Payout**, payout method setup from **Settings -> Payouts**, payout invoices, and country availability. ## Job instructions Employers can create detailed job instructions as a collaborative document inside the platform. When instructions are published, they appear in the **Instructions** tab of your manage-job view. AI trainer manage-job view in OpenTrain with the Instructions tab selected. A published instructions document is visible, containing task guidelines, quality standards, and annotation requirements for the job. Instructions often include: * Task guidelines and quality standards * Examples of acceptable and unacceptable annotations * Tools or platforms to use * File naming conventions or submission formats Check the instructions before starting work and re-read them whenever they are updated. The employer can edit instructions at any time during the contract. ## Calendar The **Calendar** tab on your dashboard shows any scheduled events the employer has created — kickoff calls, check-ins, or deadline markers. Calendar entries are created by the employer and appear on your calendar automatically. ## Reports The **Reports** tab on your dashboard lists invoices and payout records. Use it to find records for completed or pending payment rows. For invoice download details, see [Payments & payouts](/docs/trainers/payments-payouts#payout-invoices-and-reports). # Why I'm not seeing any job listings Source: https://opentrain.ai/docs/trainers/no-jobs-listed What to do if your Recommended list or Jobs catalog looks empty. The fastest path is to send your account email to support so we can investigate. OpenTrain has many open jobs at any given time. If your **Recommended** list or your **Jobs** catalog is showing nothing, that usually means something is going wrong on our side and we want to look at it for you. ## Tell us so we can investigate The fastest way to get a real human looking at your account is to email [**support@opentrain.ai**](mailto:support@opentrain.ai) with: * the email on your OpenTrain account, and * a screenshot of the empty page you're seeing. We'll look at why your account isn't seeing the open jobs and write back with the next step. The OpenTrain Jobs page in an empty state, with no jobs listed in Recommended or in the Jobs catalog. ## While you're waiting A few quick things you can try while we look at it. None of these are the answer — they're checks that give us another data point if you mention them in your message: * **Clear any filters on the [Jobs catalog](https://app.opentrain.ai/find-job).** Clearing filters (skills, languages, location, status) is the fastest way to confirm whether the empty list is from filters or from our side. If the unfiltered catalog is still empty, include that in your message. * **Refresh the page.** Some empty states are momentary; a refresh on the Jobs page will pick up the latest open jobs. * **Make sure your profile is filled out.** A more complete profile gets better matches on the **Recommended** tab specifically. It's not what makes the catalog go from empty to non-empty — that's our job. ## About AI interviews AI interviews are configured per individual job, not per category. If a specific job requires an AI interview, complete it from inside that job's detail page. There is no per-category AI interview to complete before you can see jobs. ## If you're stuck Email [**support@opentrain.ai**](mailto:support@opentrain.ai). A human on our team will pick it up. # AI Trainer overview Source: https://opentrain.ai/docs/trainers/overview What AI trainers can do on OpenTrain AI — from building a profile to landing global projects. OpenTrain AI connects you — the AI trainer, annotator, data labeler, or RLHF specialist — directly with employers worldwide who need your skills. You set your own availability, apply to projects that match your background, and get paid through the platform. ## What you can do As an AI trainer on OpenTrain AI, you can: * **Label and annotate data** across image, video, text, audio, document, code, and more * **Perform RLHF tasks** — ranking, rating, and evaluating model outputs * **Complete fine-tuning and SFT work** — prompt/response writing, function calling, red-teaming * **Do transcription, translation, and localization review** * **Work as a freelancer or agency** — individuals and teams of AI trainers are both welcome The platform matches you with relevant jobs based on your skills, languages, and experience, so you spend less time searching and more time working. ## Key sections | Where to go | What you do there | | -------------------------- | --------------------------------------------------------------- | | **Find Jobs** | Browse and apply to open projects | | **Job detail** | Read full job details and start your application | | **Dashboard** (AI Trainer) | Your main hub — ongoing work, proposals, messages | | **Manage job** | Manage an active contract, view milestones, access instructions | | **Settings → Payouts** | Add your bank account for payouts | | **Onboarding wizard** | Complete or update your AI trainer profile | ## Finding work When you open `/find-job`, the platform surfaces a **Recommended** tab powered by the job-matching engine. Recommendations are ranked based on your skills, labeling experience, languages, and location. The more complete your profile, the better your matches. You can also use the **Search** tab to browse all open jobs with filters for software, data type, label type, experience level, budget, and project length. Save jobs you want to revisit from any tab. ## The application flow 1. Open a job listing and read the full details. 2. Click **Apply** to start your proposal. 3. If the employer has enabled AI screening, you complete a short AI interview before submitting. 4. Your proposal is sent to the employer for review. 5. If hired, a contract is created and you manage all work from the **Manage job** view in your dashboard. ## Working with agencies If you represent a team of AI trainers, you can onboard as an agency through the agency onboarding flow. Agency accounts unlock team-level job access and subscription plans that control how many jobs your team can see and apply to. ## Getting paid Payouts go to the bank account you connect in **Settings → Payouts**. OpenTrain uses Stripe-hosted payout setup to securely collect identity, tax, and bank details. You do not create a separate Stripe account on stripe.com. Payout support varies by country. Some countries are not yet supported. See [Payments & payouts](/docs/trainers/payments-payouts) for details. ## Where to go next Build a strong profile so the matching engine surfaces you to the right employers. Learn how to use the job feed, filters, and recommended matches. Understand the structured screening step that many jobs require. Track contracts, milestones, instructions, and active work. Request milestone payout, add a payout method, and download payout records. # Payments & payouts Source: https://opentrain.ai/docs/trainers/payments-payouts How AI trainers get paid through milestones, request payout, set up a payout method through OpenTrain, and download payout invoices. OpenTrain AI pays AI trainers through contract milestones. An employer funds a milestone, you complete the agreed work, and you request payout from the **Contract** tab when the milestone is ready for employer review. You need a connected payout method before OpenTrain can transfer money to you. Start setup from **Settings -> Payouts -> Add Payout Method**. Do not create a separate account on stripe.com. ## How milestone payouts work AI trainer Contract tab showing Paid and Active & Funded milestones, including the Complete & Request Payout action on an Active & Funded milestone. From the manage-job view, open **Contract**. The milestone list shows each milestone's amount, due date, and current status. **Active & Funded** means the employer has funded the milestone and the work can move forward. Finish the deliverable described in the contract and any published job instructions. Click **Complete & Request Payout** on the funded milestone. The milestone moves to **Pending approval** while the employer reviews the work. After the employer approves the milestone, OpenTrain marks it **Paid** and starts payout to your connected bank account. ## Milestone statuses | Status | What it means for you | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | **Active & Funded** | The employer has funded the milestone. Complete the deliverable, then use **Complete & Request Payout** when it is ready for review. | | **Pending approval** | You have requested payout. The employer needs to review and approve the completed work before payout can start. | | **Paid** | The employer approved the milestone. OpenTrain has started or completed payout to your connected bank account. | For the shared status reference, see [Milestone payments](/docs/payments/milestones). ## Payout invoices and reports The **Reports** tab on your AI trainer dashboard lists payout records. Each row shows the invoice number, job title, amount, status, and date. Use **Payment invoice** to download the payout PDF, or **Job statement** for a job-level breakdown. AI trainer dashboard Invoices & Reports tab showing payout invoice rows, pending approval rows, and a highlighted Payment invoice button in the Receipt column for downloading a payout PDF. ## Set up your payout method Set up payouts from **Settings -> Payouts** in your AI trainer dashboard. OpenTrain uses Stripe-hosted payout setup to securely collect identity, tax, and bank details. You should not go to stripe.com and create a separate Stripe account. Go to **Settings -> Payouts**. Before setup, the tab shows an **Urgent: Set Up Your Payout Method** banner, a **No Payout Method Set** card, and an **Add Payout Method** button. OpenTrain AI Trainer Settings page with the Payouts tab selected before setup, showing the Urgent: Set Up Your Payout Method banner, the No Payout Method Set card, the country-supported confirmation row, the Add Payout Method button, and the FAQ accordions below. Click **Add Payout Method**. OpenTrain shows a confirmation modal with the country your payouts will be locked to, then redirects you to Stripe's hosted payout setup. OpenTrain AI Setup your payout method modal, showing the How it works callout, the Important: Do not create a separate Stripe account alert, the Confirm your payout country callout, and the Back to Settings and Continue buttons. Stripe asks for your country, identity details, bank account, and any required tax details on OpenTrain's behalf. OpenTrain does not handle your bank information directly. After setup, Stripe redirects you back to **Settings -> Payouts**. The tab shows your connected bank account, **Stripe Balance**, and **Connected** status when payouts are enabled. OpenTrain AI Trainer Settings -> Payouts after payout setup, showing the Stripe Balance row, the connected bank account with a Connected badge, the Go to Stripe Dashboard button, and a payout method removal button. ## If payout setup is incomplete If your payout setup is incomplete, restricted, or pending, OpenTrain cannot finish payout to your bank account. Return to **Settings -> Payouts** and use **Add Payout Method**, **Resolve Payout Requirements**, or **Go to Stripe Dashboard** to finish the missing steps. Do not try to fix OpenTrain payout setup by creating a separate Stripe account. Return to **Settings -> Payouts** and click **Add Payout Method** again. Stripe usually continues the hosted setup from where you left off or starts a fresh onboarding link. Open **Settings -> Payouts** and click **Go to Stripe Dashboard**. Complete the requested identity, bank, or tax details there. Check [Payout country availability](/docs/payments/payout-country-availability). If your country is listed but setup still fails, see [Setting up payouts as a non-US freelancer](/docs/payments/non-us-payout-setup) or email [support@opentrain.ai](mailto:support@opentrain.ai). ## Payout timing OpenTrain uses a daily payout schedule. After a milestone is approved and funds are released, Stripe processes the transfer to your connected bank account. Processing times vary by country and bank. # Profile Setup Source: https://opentrain.ai/docs/trainers/profile-setup How to set up a strong AI trainer profile on OpenTrain AI — from basic info and skills to resume uploads and your public profile URL. Your profile is how employers find you and how the matching engine ranks you for jobs. A complete profile with specific labeling experience gets surfaced more often and to better-matched opportunities. You can access the profile wizard from **Settings → Profile** in your AI trainer dashboard. You can return to it any time to update your information. The first step uploads your resume so the platform can auto-fill your profile. Two resume types are supported: * **General resume** (required) — your overall work history and background * **Data labeling resume** (optional) — a separate resume focused on annotation, RLHF, or labeling-specific experience Accepted formats: PDF, DOCX, or TXT — max 10 MB each. When you upload your general resume, the platform parses it and pre-populates your work history, education, skills, and contact details. You review and edit each section in the steps that follow. The Upload Your Resume wizard step with a General Resume card showing Jordan_Lee_RLHF_Resume.pdf at 243 KB with a green 100% verification badge, plus a checked 'Add a specialized resume for Data Labeling' option that reveals a Data Labeling Resume card showing Jordan_Lee_Data_Labeling_Resume.pdf at 179 KB. If you upload a second resume later, the platform cross-checks it against your original to verify consistency. Significant mismatches may affect your profile status. Fill in your contact and location information: * **Full name** * **Country** — used for location-based job matching and payout eligibility * **City** * **Phone number** * **LinkedIn URL** — the platform normalizes your URL automatically Your country determines which jobs you are eligible to apply for when an employer restricts their listing to specific locations. This is the most important section for job matching. The step has two pages. On the first page, pick your overall **AI training experience level** — Entry Level (less than 1 year), Intermediate (1–3 years), or Expert (3+ years). On the second page you write a short **Profile Overview** (minimum 150 characters) and add one or more **labeling experience entries**. For each entry, the wizard captures: * **Platform or tool** used (e.g., Scale AI, Labelbox, CVAT, Appen, Remotasks, or internal tooling) * **Data types** you worked with — image, video, text, audio, document, code, 3D sensor, medical, geospatial * **Label types** you performed — bounding box, polygon, segmentation, classification, NER, RLHF, fine-tuning, SFT, red-teaming, transcription, evaluation/rating, and more * **Duration and dates** Page 2 of 2 of the AI Training Experience wizard step. A Profile Overview textarea contains a multi-sentence summary above the 150-character minimum. Below it, two labeling experience cards are listed: 'Image annotation QA review' tagged Bounding Box, Classification, Label Studio, Image; and 'RLHF preference ranking pilot' tagged RLHF, Evaluation Rating, Labelbox, Text. Each card has edit and delete buttons on the right. Add a separate entry for each platform or tool you have used, even if the work overlapped in time. Employers often filter by specific software, and having each tool listed individually improves your match quality. The skills section captures three dimensions that the matching engine uses: * **AI Data Labeling Software** — select every labeling or annotation platform you have hands-on experience with (Scale AI, Labelbox, Label Studio, Encord, Roboflow, AWS SageMaker, CVAT, etc.) * **Data Type Expertise** — the types of data you are comfortable annotating * **Task Type Expertise** — the annotation and training task types you can perform These overlap with your labeling experience entries but apply platform-wide across all your work, not just a single project. The Software & Specializations wizard step with three labelled chip strips. AI Data Labeling Software shows Labelbox, Scale AI, Label Studio, and CVAT. Data Type Expertise shows Text, Image, and Audio. Task Type Expertise shows RLHF, Evaluation Rating, Bounding Box, Text Summarization, and Transcription. Add your general professional work history — roles, companies, dates, and descriptions. The resume parser auto-populates these from your uploaded resume, but you can add, edit, or remove entries manually. This section is separate from labeling experience and covers non-AI-training roles in your background. Add your education history — school, degree, field of study, and graduation year. The resume parser auto-populates these as well. Set your hourly rate and weekly availability. Availability options are: * Less than 20 hrs/week * 20+ hrs/week * I don't know yet Experience level options (used for job matching): * **Entry Level** — less than 1 year of AI training experience * **Intermediate** — 1–3 years * **Expert** — 3+ years of data labeling or annotation experience The wizard's final personalization step captures three fields: * **Profile photo** — upload a photo to display on your profile card and proposals (PNG, JPG, WEBP, or GIF, max 5 MB) * **Profile title** — a short headline that appears on your public profile, such as "RLHF evaluator and multilingual safety reviewer" * **Top industries / subject matter** — rank up to three areas that best represent your expertise (for example, AI safety evaluation, image annotation QA, or audio transcription review) The Profile Title & Top Industries wizard step. A profile photo upload card sits at the top. Below it, the OpenTrain Profile Title field reads 'RLHF evaluator and multilingual safety reviewer'. Three numbered Top Industries/Subject Matter slots are filled with 'AI safety evaluation', 'Image annotation QA', and 'Audio transcription review'. The final step shows a summary of everything you have filled in. Review each section and go back to correct anything before submitting. Once you submit your profile, it is added to the matching pool and you can start applying to jobs. ## Profile visibility Your profile visibility and availability settings live in **Settings → Profile**, not in the onboarding wizard. You can update them any time from your AI trainer dashboard. * **Visibility** — controls who can see your profile: Public, Only OpenTrain Users, or Private * **Availability** — signals to employers whether you are open to new work (Less than 20 hrs/week, 20+ hrs/week, or I don't know yet) * **Search engine visibility** — when enabled, allows search engines such as Google to index your public profile page The Settings page with the Profile tab selected. A card labelled 'Profile' with the description 'Manage your availability and visibility on OpenTrain' contains a Visibility dropdown set to 'Only OpenTrain Users', an Availability dropdown set to 'I don't know yet', and a disabled Search engine visibility toggle with the label 'Allow search engines like Google to show your profile in search results'. ## Languages Add the languages you speak and your proficiency level for each (Native/Bilingual, Fluent, Conversational, or Basic). Language is a primary matching factor for many jobs — especially translation, localization review, and multilingual annotation work. ## Agency onboarding If you represent a team of AI trainers rather than working solo, use the **Agency onboarding** flow from your dashboard. This creates a company-level profile that covers your team's headcount, security and compliance credentials, and pricing. Agency accounts have access to subscription plans that control job visibility and the number of team members who can apply. The **Free** plan has restricted access — upgrade to **Basic** or **Pro** to unlock the full job feed. Even as an agency, individual team members should complete their own AI trainer profiles. Employer proposals and contracts are tied to individual accounts. # What happens after I submit a proposal Source: https://opentrain.ai/docs/trainers/proposal-status What 'Pending' means on a proposal, the actions an employer can take, and what you can do while you're waiting. When you submit a proposal, it goes into the employer's review queue for that job. You'll see it on the [**My Proposals** tab in your AI Trainer dashboard](https://app.opentrain.ai/ai-trainer?q=proposals) with the status **Pending**. ## What "Pending" means The employer hasn't made a decision yet. They may still be collecting proposals, shortlisting candidates, or waiting on AI screening answers from applicants. The proposal stays Pending until the employer takes one of these actions: * **Accept** — a contract is created and your status moves to **Hired**. * **Decline** — the proposal closes. You can apply to other jobs. * **Message** — the employer asks a question. You'll see the message in the [Proposals tab](https://app.opentrain.ai/ai-trainer?q=proposals). If the job closes without a decision, your proposal moves to **Closed**. ## How long this takes Review time is up to the employer. Some employers review proposals daily; others wait until they have a larger pool. Larger jobs and recruiting-configured jobs (where AI interview answers are required) tend to take longer to review. ## What you can do * Make sure your profile is complete. A complete profile with specific labeling experience is reviewed more often. * Finish the AI interview if the job requires one. Proposals without a completed interview are typically skipped. * Keep your messages on the [Proposals tab](https://app.opentrain.ai/ai-trainer?q=proposals) — that's where the employer will reach you. If a proposal has been Pending for an unusually long time and you'd like a hand looking into it, email [support@opentrain.ai](mailto:support@opentrain.ai) with the job link and a human on our team will pick it up. # Changelog Source: https://opentrain.ai/docs/changelog/overview Product updates, new features, and fixes shipped to OpenTrain AI. The changelog covers product changes to OpenTrain AI. ## Highlights this week Employer project sharing, better job discovery for language and specialist roles, and reliability fixes across AI trainer onboarding, contracts, billing records, and messaging. ### New * **Share projects with teammates** - Employers can now open Share from a job or folder header, add existing teammates who should have project access, and review the access list in Job Settings. Owners keep access, and non-owner teammates can only share projects they can already open. See [Share projects with teammates](/docs/employers/sharing-projects). ### Updates * **More accurate job matching for language and specialized roles** — The matcher better distinguishes language-driven data tasks, professional and advisory roles, and domain-anchored requirements, so AI trainers see jobs that fit their actual skills. See [Finding jobs](/docs/trainers/finding-jobs). ### Fixes * **AI trainer resume autofill** — Resume parsing and profile autofill recover gracefully from timeouts and parser issues during onboarding. See [Profile setup](/docs/trainers/profile-setup). * **Ended contract compensation view** — Compensation details on ended contracts load reliably for authorized viewers. See [Managing work](/docs/trainers/managing-work). * **First milestone refund invoicing** — Employer billing records now stay accurate after a first-milestone refund. See [Invoicing and payouts](/docs/employers/invoicing-payouts). * **Messaging spam protection** — OpenTrain blocks known scam messages before they reach job-channel inboxes. See [Job channels](/docs/messaging/job-channels). ## Highlights this week Pay-per-label contract switches, clearer job discovery, and reliability improvements across core hiring and work flows. ### New * **Switch active contracts from hourly to pay-per-label** — Employers can now change a live hourly contract to pay-per-label for future milestones. Existing funded milestones keep their original hourly terms. See [Milestones](/docs/payments/milestones). ### Updates * **Find Jobs improvements** — Job cards are easier to scan on desktop and mobile, with clearer descriptions, badges, and application status. See [Finding jobs](/docs/trainers/finding-jobs). * **Contract and messaging filters** — Ended contracts are easier to spot and filter across contract lists, inboxes, and job messages. See [Managing work](/docs/trainers/managing-work). ### Fixes * **Message edit expiry** — Messages edited after the edit window now show a clear expiry notice instead of a generic error. * **Job search, profiles, and onboarding reliability** — Job search, job details, employer dashboards, profiles, AI trainer onboarding, and resume autofill are more stable. * **Public job detail header** — Public job detail pages now have cleaner header spacing and layout. ## Highlights this week Safer proposal messages, agency marketplace trials, provider invite access, and public job detail improvements. ### New * **Proposal message safety screening** — Proposal and bulk messages are screened for abusive content before delivery, helping keep conversations safe. See [Proposal Messages](/docs/messaging/proposals). * **Agency marketplace free trial** — Agencies can self-serve a 30-day free trial to access the marketplace without entering payment details up front. * **Provider invite access** — Employers can invite external annotation providers directly into a job, granting scoped access to candidates and deliverables without sharing their full workspace. ### Fixes * **AI trainer dashboard stability** — The AI trainer dashboard is more reliable when contract data is temporarily unavailable. * **Public job detail** — Candidates now see clearer job status before applying. # Get Contract Budget Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/contracts/budget GET /api/partner/v1/contracts/{contractId}/budget Read a linked contract's budget: funded milestone volume, consumed work, remaining volume, and the OK / LOW / DEPLETED state. Returns the contract's current budget: funded milestone volume versus consumed work, and the resulting `OK` / `LOW` / `DEPLETED` state. Read-only and side-effect-free — polling it never emits events. Use it to reconcile after downtime or to render budget status in your UI; the [usage-sync guide](/docs/developers/annotation-platforms/usage-sync) explains how the numbers are computed. **Requirements:** `contracts:read` scope, and the contract's job must be referenced by a [project link](/docs/developers/annotation-platforms/project-links) on the calling token's install. ## Request The OpenTrain contract to read. ## Response The contract this budget describes. `PAY_PER_HOUR`, `PAY_PER_LABEL`, or `FIXED_PRICE`. Determines the volume unit (hours, labels) — `FIXED_PRICE` contracts never deplete. `OK` (below 80% consumed), `LOW` (`consumedFraction` ≥ 0.8), or `DEPLETED` (≥ 1.0). Always `OK` for `FIXED_PRICE` or when nothing is funded. Total volume (hours or labels) across funded and completed milestones. Total USD across those milestones. Raw consumption totals across all reported usage and OpenTrain first-party work: `seconds`, `hours`, `labels`, `tasks`. Consumption in the contract's volume unit — hours for `PAY_PER_HOUR`, labels for `PAY_PER_LABEL`. `fundedVolume - consumedVolume`, floored at 0. `consumedVolume / fundedVolume`, rounded to 4 decimals. `0` when nothing is funded. The current funded milestone (`id`, `name`, `amountUsd`, `volume`, `status`), or `null` if none is active. ISO 8601 timestamp of the most recent usage report or first-party work record — `null` if no work has been recorded. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------ | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `contracts:read` | | `404` | `NOT_FOUND` | Contract not found, or its job is not linked by this install | ```bash curl theme={null} curl -sS "https://app.opentrain.ai/api/partner/v1/contracts//budget" \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json 200 theme={null} { "contractId": "", "paymentType": "PAY_PER_HOUR", "state": "LOW", "fundedVolume": 40, "fundedAmountUsd": 560, "consumed": { "seconds": 118800, "hours": 33, "labels": 0, "tasks": 87 }, "consumedVolume": 33, "remainingVolume": 7, "consumedFraction": 0.825, "activeMilestone": { "id": "", "name": "Week 2", "amountUsd": 280, "volume": 20, "status": "ACTIVE_FUNDED" }, "lastUsageAt": "2026-06-12T18:00:00.000Z" } ``` # List Contracts Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/contracts/list GET /api/partner/v1/contracts List contracts on jobs linked by this install, newest first. Filter by project link or status. Returns contracts whose job is referenced by at least one [project link](/docs/developers/annotation-platforms/project-links) on the calling token's install, newest first. This is the pull-side complement to the [`contract.started` / `contract.ended` webhooks](/docs/developers/annotation-platforms/lifecycle-events) — poll it to reconcile state after downtime, since events are never replayed. **Requirements:** `contracts:read` scope. ## Request Restrict to contracts on the job linked by this project link. Returns `404` if no link with this ID exists on the install. Filter by contract status: `active` or `ended`. Maximum number of contracts to return (1–200). ## Response Array of contracts, newest first. Contract ID. Use it with [`GET /contracts/{contractId}/participants`](/docs/developers/annotation-platforms/api-reference/contracts/participants). `active` or `ended`. The OpenTrain job the contract belongs to. Contract title. ISO 8601 start timestamp. ISO 8601 end timestamp — `null` while the contract is active. ISO 8601 creation timestamp. Project links on this install referencing the contract job. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ----------------------------------------------------- | | `400` | `BAD_REQUEST` | Invalid `status` value or `limit` out of range | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `contracts:read` | | `404` | `NOT_FOUND` | `projectLinkId` does not match a link on this install | ```bash curl theme={null} curl -sS "https://app.opentrain.ai/api/partner/v1/contracts?status=active&limit=50" \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json 200 theme={null} { "contracts": [ { "id": "", "status": "active", "jobId": "", "title": "Traffic signs batch 3 — annotation", "startDate": "2026-06-12T10:00:00.000Z", "endDate": null, "createdAt": "2026-06-12T10:00:00.000Z", "projectLinkIds": [""] } ] } ``` # List Contract Participants Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/contracts/participants GET /api/partner/v1/contracts/{contractId}/participants Retrieve the AI trainers on a contract. workEmail appears only with PII consent plus the participants:email scope — personal emails are never shared. Returns the AI trainers on a contract whose job is linked by this install. Use it to provision workspace members — or to re-resolve a [`workEmail`](/docs/developers/annotation-platforms/lifecycle-events#when-workemail-is-included) that was absent from a webhook payload. **Requirements:** `participants:read` scope. The `workEmail` field additionally requires the `participants:email` scope **and** the install's PII consent — see [privacy and Work Email](/docs/developers/concepts/privacy-and-work-email). ## Request The contract ID, from [list contracts](/docs/developers/annotation-platforms/api-reference/contracts/list) or a `contract.started` event. ## Response The contract these participants belong to. The AI trainers on the contract. Stable OpenTrain user ID — use it as your cross-reference key. Display name, masked to first name + last initial (for example `"Maria S."`). Full last names never leave the platform. Country. Public OpenTrain profile URL. Platform-issued `@opentrain.work` Work Email. Present only when the install has PII consent and the token holds `participants:email`. Personal emails are never shared. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------ | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `participants:read` | | `404` | `NOT_FOUND` | Contract not found, or its job is not linked by this install | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/partner/v1/contracts//participants \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json 200 theme={null} { "contractId": "", "participants": [ { "opentrainUserId": "", "displayName": "Maria S.", "country": "PH", "profileUrl": "https://app.opentrain.ai/profile/maria-s-annotation", "workEmail": "maria.1234@opentrain.work" } ] } ``` # Report Usage Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/contracts/usage POST /api/partner/v1/contracts/{contractId}/usage Report cumulative per-worker, per-day work totals (time, tasks, labels) on a linked contract. Idempotent upserts — re-POSTing never double counts. Reports work done on your platform against an OpenTrain contract. Entries are **cumulative per-worker, per-day totals**: each entry replaces the stored totals for its (worker, `workDate`), so re-POSTing the same report — or a corrected one — is idempotent and never double counts. Fields omitted from an entry keep their previously stored values. The response includes the recomputed [budget](/docs/developers/annotation-platforms/api-reference/contracts/budget). Crossing the 80% / 100% consumption thresholds emits [`milestone.budget_low` / `milestone.budget_depleted`](/docs/developers/annotation-platforms/usage-sync#the-depletion-events) webhooks. **Requirements:** `usage:write` scope, and the contract's job must be referenced by a [project link](/docs/developers/annotation-platforms/project-links) on the calling token's install. ## Request The OpenTrain contract to attribute usage to. 1–100 usage entries. One entry per (worker, day). Calendar day the work happened, as `YYYY-MM-DD` (UTC). Cannot be in the future. OpenTrain user ID of the worker. Defaults to the contract's hired AI trainer; if provided, it must match a participant on the contract. Cumulative seconds worked that day, 0–86400. Consumes budget on `PAY_PER_HOUR` contracts (hours = seconds / 3600). Cumulative tasks completed that day. Non-negative. Informational on all payment types. Cumulative labels completed that day. Non-negative. Consumes budget on `PAY_PER_LABEL` contracts. Your report identifier for audit, max 200 characters. ## Response The contract the usage was recorded against. Number of entries upserted. The recomputed budget — same shape as [`GET /contracts/{contractId}/budget`](/docs/developers/annotation-platforms/api-reference/contracts/budget#response). ## Errors | Status | `code` | Meaning | | ------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Invalid entries: missing/malformed `workDate`, future date, `totalSeconds` out of range, negative counts, more than 100 entries, or `workerOpentrainUserId` not a participant on this contract | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `usage:write` | | `404` | `NOT_FOUND` | Contract not found, or its job is not linked by this install | | `409` | `CONFLICT` | Contract has no hired AI trainer to attribute usage to | ```bash curl theme={null} curl -sS -X POST "https://app.opentrain.ai/api/partner/v1/contracts//usage" \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "entries": [ { "workDate": "2026-06-12", "totalSeconds": 14400, "tasksCompleted": 52, "labelsCompleted": 410, "externalReportId": "daily-report-8841" } ] }' ``` ```json 200 theme={null} { "contractId": "", "accepted": 1, "budget": { "contractId": "", "paymentType": "PAY_PER_HOUR", "state": "OK", "fundedVolume": 40, "fundedAmountUsd": 560, "consumed": { "seconds": 100800, "hours": 28, "labels": 410, "tasks": 52 }, "consumedVolume": 28, "remainingVolume": 12, "consumedFraction": 0.7, "activeMilestone": { "id": "", "name": "Week 2", "amountUsd": 280, "volume": 20, "status": "ACTIVE_FUNDED" }, "lastUsageAt": "2026-06-12T18:00:00.000Z" } } ``` # Get Current Install Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/installs/current GET /api/partner/v1/installs/current Retrieve the install your platform token is scoped to: status, granted scopes, PII consent, and the platform app it belongs to. Returns the [install](/docs/developers/annotation-platforms/consent-and-installs) the calling token belongs to. Use it as a connectivity check after the customer pastes in their token, and to confirm which scopes and PII consent you actually hold before relying on them. **Requirements:** any valid platform token — no specific scope. (Like every Platform API call, it still requires a live token, an `ACTIVE` install, an `ACTIVE` platform app, and the annotation-platform feature on the granting employer.) ## Request No parameters. ## Response The current install. Install ID. Also delivered as `opentrain_install_id` on the consent return redirect. `ACTIVE` or `REVOKED`. A revoked install's tokens stop authenticating, so in practice you only ever see `ACTIVE` here. The scopes the customer granted — see the [scope table](/docs/developers/annotation-platforms/consent-and-installs#scopes). Whether the customer checked the explicit Work Email consent box. `participants:email` only takes effect when this is `true`. The granting employer's organization ID. ISO 8601 timestamp of the grant. Your app: `{id, slug, name}`. The token used for this request: `{id, name, scopes}`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ----------------------------------------------------------------------------------------------------------- | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Install or platform app no longer active, or annotation-platform feature disabled for the granting employer | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/partner/v1/installs/current \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json 200 theme={null} { "install": { "id": "", "status": "ACTIVE", "scopes": [ "project-links:read", "project-links:write", "contracts:read", "participants:read", "participants:email", "webhooks:manage" ], "piiConsent": true, "organizationId": "", "createdAt": "2026-06-12T10:00:00.000Z", "partnerApp": { "id": "", "slug": "your-app", "name": "Your App" }, "token": { "id": "", "name": "Your App connection", "scopes": [ "project-links:read", "project-links:write", "contracts:read", "participants:read", "participants:email", "webhooks:manage" ] } } } ``` # Platform API Reference Overview Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/overview Base URL, ot_ptk_ bearer authentication, the error envelope, scopes, and a map of every OpenTrain Platform API endpoint. Every page in this reference documents one Platform API endpoint by hand: parameters, response fields, scope requirements, examples, and the errors you can actually hit. The machine-readable contract of record is the served spec — if a page and the spec ever disagree, the spec wins: ```text theme={null} GET https://app.opentrain.ai/api/partner/v1/openapi.json ``` (The spec endpoint itself requires no authentication.) ## Base URL and Authentication ```text theme={null} https://app.opentrain.ai/api/partner/v1 ``` Every endpoint requires an install-scoped platform token in the `Authorization` header: ```bash theme={null} curl -sS https://app.opentrain.ai/api/partner/v1/installs/current \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` Tokens start with `ot_ptk_` and are minted when a customer installs your app via the [consent flow](/docs/developers/annotation-platforms/consent-and-installs) — shown once, on the consent screen only. Beyond the per-endpoint scope, **every** request additionally requires a live (non-revoked) token, an `ACTIVE` install, an `ACTIVE` platform app, and the annotation-platform feature enabled on the granting employer's account. Any of those failing yields `401` or `403`. ## The Error Envelope Non-2xx responses share the same JSON shape as the [Public API](/docs/developers/concepts/errors-pagination-limits): ```json theme={null} { "error": "Human-readable message", "code": "FORBIDDEN", "requestId": "9d1f3a8e-...", "details": { "...": "endpoint-specific context" } } ``` `code` is one of `BAD_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `RATE_LIMITED`, `INTERNAL_ERROR`. Include `requestId` when contacting support. ## Scopes | Scope | Endpoints it unlocks | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | *(any valid token)* | [`GET /installs/current`](/docs/developers/annotation-platforms/api-reference/installs/current) | | `project-links:read` | [List](/docs/developers/annotation-platforms/api-reference/project-links/list) and [get](/docs/developers/annotation-platforms/api-reference/project-links/get) project links | | `project-links:write` (implies `read`) | [Create](/docs/developers/annotation-platforms/api-reference/project-links/create) and [delete](/docs/developers/annotation-platforms/api-reference/project-links/delete) project links | | `contracts:read` | [List contracts](/docs/developers/annotation-platforms/api-reference/contracts/list) and [read contract budgets](/docs/developers/annotation-platforms/api-reference/contracts/budget) | | `participants:read` | [List contract participants](/docs/developers/annotation-platforms/api-reference/contracts/participants) | | `participants:email` | The `workEmail` field on participants and webhook payloads — also requires install [PII consent](/docs/developers/annotation-platforms/consent-and-installs#scopes) | | `usage:write` | [Report per-day work usage](/docs/developers/annotation-platforms/api-reference/contracts/usage) on linked contracts | | `webhooks:manage` | All six [webhook-endpoint](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/list) operations | ## Endpoint Map | Family | Endpoints | What it covers | | ------------------------------------------------------------------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Installs](/docs/developers/annotation-platforms/api-reference/installs/current) | 1 | The install (consent grant) this token belongs to | | [Project links](/docs/developers/annotation-platforms/api-reference/project-links/list) | 4 | Links between your projects and OpenTrain jobs | | [Contracts](/docs/developers/annotation-platforms/api-reference/contracts/list) | 4 | Hired contracts on linked jobs, participants, [budget](/docs/developers/annotation-platforms/api-reference/contracts/budget), and [usage reporting](/docs/developers/annotation-platforms/api-reference/contracts/usage) | | [Webhook endpoints](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/list) | 6 | Endpoint CRUD, re-enable, and redelivery | ## Related The core loop, provisioning modes, and how to get access. The eight event types and their payload shapes. Delivery mechanics: signatures, retries, auto-disable, redelivery. Tested HMAC verification code, shared with the Public API. # Create Project Link Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/project-links/create POST /api/partner/v1/project-links Link one of your platform's projects to an OpenTrain job. externalProjectId must be unique per install; emits project_link.created. Creates a [project link](/docs/developers/annotation-platforms/project-links) mapping one of your projects to an OpenTrain job. Contract [lifecycle events](/docs/developers/annotation-platforms/lifecycle-events) are emitted only for linked jobs, so this is the routing step of the integration. Emits a `project_link.created` event. **Requirements:** `project-links:write` scope. ## Request Your project's identifier (max 200 chars). **Unique per install** — reusing one returns `409`. Human-readable project name (max 300 chars). Deep link to the project in your platform (max 2048 chars). The OpenTrain job to link. Must be a job owned by the installing employer — anything else returns `404`. A link without a job receives no contract events until it points at one. `PARTNER_WEBHOOK` (you provision workspace members from webhook events — recommended) or `MANAGED_ADAPTER` (the OpenTrain managed-credential path). See [provisioning modes](/docs/developers/annotation-platforms/overview#provisioning-modes). ## Response The created link: `{id, jobId, externalProjectId, externalProjectName, externalProjectUrl, provisioningMode, createdAt}`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | -------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Body not valid JSON, `externalProjectId` missing, or a field exceeds its length limit | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `project-links:write` | | `404` | `NOT_FOUND` | `jobId` does not reference a job owned by the installing employer | | `409` | `CONFLICT` | Duplicate `externalProjectId` for this install, or the install's link limit is reached | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/partner/v1/project-links \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "jobId": "", "provisioningMode": "PARTNER_WEBHOOK" }' ``` ```json 201 theme={null} { "projectLink": { "id": "", "jobId": "", "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "provisioningMode": "PARTNER_WEBHOOK", "createdAt": "2026-06-12T10:00:00.000Z" } } ``` ```json 409 (duplicate) theme={null} { "error": "A project link with this externalProjectId already exists for this install.", "code": "CONFLICT", "requestId": "" } ``` # Delete Project Link Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/project-links/delete DELETE /api/partner/v1/project-links/{linkId} Permanently remove a project link. The response carries a final snapshot, and a project_link.removed event is emitted. Permanently removes a [project link](/docs/developers/annotation-platforms/project-links). The link row is hard-deleted — the response and the emitted `project_link.removed` [event](/docs/developers/annotation-platforms/lifecycle-events#project-link-events) carry the final snapshot of its data. Contract events for the job stop unless another link on the install references the same job. There is no update operation on links: to re-point a project at a different job, delete and [create](/docs/developers/annotation-platforms/api-reference/project-links/create) again. **Requirements:** `project-links:write` scope. ## Request The project link ID to remove. ## Response Final snapshot of the removed link: `{id, jobId, externalProjectId, externalProjectName, externalProjectUrl, provisioningMode, createdAt}`. Always `true` on success. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------ | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `project-links:write` | | `404` | `NOT_FOUND` | No link with this ID on this install | ```bash curl theme={null} curl -sS -X DELETE https://app.opentrain.ai/api/partner/v1/project-links/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json 200 theme={null} { "projectLink": { "id": "", "jobId": "", "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "provisioningMode": "PARTNER_WEBHOOK", "createdAt": "2026-06-12T10:00:00.000Z" }, "deleted": true } ``` # Get Project Link Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/project-links/get GET /api/partner/v1/project-links/{linkId} Retrieve one project link by ID. Retrieves one [project link](/docs/developers/annotation-platforms/project-links) on the calling token's install. **Requirements:** `project-links:read` scope (implied by `project-links:write`). ## Request The project link ID, from [create](/docs/developers/annotation-platforms/api-reference/project-links/create) or [list](/docs/developers/annotation-platforms/api-reference/project-links/list). ## Response Link ID. The linked OpenTrain job, or `null` if the link is not job-bound yet. Your project's identifier — unique per install. Human-readable project name. Deep link to the project in your platform. `PARTNER_WEBHOOK` or `MANAGED_ADAPTER` — see [provisioning modes](/docs/developers/annotation-platforms/overview#provisioning-modes). ISO 8601 creation timestamp. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------ | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `project-links:read` | | `404` | `NOT_FOUND` | No link with this ID on this install | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/partner/v1/project-links/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json 200 theme={null} { "projectLink": { "id": "", "jobId": "", "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "provisioningMode": "PARTNER_WEBHOOK", "createdAt": "2026-06-12T10:00:00.000Z" } } ``` # List Project Links Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/project-links/list GET /api/partner/v1/project-links List the project links on the current install, newest first. Lists every [project link](/docs/developers/annotation-platforms/project-links) on the calling token's install, newest first. There is no pagination — installs hold a bounded number of links. **Requirements:** `project-links:read` scope (implied by `project-links:write`). ## Request No parameters. ## Response Array of project links, newest first. Each: `{id, jobId, externalProjectId, externalProjectName, externalProjectUrl, provisioningMode, createdAt}` — same shape as [`GET /project-links/{linkId}`](/docs/developers/annotation-platforms/api-reference/project-links/get). ## Errors | Status | `code` | Meaning | | ------ | -------------- | ---------------------------------- | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `project-links:read` | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/partner/v1/project-links \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json 200 theme={null} { "projectLinks": [ { "id": "", "jobId": "", "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "provisioningMode": "PARTNER_WEBHOOK", "createdAt": "2026-06-12T10:00:00.000Z" } ] } ``` # Create Webhook Endpoint Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/webhook-endpoints/create POST /api/partner/v1/webhook-endpoints Register an HTTPS endpoint for platform lifecycle events. The HMAC signing secret is returned once, only in this response. Registers a [webhook endpoint](/docs/developers/annotation-platforms/webhooks) to receive [lifecycle events](/docs/developers/annotation-platforms/lifecycle-events). The response includes the HMAC signing `secret` **once** — store it immediately; no later call returns it. If you lose it, delete the endpoint and create a new one. Delivery starts with events created **after** the endpoint is registered — there is no backfill of earlier events. Link projects and subscribe before contracts you care about begin. **Requirements:** `webhooks:manage` scope. ## Request Delivery URL. Must be HTTPS (`http` is allowed for localhost only). Redirects are not followed. At least one of: `contract.started`, `contract.ended`, `project_link.created`, `project_link.removed`, `install.revoked`, `milestone.funded`, `milestone.budget_low`, `milestone.budget_depleted`. ## Response The created endpoint: `{id, url, eventTypes, status, createdAt, consecutiveFailures, disabledAt, disabledReason}` — same shape as [`GET /webhook-endpoints/{endpointId}`](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/get). HMAC-SHA256 signing secret for [verifying deliveries](/docs/developers/guides/verify-webhook-signatures). **Shown only in this response.** Reminder that the secret will not be shown again. ## Errors | Status | `code` | Meaning | | ------ | -------------- | -------------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Body not valid JSON, `url` missing/not HTTPS, or `eventTypes` empty/contains an unknown type | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `webhooks:manage` | | `409` | `CONFLICT` | Endpoint limit reached for this install | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/partner/v1/webhook-endpoints \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-platform.example.com/webhooks/opentrain", "eventTypes": ["contract.started", "contract.ended", "install.revoked"] }' ``` ```json 201 theme={null} { "webhookEndpoint": { "id": "", "url": "https://your-platform.example.com/webhooks/opentrain", "eventTypes": ["contract.started", "contract.ended", "install.revoked"], "status": "ACTIVE", "createdAt": "2026-06-12T10:00:00.000Z", "consecutiveFailures": 0, "disabledAt": null, "disabledReason": null }, "secret": "whsec_", "message": "Store the secret now — it is only returned once. Use it to verify the X-OpenTrain-Signature header on deliveries." } ``` # Delete Webhook Endpoint Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/webhook-endpoints/delete DELETE /api/partner/v1/webhook-endpoints/{endpointId} Permanently remove a webhook endpoint. Pending deliveries are discarded. Permanently removes a [webhook endpoint](/docs/developers/annotation-platforms/webhooks). Pending deliveries are discarded. Deleting and [re-creating](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/create) is also how you rotate the signing secret — the new endpoint gets a new one. If you only want to pause delivery, [set `status` to `DISABLED`](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/update) instead. **Requirements:** `webhooks:manage` scope. ## Request The webhook endpoint ID to remove. ## Response Final snapshot of the removed endpoint: `{id, url, eventTypes, status, createdAt, consecutiveFailures, disabledAt, disabledReason}`. Always `true` on success. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ---------------------------------------- | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `webhooks:manage` | | `404` | `NOT_FOUND` | No endpoint with this ID on this install | ```bash curl theme={null} curl -sS -X DELETE https://app.opentrain.ai/api/partner/v1/webhook-endpoints/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json 200 theme={null} { "webhookEndpoint": { "id": "", "url": "https://your-platform.example.com/webhooks/opentrain", "eventTypes": ["contract.started", "contract.ended"], "status": "ACTIVE", "createdAt": "2026-06-12T10:00:00.000Z", "consecutiveFailures": 0, "disabledAt": null, "disabledReason": null }, "deleted": true } ``` # Get Webhook Endpoint Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/webhook-endpoints/get GET /api/partner/v1/webhook-endpoints/{endpointId} Retrieve one webhook endpoint and its delivery health: status, consecutive failures, and disable reason. Retrieves one [webhook endpoint](/docs/developers/annotation-platforms/webhooks) on the calling token's install, including its delivery-health fields. The signing secret is never returned — it is only shown once, by [create](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/create). **Requirements:** `webhooks:manage` scope. ## Request The webhook endpoint ID, from [create](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/create) or [list](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/list). ## Response Endpoint ID. The delivery URL. Subscribed [event types](/docs/developers/annotation-platforms/lifecycle-events). `ACTIVE` or `DISABLED`. Deliveries are only attempted while `ACTIVE`. ISO 8601 creation timestamp. Failed deliveries in a row. Resets to `0` on any successful delivery; at 10 the endpoint is auto-disabled. When the endpoint was disabled, or `null`. Why the endpoint was disabled, or `null`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ---------------------------------------- | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `webhooks:manage` | | `404` | `NOT_FOUND` | No endpoint with this ID on this install | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/partner/v1/webhook-endpoints/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json 200 theme={null} { "webhookEndpoint": { "id": "", "url": "https://your-platform.example.com/webhooks/opentrain", "eventTypes": ["contract.started", "contract.ended"], "status": "ACTIVE", "createdAt": "2026-06-12T10:00:00.000Z", "consecutiveFailures": 0, "disabledAt": null, "disabledReason": null } } ``` # List Webhook Endpoints Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/webhook-endpoints/list GET /api/partner/v1/webhook-endpoints List the webhook endpoints registered on the current install, newest first, including delivery health. Lists every [webhook endpoint](/docs/developers/annotation-platforms/webhooks) registered on the calling token's install, newest first. Use it to monitor delivery health — a non-zero `consecutiveFailures` or a `DISABLED` status means your receiver needs attention. **Requirements:** `webhooks:manage` scope. ## Request No parameters. ## Response Array of webhook endpoints, newest first. The signing secret is never included — it is only returned once, by [create](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/create). Endpoint ID. The delivery URL. Subscribed [event types](/docs/developers/annotation-platforms/lifecycle-events). `ACTIVE` or `DISABLED`. Deliveries are only attempted while `ACTIVE`. ISO 8601 creation timestamp. Failed deliveries in a row. Resets to `0` on any successful delivery; at 10 the endpoint is auto-disabled. When the endpoint was disabled, or `null`. Why the endpoint was disabled, or `null`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ---------------------------------- | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `webhooks:manage` | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/partner/v1/webhook-endpoints \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json 200 theme={null} { "webhookEndpoints": [ { "id": "", "url": "https://your-platform.example.com/webhooks/opentrain", "eventTypes": ["contract.started", "contract.ended"], "status": "ACTIVE", "createdAt": "2026-06-12T10:00:00.000Z", "consecutiveFailures": 0, "disabledAt": null, "disabledReason": null } ] } ``` # Redeliver Webhook Events Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/webhook-endpoints/redeliver POST /api/partner/v1/webhook-endpoints/{endpointId}/redeliver Requeue failed webhook deliveries for an endpoint, or one specific delivery by ID, regardless of its status. Requeues [webhook deliveries](/docs/developers/annotation-platforms/webhooks) for retry. With a `deliveryId`, requeues that specific delivery regardless of its status; without one, requeues **all `FAILED` deliveries** for the endpoint. Requeued deliveries are only attempted while the endpoint is `ACTIVE` — when [recovering from auto-disable](/docs/developers/annotation-platforms/webhooks#auto-disable-and-recovery), re-enable first, then redeliver. **Requirements:** `webhooks:manage` scope. ## Request The webhook endpoint ID. A specific delivery to requeue — the value of the `X-OpenTrain-Delivery` header. Omit to requeue every `FAILED` delivery on the endpoint. ## Response The endpoint: `{id, url, eventTypes, status, createdAt, consecutiveFailures, disabledAt, disabledReason}` — same shape as [`GET /webhook-endpoints/{endpointId}`](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/get). How many deliveries were requeued. When the requeued deliveries will be attempted — or a reminder that nothing is attempted while the endpoint is `DISABLED`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ---------------------------------------------- | | `400` | `BAD_REQUEST` | Body not valid JSON | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `webhooks:manage` | | `404` | `NOT_FOUND` | Endpoint or delivery not found on this install | ```bash curl (all failed) theme={null} curl -sS -X POST https://app.opentrain.ai/api/partner/v1/webhook-endpoints//redeliver \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` ```bash curl (one delivery) theme={null} curl -sS -X POST https://app.opentrain.ai/api/partner/v1/webhook-endpoints//redeliver \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"deliveryId": ""}' ``` ```json 200 theme={null} { "webhookEndpoint": { "id": "", "url": "https://your-platform.example.com/webhooks/opentrain", "eventTypes": ["contract.started", "contract.ended"], "status": "ACTIVE", "createdAt": "2026-06-12T10:00:00.000Z", "consecutiveFailures": 0, "disabledAt": null, "disabledReason": null }, "requeued": 3, "message": "Requeued deliveries will be attempted on the next delivery run (within ~5 minutes)." } ``` # Update Webhook Endpoint Source: https://opentrain.ai/docs/developers/annotation-platforms/api-reference/webhook-endpoints/update PATCH /api/partner/v1/webhook-endpoints/{endpointId} Change a webhook endpoint's URL, event types, or status. Setting status to ACTIVE re-enables an auto-disabled endpoint and resets its failure counter. Updates a [webhook endpoint](/docs/developers/annotation-platforms/webhooks). All body fields are optional — send only what you want to change. Setting `status` to `ACTIVE` re-enables an [auto-disabled](/docs/developers/annotation-platforms/webhooks#auto-disable-and-recovery) endpoint and resets its failure counter. Updating never changes the signing secret, and the secret is never returned. To rotate it, [delete](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/delete) the endpoint and [create](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/create) a new one. **Requirements:** `webhooks:manage` scope. ## Request The webhook endpoint ID. New delivery URL. Must be HTTPS (`http` is allowed for localhost only). Replacement list of [event types](/docs/developers/annotation-platforms/lifecycle-events) — at least one. `ACTIVE` or `DISABLED`. `ACTIVE` re-enables an auto-disabled endpoint and resets `consecutiveFailures` to `0`; `DISABLED` pauses delivery. ## Response The updated endpoint: `{id, url, eventTypes, status, createdAt, consecutiveFailures, disabledAt, disabledReason}` — same shape as [`GET /webhook-endpoints/{endpointId}`](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/get). ## Errors | Status | `code` | Meaning | | ------ | -------------- | ----------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Body not valid JSON, invalid `url`, empty/unknown `eventTypes`, or invalid `status` | | `401` | `UNAUTHORIZED` | Missing, invalid, or revoked token | | `403` | `FORBIDDEN` | Token lacks `webhooks:manage` | | `404` | `NOT_FOUND` | No endpoint with this ID on this install | ```bash curl theme={null} curl -sS -X PATCH https://app.opentrain.ai/api/partner/v1/webhook-endpoints/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "ACTIVE"}' ``` ```json 200 theme={null} { "webhookEndpoint": { "id": "", "url": "https://your-platform.example.com/webhooks/opentrain", "eventTypes": ["contract.started", "contract.ended"], "status": "ACTIVE", "createdAt": "2026-06-12T10:00:00.000Z", "consecutiveFailures": 0, "disabledAt": null, "disabledReason": null } } ``` # Build With Your Coding Agent Source: https://opentrain.ai/docs/developers/annotation-platforms/build-with-your-agent Point Claude Code, Codex, Cursor, or any coding agent at OpenTrain's machine-readable docs and let it build your platform integration autonomously. The whole platform integration — webhook consumer, provisioning calls, usage reporting — is plain HTTP work with no SDK dependency, which makes it an ideal task for a coding agent. Every contract it needs is machine-readable: the Platform API ships a served OpenAPI spec, and every page of these docs exports as clean Markdown. Give your agent those URLs and a goal, and it can build, test, and harden the integration in one session. The [reference integration](/docs/developers/annotation-platforms/reference-integration) on the next page was built exactly this way. ## Give Your Agent the Docs Three surfaces cover everything; none of them require authentication: | Surface | What it contains | | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [`https://app.opentrain.ai/api/partner/v1/openapi.json`](https://app.opentrain.ai/api/partner/v1/openapi.json) | The Platform API's OpenAPI spec — the machine-readable contract of record for every endpoint, parameter, and response shape. Generated from the same code that serves the requests. | | [`https://www.opentrain.ai/docs/llms.txt`](https://www.opentrain.ai/docs/llms.txt) | Index of all documentation pages with one-line summaries ([`llms-full.txt`](https://www.opentrain.ai/docs/llms-full.txt) is the whole site in one file). | | Any docs page + `.md` | Append `.md` to any page URL for clean Markdown — for example [`/docs/developers/annotation-platforms/webhooks.md`](https://www.opentrain.ai/docs/developers/annotation-platforms/webhooks.md). Code blocks and tab contents survive the export. | The pages worth feeding in full, in reading order: [overview](/docs/developers/annotation-platforms/overview), [register your app](/docs/developers/annotation-platforms/register-your-app), [consent and installs](/docs/developers/annotation-platforms/consent-and-installs), [project links](/docs/developers/annotation-platforms/project-links), [webhooks](/docs/developers/annotation-platforms/webhooks), [usage sync](/docs/developers/annotation-platforms/usage-sync), and the [reference integration](/docs/developers/annotation-platforms/reference-integration). ## A Bootstrap Prompt Adapt and paste this into your agent to start the session: ```text theme={null} You are building an OpenTrain integration for , an annotation platform. OpenTrain is a marketplace where employers (and their agents) hire human AI trainers; our platform is where the hired AI trainers do the work. Fetch this context first: 1. https://www.opentrain.ai/docs/llms.txt — docs index; fetch any page with .md appended 2. https://app.opentrain.ai/api/partner/v1/openapi.json — Platform API spec (no auth needed) 3. In full: - https://www.opentrain.ai/docs/developers/annotation-platforms/overview.md - https://www.opentrain.ai/docs/developers/annotation-platforms/webhooks.md - https://www.opentrain.ai/docs/developers/annotation-platforms/usage-sync.md - https://www.opentrain.ai/docs/developers/annotation-platforms/reference-integration.md Build a webhook consumer that: - verifies X-OpenTrain-Signature (t=...,v1=... HMAC-SHA256 over ".") and dedupes by X-OpenTrain-Delivery - on contract.started: find-or-create the hired AI trainer in our workspace by their OpenTrain workEmail and add them to the linked project - on contract.ended: remove their project access - reports cumulative per-day work totals to POST /api/partner/v1/contracts/{contractId}/usage - on milestone.budget_low / milestone.budget_depleted: surface a "funded budget is running out — top up on OpenTrain" notice in our UI Constraints: - The platform token is in env OPENTRAIN_PARTNER_TOKEN (ot_ptk_...); the webhook secret is in OPENTRAIN_WEBHOOK_SECRET. Never print either. - Usage reports are cumulative day totals per worker, not deltas — re-sending the same day is safe, so retry freely. - Use only our platform's existing user/membership APIs on our side. ``` Keep a human in the loop where OpenTrain does: anything that moves money (funding, payment release, ending a funded contract) is [co-signed](/docs/developers/concepts/human-approvals) on OpenTrain itself, so your agent can build and test the full loop without being able to spend anything. ## Optional: Let Your Agent Drive the Employer Side Testing the integration end-to-end needs an employer on the other side — someone to post a job, link it to your project, and hire. Your agent can play that role too: the [OpenTrain MCP server](/docs/developers/mcp/overview) exposes the employer surface (jobs, proposals, contracts, milestones) as tools, using a separate personal `ot_pat_` token from an employer test account. The same surface is available as a [CLI](/docs/developers/cli/commands) and [plain HTTP](/docs/developers/api-reference/overview). ```bash theme={null} claude mcp add opentrain --env OPENTRAIN_PERSONAL_API_TOKEN=ot_pat_... -- npx -y @opentrain-ai/mcp ``` Add `--scope user` to make it available in every project. Full setup: [Claude Code](/docs/developers/agents/claude-code). ```bash theme={null} codex mcp add opentrain --env OPENTRAIN_PERSONAL_API_TOKEN=ot_pat_... -- npx -y @opentrain-ai/mcp ``` Or declare it in `~/.codex/config.toml`. Full setup: [Codex CLI](/docs/developers/agents/codex-cli). `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global): ```json theme={null} { "mcpServers": { "opentrain": { "command": "npx", "args": ["-y", "@opentrain-ai/mcp"], "env": { "OPENTRAIN_PERSONAL_API_TOKEN": "ot_pat_..." } } } } ``` Full setup (including keeping the token out of JSON with `envFile`): [Cursor](/docs/developers/agents/cursor). `~/.gemini/config/mcp_config.json`: ```json theme={null} { "mcpServers": { "opentrain": { "command": "npx", "args": ["-y", "@opentrain-ai/mcp"], "env": { "OPENTRAIN_PERSONAL_API_TOKEN": "ot_pat_..." } } } } ``` Full setup: [Antigravity](/docs/developers/agents/antigravity). Reads Claude Code-format `.mcp.json` at the project root (same `mcpServers` shape as Cursor above), or use the `/mcps` modal in-session. Full setup: [Grok Build](/docs/developers/agents/grok-build). Run `/mcp add` in-session, or edit `~/.copilot/mcp-config.json`: ```json theme={null} { "mcpServers": { "opentrain": { "type": "stdio", "command": "npx", "args": ["-y", "@opentrain-ai/mcp"], "env": { "OPENTRAIN_PERSONAL_API_TOKEN": "${OPENTRAIN_PERSONAL_API_TOKEN}" } } } } ``` Full setup: [Copilot CLI](/docs/developers/agents/copilot-cli). `opencode.json` — note the key is `mcp`, the command is an array, and env lives under `environment`: ```json theme={null} { "$schema": "https://opencode.ai/config.json", "mcp": { "opentrain": { "type": "local", "command": ["npx", "-y", "@opentrain-ai/mcp"], "environment": { "OPENTRAIN_PERSONAL_API_TOKEN": "{env:OPENTRAIN_PERSONAL_API_TOKEN}" }, "enabled": true } } } ``` Full setup: [OpenCode](/docs/developers/agents/opencode). Aider has no MCP support — use the CLI instead: ```bash theme={null} npm install -g @opentrain-ai/cli opentrain auth login --api-key ot_pat_... ``` Then run commands from the chat with `/run opentrain ... --json`. Full setup: [Aider](/docs/developers/agents/aider). Any MCP-capable agent works with the generic `mcpServers` shape — `command: "npx"`, `args: ["-y", "@opentrain-ai/mcp"]`, token in `env`. Examples for Claude Desktop and Windsurf: [Other MCP Agents](/docs/developers/agents/other-mcp-agents). ## Next Steps Self-serve: create the app, get your client credentials, send your first consent link. The complete worked example your agent can use as a blueprint. Report work back and keep funded budgets ahead of consumption. # Consent and Installs Source: https://opentrain.ai/docs/developers/annotation-platforms/consent-and-installs How customers connect their OpenTrain account to your platform: the consent deep link, scopes, PII consent, one-time ot_ptk_ tokens, reconnects, and revocation. An **install** is one customer's grant of access to your platform app: which scopes they approved, whether they consented to Work Email sharing, and the tokens minted under that grant. Every Platform API token (`ot_ptk_…`) belongs to exactly one install. ## The Consent Deep Link Send your customer (an OpenTrain employer — specifically the organization owner) to: ```text theme={null} https://app.opentrain.ai/integrations/{partnerSlug}/connect?external_project_id=&redirect_uri=&state= ``` | Query parameter | Required | Purpose | | --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `external_project_id` | No | Your project identifier. Echoed back on the return redirect so you know which of your projects initiated the connection. | | `redirect_uri` | No | Where to send the customer after consent. Must be `http(s)` and match a redirect URI registered for your app: same origin, and its path must start with the registered base path (so dynamic sub-paths under a registered base work). | | `state` | No | Opaque value echoed back unchanged on the return redirect — use it to correlate the flow on your side. | On the consent screen the customer sees your app's name, the scopes you requested, and — if you requested `participants:email` — a separate, explicit **PII consent checkbox** for Work Email sharing. They can approve or decline. ## The One-Time Token When the customer approves, OpenTrain mints an install-scoped `ot_ptk_…` token and displays it **once, on the consent screen only**. The customer copies it into your platform's integration settings. The token never travels in the redirect back to your platform. The return redirect carries only `opentrain_install_id`, plus your echoed `state` and `external_project_id` — never credentials. If the token is lost, the customer must reconnect to mint a new one. ## Scopes Your app requests a subset of these seven scopes when you [register it](/docs/developers/annotation-platforms/register-your-app); the customer sees each one described on the consent screen: | Scope | Grants | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `project-links:read` | Read links between your projects and OpenTrain jobs | | `project-links:write` | Create and remove project links (implies `project-links:read`) | | `contracts:read` | Read contracts (status, dates, [budget](/docs/developers/annotation-platforms/api-reference/contracts/budget)) on linked jobs | | `participants:read` | Read hired participants on linked contracts: OpenTrain user ID, display name, profile URL, country | | `participants:email` | Read the participant's `@opentrain.work` [Work Email](/docs/developers/concepts/privacy-and-work-email). Requires the explicit PII consent checkbox at install time; personal email addresses are never shared | | `usage:write` | [Report cumulative per-day work usage](/docs/developers/annotation-platforms/usage-sync) (time, tasks, labels) for contracts on linked jobs | | `webhooks:manage` | Create, list, update, and delete webhook endpoints | `participants:email` is double-gated: the scope must be granted **and** the install must have `piiConsent: true`. If the customer granted the scope but left the consent box unchecked, requests that would return Work Email are refused with `403` at request time — and webhook payloads simply omit the `workEmail` field. ## Inspecting Your Install [`GET /installs/current`](/docs/developers/annotation-platforms/api-reference/installs/current) returns the install your token belongs to — useful as a connectivity check and to confirm which scopes and consent you actually have: ```bash theme={null} curl -sS https://app.opentrain.ai/api/partner/v1/installs/current \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` ```json theme={null} { "install": { "id": "", "status": "ACTIVE", "scopes": [ "project-links:read", "project-links:write", "contracts:read", "participants:read", "participants:email", "webhooks:manage" ], "piiConsent": true, "organizationId": "", "createdAt": "2026-06-12T10:00:00.000Z", "partnerApp": { "id": "", "slug": "your-app", "name": "Your App" }, "token": { "id": "", "name": "Your App connection", "scopes": ["..."] } } } ``` ## What Every Request Requires Beyond the right scope, every Platform API request checks four things. Any of them failing yields `401` or `403`: 1. A live token — not revoked or expired 2. An `ACTIVE` install 3. An `ACTIVE` platform app 4. The annotation-platform feature enabled for the granting employer's account ## Reconnecting If a customer runs the consent flow again for an app they already installed, OpenTrain **revokes all previous tokens for that install before minting the fresh one**. A scope reduction on reconnect therefore cannot be bypassed by holding on to an older, broader token. Treat any `401` as a signal to ask the customer to reconnect. ## Revocation Customers can disconnect your app at any time from their OpenTrain integrations page. Disconnecting immediately: 1. Flips the install to `REVOKED` 2. Revokes every access token — your next request returns `401` 3. Emits an [`install.revoked`](/docs/developers/annotation-platforms/lifecycle-events#install-revoked) event to webhook deliveries already queued 4. Stops all new event fan-out for that install Handle `install.revoked` by ceasing work for that customer and marking the connection as disconnected in your UI. # Lifecycle Events Source: https://opentrain.ai/docs/developers/annotation-platforms/lifecycle-events The eight platform event types — contract, project-link, install, and milestone-budget events — with payload shapes, Work Email rules, and how to handle each. The platform integration emits eight event types. They are delivered as signed POSTs to your [webhook endpoints](/docs/developers/annotation-platforms/webhooks) and answer two questions: *who should have access to your workspace right now,* and *is there funded budget for the work?* | Event | Fires when | What you should do | | --------------------------- | --------------------------------------------------------- | --------------------------------------------------------------- | | `contract.started` | The employer hires an AI trainer on a linked job | Provision the AI trainer in your workspace (by Work Email) | | `contract.ended` | The contract ends (a human co-signed action in OpenTrain) | Remove the AI trainer's access | | `project_link.created` | A project link is created on your install | Record the mapping; optionally reconcile existing contracts | | `project_link.removed` | A project link is deleted (permanent) | Stop associating that job's contracts with your project | | `install.revoked` | The customer disconnects (or reconnects) your app | Cease work for that customer; mark the connection disconnected | | `milestone.funded` | The employer funds a milestone on a linked contract | Clear budget warnings; work can continue | | `milestone.budget_low` | Budget consumption crosses 80% of funded volume | Warn on the project; the employer is nudged to fund more | | `milestone.budget_depleted` | Budget consumption crosses 100% | Tell the AI trainer funded work is exhausted pending re-funding | Events are emitted only for **ACTIVE installs**, and contract events only for installs holding a project link whose `jobId` matches the contract's job. Emission is best-effort and happens after the underlying business action commits — it never blocks a hire or contract end. ## The Event Record Every webhook body is one event record: ```json theme={null} { "id": "", "type": "contract.started", "apiVersion": "v1", "createdAt": "2026-06-12T10:00:00.000Z", "resourceId": "", "jobId": "", "data": { "...": "event-type-specific payload below" } } ``` `id` is monotonically increasing — safe to use for ordering. `resourceId` is the contract ID for contract events, the link ID for project-link events, the install ID for `install.revoked`, and the milestone ID for milestone-budget events (the contract ID when no milestone applies). `jobId` is `null` where no job applies. Always verify the [`X-OpenTrain-Signature` header](/docs/developers/guides/verify-webhook-signatures) before trusting any of it. ## Contract Events `contract.started` and `contract.ended` share one payload shape: ```json theme={null} { "contract": { "id": "", "status": "active", "jobId": "", "title": "Traffic sign annotation", "startDate": "2026-06-12T10:00:00.000Z", "endDate": null }, "projectLink": { "id": "", "jobId": "", "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "provisioningMode": "PARTNER_WEBHOOK" }, "freelancer": { "opentrainUserId": "", "displayName": "Maria S.", "country": "Philippines", "profileUrl": "https://app.opentrain.ai/profile/", "workEmail": "maria.1234@opentrain.work" } } ``` On `contract.ended`, `contract.status` is `"ended"` and `endDate` carries the end timestamp. If multiple links on your install reference the same job, you receive one event per link, each carrying its own `projectLink`. ### When `workEmail` Is Included The `freelancer.workEmail` field appears only when **all** of these hold: 1. The install was granted with the explicit **PII consent** checkbox checked 2. The install's scopes include `participants:email` 3. The AI trainer has an active `@opentrain.work` Work Email account Otherwise the field is simply omitted. Personal email addresses are **never** included under any circumstances — see [Privacy and Work Email](/docs/developers/concepts/privacy-and-work-email). If `workEmail` is missing, you can retry via [`GET /contracts/{contractId}/participants`](/docs/developers/annotation-platforms/api-reference/contracts/participants) (same consent gates apply) or skip provisioning and surface the gap to the customer. ## Project Link Events `project_link.created` and `project_link.removed` carry the link (for `removed`, a final snapshot — the row is hard-deleted): ```json theme={null} { "projectLink": { "id": "", "jobId": "", "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "provisioningMode": "PARTNER_WEBHOOK" } } ``` ## Install Revoked ```json theme={null} { "installId": "", "partnerAppId": "", "partnerAppSlug": "your-app" } ``` After this event your token returns `401` on every request. What to stop or archive on your side is your call — OpenTrain does not delete anything in your workspace. The full revocation sequence is described in [Consent and Installs](/docs/developers/annotation-platforms/consent-and-installs#revocation). ## Milestone Budget Events `milestone.funded`, `milestone.budget_low`, and `milestone.budget_depleted` close the funding loop for platforms that [report usage](/docs/developers/annotation-platforms/usage-sync). All three share one payload shape — the contract, the active funded milestone (or `null`), the full [budget object](/docs/developers/annotation-platforms/api-reference/contracts/budget#response), and the project link: ```json theme={null} { "contract": { "id": "", "status": "active", "jobId": "", "title": "Traffic sign annotation" }, "milestone": { "id": "", "name": "Week 2", "amountUsd": 280, "volume": 20, "status": "ACTIVE_FUNDED" }, "budget": { "contractId": "", "paymentType": "PAY_PER_HOUR", "state": "LOW", "fundedVolume": 40, "fundedAmountUsd": 560, "consumed": { "seconds": 118800, "hours": 33, "labels": 0, "tasks": 87 }, "consumedVolume": 33, "remainingVolume": 7, "consumedFraction": 0.825, "activeMilestone": { "id": "", "name": "Week 2", "amountUsd": 280, "volume": 20, "status": "ACTIVE_FUNDED" }, "lastUsageAt": "2026-06-12T18:00:00.000Z" }, "projectLink": { "id": "", "jobId": "", "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "provisioningMode": "PARTNER_WEBHOOK" } } ``` The threshold events fire **once per upward crossing** — repeated usage reports while the state stays `LOW` or `DEPLETED` do not re-fire them. The [usage-sync guide](/docs/developers/annotation-platforms/usage-sync) covers how budgets are computed and what to do on each event. ## Webhooks Trigger, Pulls Reconcile Treat webhooks as **triggers**, not as your source of truth: * **No historical replay.** A webhook endpoint only receives events created after it was registered, and contract events are only emitted for jobs linked at hire/end time. Anything earlier must come from a pull. * **Reconcile on a schedule.** Periodically list [`GET /contracts?status=active`](/docs/developers/annotation-platforms/api-reference/contracts/list) and diff against who currently has access in your workspace. Provision anyone missing, offboard anyone whose contract ended while your endpoint was down or [auto-disabled](/docs/developers/annotation-platforms/webhooks#auto-disable-and-recovery). * **Resolve details by pulling.** On any contract event you can re-fetch [participants](/docs/developers/annotation-platforms/api-reference/contracts/participants) for the authoritative current state rather than relying solely on the payload snapshot. The [reference integration](/docs/developers/annotation-platforms/reference-integration) shows this pattern: webhook-driven provisioning with a participants-endpoint fallback. # Annotation Platforms Overview Source: https://opentrain.ai/docs/developers/annotation-platforms/overview Integrate OpenTrain talent into your annotation platform: customers hire AI trainers on OpenTrain, and contract lifecycle webhooks tell you exactly when to provision and offboard them in your workspace. The OpenTrain Platform API lets annotation platforms plug OpenTrain's AI trainer marketplace into their own product — whether you run a commercial data labeling platform or internal annotation tooling at an AI lab or enterprise. Your customers hire AI trainers on OpenTrain (with OpenTrain handling vetting, contracts, escrow, and payouts), and OpenTrain tells your platform exactly when each hired AI trainer should gain or lose access to your workspace. ```text theme={null} Base URL: https://app.opentrain.ai/api/partner/v1 Auth: Authorization: Bearer ot_ptk_... ``` **Platform apps never receive personal data.** Pre-hire candidates are invisible to this API entirely. Post-hire participants expose only their OpenTrain user ID, display name, profile URL, and country. The optional `workEmail` field returns the AI trainer's platform-issued `@opentrain.work` [Work Email](/docs/developers/concepts/privacy-and-work-email) — and only when the employer explicitly consented at install time. Personal email addresses are never shared through any surface. ## The Core Loop ```mermaid theme={null} sequenceDiagram participant Customer as Employer (your customer) participant OpenTrain participant Platform as Your platform Customer->>OpenTrain: Opens your consent deep link and approves scopes OpenTrain-->>Customer: Shows the ot_ptk_ token once Customer->>Platform: Pastes the token into your integration settings Platform->>OpenTrain: POST /project-links (map your project to their job) Platform->>OpenTrain: POST /webhook-endpoints (subscribe to events) Customer->>OpenTrain: Hires an AI trainer OpenTrain-->>Platform: contract.started (signed webhook) Platform->>Platform: Provision the AI trainer by Work Email Customer->>OpenTrain: Ends the contract (human co-signed) OpenTrain-->>Platform: contract.ended Platform->>Platform: Offboard the AI trainer ``` 1. **Connect.** Your customer opens your [consent deep link](/docs/developers/annotation-platforms/consent-and-installs), reviews the scopes your app requests, and approves. OpenTrain mints an install-scoped `ot_ptk_…` token and shows it once — the customer pastes it into your platform. 2. **Link.** Your platform creates a [project link](/docs/developers/annotation-platforms/project-links) mapping one of your projects to the customer's OpenTrain job. All events are job-keyed, so this mapping is what routes them to you. 3. **Subscribe.** Register a [webhook endpoint](/docs/developers/annotation-platforms/webhooks) for the [lifecycle events](/docs/developers/annotation-platforms/lifecycle-events) you care about. 4. **Provision.** When the customer hires, you receive `contract.started` with the AI trainer's Work Email (consent permitting) — create or invite their account in your workspace. 5. **Offboard.** Ending a contract is a human co-signed action inside OpenTrain. When it completes you receive `contract.ended` — remove the AI trainer's access. 6. **Sync usage (optional).** [Report cumulative per-day work](/docs/developers/annotation-platforms/usage-sync) on linked contracts — OpenTrain computes budget consumption against funded milestones and webhooks warn both sides before funded hours or labels run out. The [reference integration](/docs/developers/annotation-platforms/reference-integration) implements this entire loop in one self-contained webhook consumer you can adapt to your own platform's user API. ## Provisioning Modes Each project link declares how hired AI trainers get into your workspace: | Mode | Who provisions | When to use | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `PARTNER_WEBHOOK` (default, recommended) | **You** — your platform consumes `contract.started` / `contract.ended` webhooks and manages accounts itself | You have a user or membership API and want full control | | `MANAGED_ADAPTER` | **OpenTrain** — the managed-credential path operated by OpenTrain | Your platform has no integration surface yet; coordinate with the OpenTrain team | Everything in these docs focuses on `PARTNER_WEBHOOK`, the mode the reference integration uses. ## Authentication Every Platform API call carries an install-scoped bearer token: ```bash theme={null} curl -sS https://app.opentrain.ai/api/partner/v1/installs/current \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` Tokens start with `ot_ptk_` and are minted on the OpenTrain consent screen — shown once, never included in the redirect back to your platform. Each token is bound to a single **install** (one customer's grant to your app) and carries exactly the scopes that customer approved. See [Consent and Installs](/docs/developers/annotation-platforms/consent-and-installs) for the full lifecycle, including reconnects and revocation. ## Getting Access Registration is self-serve: create your platform app directly in the OpenTrain app — name, redirect URIs, and requested scopes — and your client ID and consent deep link are issued immediately. See [Register Your App](/docs/developers/annotation-platforms/register-your-app). ## Where to Go Next The consent deep link, scopes, one-time tokens, reconnects, and revocation. Map your projects to OpenTrain jobs so events route correctly. The eight event types, their payloads, and what to do on each. Report work back to OpenTrain and keep funded budgets ahead of consumption. Endpoint management, signatures, retries, auto-disable, and redelivery. A complete worked example: a webhook consumer that provisions and offboards AI trainers against a self-hosted annotation tool. Every endpoint, hand-documented with examples and error tables. The machine-readable contract of record is the served spec at `GET https://app.opentrain.ai/api/partner/v1/openapi.json` (no authentication required). The Platform API shares its [error envelope](/docs/developers/concepts/errors-pagination-limits) and [webhook signature scheme](/docs/developers/guides/verify-webhook-signatures) with the Public API. # Project Links Source: https://opentrain.ai/docs/developers/annotation-platforms/project-links Map your platform's projects to OpenTrain jobs so contract events route to the right workspace: create, list, get, and delete project links. A **project link** maps one of your platform's projects to an OpenTrain job. Links are the routing layer of the integration: [lifecycle events](/docs/developers/annotation-platforms/lifecycle-events) are job-keyed, so you only receive `contract.started` / `contract.ended` for jobs you have linked, and [`GET /contracts`](/docs/developers/annotation-platforms/api-reference/contracts/list) only surfaces contracts on linked jobs. Link the job **before** the customer starts hiring. Events are emitted at hire/end time for jobs with an active link — there is no replay for contracts that started before the link existed (though you can still read them via [`GET /contracts`](/docs/developers/annotation-platforms/api-reference/contracts/list) once the link is in place). ## Creating a Link Requires `project-links:write`. The customer supplies (or your integration settings store) the OpenTrain `jobId`; you supply your own project's identity: ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/partner/v1/project-links \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "jobId": "", "provisioningMode": "PARTNER_WEBHOOK" }' ``` The rules: * **`externalProjectId` is required** (max 200 characters) and **unique per install** — creating a second link with the same ID returns `409`. * **`jobId` must be a job owned by the installing employer** — anything else returns `404`. (`jobId` is optional at creation; a link without a job receives no contract events until it points at one.) * **`provisioningMode` defaults to `PARTNER_WEBHOOK`** — you provision workspace members from webhook events. `MANAGED_ADAPTER` selects the OpenTrain managed-credential path instead. * A successful create emits a `project_link.created` event. Full parameter and response detail: [Create Project Link](/docs/developers/annotation-platforms/api-reference/project-links/create). ## Reading Links ```bash theme={null} # All links on this install, newest first (project-links:read) curl -sS https://app.opentrain.ai/api/partner/v1/project-links \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" # One link curl -sS https://app.opentrain.ai/api/partner/v1/project-links/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` Each link looks like: ```json theme={null} { "id": "", "jobId": "", "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "provisioningMode": "PARTNER_WEBHOOK", "createdAt": "2026-06-12T10:00:00.000Z" } ``` ## Deleting a Link ```bash theme={null} curl -sS -X DELETE https://app.opentrain.ai/api/partner/v1/project-links/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` Deletion is **permanent** — the link row is hard-deleted. The response and the emitted `project_link.removed` event both carry a final snapshot of the link, which is the last time you will see its data. After deletion, contract events for that job stop (unless another link on your install references the same job). ## Re-Linking There is no update operation on links. To point one of your projects at a different OpenTrain job — or fix a wrong `externalProjectId` — delete the old link and create a new one. Expect a `project_link.removed` followed by a `project_link.created` event. ## Related Reference Pages * [List Project Links](/docs/developers/annotation-platforms/api-reference/project-links/list) * [Create Project Link](/docs/developers/annotation-platforms/api-reference/project-links/create) * [Get Project Link](/docs/developers/annotation-platforms/api-reference/project-links/get) * [Delete Project Link](/docs/developers/annotation-platforms/api-reference/project-links/delete) # Reference Integration: Webhook Consumer Source: https://opentrain.ai/docs/developers/annotation-platforms/reference-integration A complete worked example: a single-file webhook consumer that provisions hired AI trainers into your annotation workspace and offboards them when contracts end. This page walks through a complete, working platform integration: a single TypeScript file that links one of your annotation projects to an OpenTrain job, subscribes to lifecycle webhooks, and then provisions and offboards AI trainers automatically as contracts start and end. It was validated end-to-end against a self-hosted open-source annotation tool. The integration touches only generic user/organization APIs on the tool side — find-or-create a user by email, remove an organization membership — so the same structure maps directly onto any annotation platform or internal tool with a members API. ## Architecture The integration is a **pure webhook consumer**, platform-side only: * It talks to OpenTrain exclusively through the [Platform API](/docs/developers/annotation-platforms/api-reference/overview) (`Authorization: Bearer ot_ptk_…`) and signed webhooks. * Its only imports are `node:crypto` (signature verification) and `node:http` (the listener) — no SDKs, no frameworks. * It has three subcommands: `link`, `subscribe`, and `run`. ```mermaid theme={null} flowchart LR OT[OpenTrain] -- "signed webhook
contract.started / contract.ended" --> C[Consumer
node:http listener] C -- "verify HMAC, dedupe" --> H[Event handler] H -- "find-or-create user,
remove org membership" --> TOOL[Your annotation tool's API] H -. "participants fallback
(workEmail missing)" .-> OT ``` ## Prerequisites * An annotation tool (or workspace) whose API you administer. * A tool-side API token with **administrator rights over organization membership**. In many tools, a plain member's token can read and create users but gets `403` when removing memberships — offboarding silently breaks. Test the removal path with your token before going live. * An OpenTrain platform token with `project-links:write`, `contracts:read`, `participants:read`, `participants:email`, and `webhooks:manage` — and an install granted **with PII consent**, since provisioning keys off the AI trainer's [Work Email](/docs/developers/concepts/privacy-and-work-email). ## Configuration Every setting is an environment variable with a matching CLI flag: | Env var | Flag | Used by | Value | | -------------------------- | ------------------ | ---------------------------------------- | ---------------------------------------------------- | | `OPENTRAIN_BASE_URL` | `--opentrain-url` | all | `https://app.opentrain.ai` | | `OPENTRAIN_PARTNER_TOKEN` | `--partner-token` | `link`, `subscribe` (optional for `run`) | `ot_ptk_…` from the consent screen | | `OPENTRAIN_WEBHOOK_SECRET` | `--webhook-secret` | `run` | `whsec_…` from endpoint creation | | `TOOL_BASE_URL` | `--tool-url` | `link`, `run` | your tool's API origin, e.g. `http://localhost:8080` | | `TOOL_API_TOKEN` | `--tool-token` | `link`, `run` | tool-side admin API token | ## Step 1: Connect The customer (an OpenTrain employer) opens your [consent deep link](/docs/developers/annotation-platforms/consent-and-installs), approves the scopes plus the PII consent checkbox, and pastes the one-time `ot_ptk_…` token into your configuration. ## Step 2: Link a Project ```bash theme={null} npx tsx opentrain-integration.ts link --project 1 --job-id ``` The `link` command reads the project from your tool's API and registers it as an OpenTrain [project link](/docs/developers/annotation-platforms/project-links), so contract events for that job route to this integration: ```typescript theme={null} const { status, json } = await toolRequest(config, 'GET', `/api/projects/${projectId}/`); const project = json as { id: number; title?: string }; await opentrainRequest(config, 'POST', '/api/partner/v1/project-links', { externalProjectId: String(project.id), externalProjectName: project.title ?? `Annotation project ${project.id}`, externalProjectUrl: `${config.toolBaseUrl}/projects/${project.id}`, provisioningMode: 'PARTNER_WEBHOOK', jobId, }); ``` ## Step 3: Subscribe ```bash theme={null} npx tsx opentrain-integration.ts subscribe --url https://your-host.example.com/webhooks/opentrain ``` This registers a [webhook endpoint](/docs/developers/annotation-platforms/webhooks) for `contract.started`, `contract.ended`, `project_link.removed`, and `install.revoked`, then prints the one-time `whsec_…` secret: ```text theme={null} Export the secret before starting the listener: export OPENTRAIN_WEBHOOK_SECRET=whsec_ ``` ## Step 4: Run the Listener ```bash theme={null} npx tsx opentrain-integration.ts run --port 8484 --path /webhooks/opentrain ``` The listener does four things in order on every delivery: verify, dedupe, handle, acknowledge. ### Verify the Signature Raw bytes, constant-time compare, 300-second drift window — rejected deliveries get `401`: ```typescript theme={null} import { createHmac, timingSafeEqual } from 'node:crypto'; const SIGNATURE_TOLERANCE_SECONDS = 300; function verifySignature(secret: string, signatureHeader: string, body: string): boolean { const parts = new Map( signatureHeader.split(',').map((part) => { const eq = part.indexOf('='); return [part.slice(0, eq), part.slice(eq + 1)] as const; }) ); const timestamp = parts.get('t'); const signature = parts.get('v1'); if (!timestamp || !signature) return false; const timestampSeconds = Number(timestamp); if (!Number.isFinite(timestampSeconds)) return false; const drift = Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds); if (drift > SIGNATURE_TOLERANCE_SECONDS) return false; const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); const expectedBuffer = Buffer.from(expected, 'utf8'); const actualBuffer = Buffer.from(signature, 'utf8'); if (expectedBuffer.length !== actualBuffer.length) return false; return timingSafeEqual(expectedBuffer, actualBuffer); } ``` ### Dedupe and Acknowledge Retries reuse the same `X-OpenTrain-Delivery` ID, so the listener keeps a bounded set of processed IDs and answers `200 duplicate` for repeats. Successful handling returns `200`; a thrown error returns `500`, which makes OpenTrain [retry with backoff](/docs/developers/annotation-platforms/webhooks#retries) (1m, 5m, 30m, 120m). ### Handle Contract Events `contract.started` finds or creates the workspace user by Work Email; `contract.ended` removes their organization membership: ```typescript theme={null} async function handleEvent(config: Config, event: WebhookEvent): Promise { switch (event.type) { case 'contract.started': { const resolved = await resolveWorkEmail(config, event); if (!resolved) return; // no consent/scope — surface the gap instead of guessing const { user, created } = await ensureWorkspaceUser( config, resolved.email, resolved.displayName ); log(created ? 'provisioned workspace user' : 'user already exists', { userId: user.id, contractId: event.data?.contract?.id ?? event.resourceId, }); return; } case 'contract.ended': { const resolved = await resolveWorkEmail(config, event); if (!resolved) return; const outcome = await removeWorkspaceOrgMember(config, resolved.email); log(`deprovision outcome: ${outcome}`); // removed | not_found | cannot_remove_self return; } case 'project_link.removed': case 'install.revoked': log(`received ${event.type} — stopping work for this link/install is up to the platform`); return; } } ``` ### Fall Back to the Participants Endpoint If the payload lacks `workEmail` (or arrived before consent was sorted out), the handler pulls [`GET /contracts/{contractId}/participants`](/docs/developers/annotation-platforms/api-reference/contracts/participants) — the same consent gates apply, but this makes the handler robust to redelivered or hand-replayed events: ```typescript theme={null} async function resolveWorkEmail(config: Config, event: WebhookEvent) { const freelancer = event.data?.freelancer; if (freelancer?.workEmail) { return { email: freelancer.workEmail, displayName: freelancer.displayName }; } const contractId = event.data?.contract?.id ?? event.resourceId; if (!contractId || !config.partnerToken) return null; const result = await opentrainRequest( config, 'GET', `/api/partner/v1/contracts/${contractId}/participants` ); const participants = (result.participants ?? []) as Array<{ displayName?: string; workEmail?: string; }>; const match = participants.find((participant) => participant.workEmail); return match?.workEmail ? { email: match.workEmail, displayName: match.displayName ?? 'OpenTrain Freelancer' } : null; } ``` ## Failure Handling | Failure | What happens | What you do | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Handler throws (e.g. your tool's API briefly down) | Listener returns `500`; OpenTrain retries up to 5 attempts | Usually nothing — a later retry succeeds | | Listener down past the retry window | Deliveries marked `FAILED`; 10 consecutive failures [auto-disable](/docs/developers/annotation-platforms/webhooks#auto-disable-and-recovery) the endpoint | `PATCH {"status": "ACTIVE"}`, then [redeliver](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/redeliver) all `FAILED` | | Events from before the subscription existed | Never delivered — no backfill | Reconcile via [`GET /contracts?status=active`](/docs/developers/annotation-platforms/api-reference/contracts/list) | ## Tool-Side Gotchas These came up validating the consumer against a real self-hosted tool — check the equivalents in yours: * **User-list response shapes vary across versions.** Some builds return a plain array, others `{ "results": [...] }`. Handle both when searching for a user by email. * **"Cannot remove self" surfaces oddly.** The tool refused to remove the token owner from their own organization with a `405` rather than a clear error — treat unexpected statuses on the removal path as a distinct outcome, not a generic failure. * **Use an organization admin's token.** Membership removal returned `403` for plain members, which breaks offboarding while provisioning appears to work. * **Check which auth scheme your token uses.** Some tools ship multiple token systems (e.g. a legacy `Authorization: Token ` header that must be explicitly enabled) — verify the scheme your integration uses is switched on. # Register Your App Source: https://opentrain.ai/docs/developers/annotation-platforms/register-your-app Create an annotation-platform app in OpenTrain settings — self-serve, instant, no approval queue. Get a ptnapp_ client ID and a consent link your customers use to connect. Registering an annotation-platform app is self-serve and instant: create the app in your OpenTrain settings, get a `ptnapp_` client ID and a consent link, and start connecting customers immediately. There is no approval queue — the real gate is the [consent screen](/docs/developers/annotation-platforms/consent-and-installs), where each customer explicitly grants your app its scopes. ## Create the App In the OpenTrain app, go to [Settings → Developer](https://app.opentrain.ai/employer-settings?tab=developer) and create an app under **Platform apps**: | Field | Required | Constraints | | ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Name | Yes | What customers see on the consent screen. Max 120 characters. | | Slug | Yes | Lowercase letters, digits, hyphens (`my-platform`), 1–50 characters. **Immutable after creation** — it becomes part of your consent link. | | Description | No | Shown to customers for context. Max 500 characters. | | Website URL | No | Your platform's site. | | Redirect URIs | No | Up to 10 `http(s)` base URIs. Required only if you pass `redirect_uri` on the consent link — the URI must match a registered one (same origin, path starts with the registered base path). | | Scopes | Yes | At least one of the seven [Platform API scopes](/docs/developers/annotation-platforms/consent-and-installs#scopes). Request only what you need — customers see every scope on the consent screen. | On creation the app is **active immediately** and the success banner shows two things: 1. **Your client ID** (`ptnapp_…`) — a public identifier for your app. 2. **Your consent link** — what you send customers to connect: ```text theme={null} https://app.opentrain.ai/integrations/{your-slug}/connect ``` There is no client secret. Your app never authenticates as itself — each customer's consent mints an install-scoped `ot_ptk_…` token, shown once on the consent screen, and that token is the credential your platform stores. See [Consent and Installs](/docs/developers/annotation-platforms/consent-and-installs). ## Managing the App From the same settings section you can: * **Edit** everything except the slug — name, description, website, redirect URIs, and scopes. Scope changes apply to **new installs only**; existing installs keep the scopes their customer granted until they reconnect. * **Deactivate** — flips the app to `DISABLED`: the consent link stops accepting new connections and API requests from existing installs are refused until you reactivate. Use it as an emergency brake. * **Reactivate** — restores new connections and existing installs' API access. ## What Happens Next 1. Send a customer your consent link (optionally with `external_project_id`, `redirect_uri`, and `state` — see [the consent deep link](/docs/developers/annotation-platforms/consent-and-installs#the-consent-deep-link)). 2. They approve the scopes and copy the one-time `ot_ptk_…` token into your platform's settings. 3. You call the [Platform API](/docs/developers/annotation-platforms/api-reference/overview) with that token, [register webhooks](/docs/developers/annotation-platforms/webhooks), and [link projects](/docs/developers/annotation-platforms/project-links). The deep link, scopes, PII consent, and the one-time token. A complete webhook-consumer walkthrough you can adapt. # Usage Sync and Budgets Source: https://opentrain.ai/docs/developers/annotation-platforms/usage-sync Report work done on your platform back to OpenTrain contracts, track milestone budget depletion, and close the funding loop with budget_low / budget_depleted events. Your platform is where the work happens; OpenTrain is where the contract and the money live. Usage sync connects the two: you report how much each hired AI trainer has worked, OpenTrain converts that into budget consumption against the contract's funded milestones, and when funding runs low both sides find out — your platform via webhooks, the employer and the AI trainer via OpenTrain notifications. The result is a loop where work never silently outruns funding. ```mermaid theme={null} flowchart TD A["AI trainer works in your platform"] --> B["You POST cumulative day totals
to /contracts/{id}/usage"] B --> C["OpenTrain recomputes the budget
against funded milestones"] C --> D{"consumedFraction"} D -->|"≥ 80%"| E["milestone.budget_low webhook
+ employer notified in OpenTrain"] D -->|"≥ 100%"| F["milestone.budget_depleted webhook
+ employer and AI trainer notified"] E --> G["Employer funds the next milestone
(human co-signs in OpenTrain)"] F --> G G --> H["milestone.funded webhook —
budget back to OK, work continues"] ``` ## Reporting Usage [`POST /contracts/{contractId}/usage`](/docs/developers/annotation-platforms/api-reference/contracts/usage) (scope `usage:write`) takes **cumulative per-worker, per-day totals** — not deltas: ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/partner/v1/contracts//usage \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "entries": [ { "workDate": "2026-06-12", "totalSeconds": 14400, "tasksCompleted": 52, "labelsCompleted": 410 } ] }' ``` Each entry **replaces** the stored totals for its (worker, day), which makes retries free: re-POSTing the same report — or a corrected one — never double counts. Send the AI trainer's full day total each time, on whatever cadence suits you (end of day is enough; hourly is fine too). `workerOpentrainUserId` is optional and defaults to the contract's hired AI trainer. The response returns the recomputed budget, so a usage POST doubles as a budget check. ## How Budgets Are Computed A contract's budget is the sum of its funded milestones' **volume** measured against consumed work. What counts as volume depends on the contract's payment type: | `paymentType` | Funded volume | Consumed by | Depletes? | | --------------- | ---------------- | -------------------------------- | ------------------------- | | `PAY_PER_HOUR` | Milestone hours | `totalSeconds / 3600` | Yes | | `PAY_PER_LABEL` | Milestone labels | `labelsCompleted` | Yes | | `FIXED_PRICE` | — | Usage is progress reporting only | No — state is always `OK` | The budget object (returned by usage POSTs, [`GET /contracts/{contractId}/budget`](/docs/developers/annotation-platforms/api-reference/contracts/budget), and carried on budget webhooks) reports `state` as one of: | `state` | Meaning | | ---------- | ---------------------------------------------------------- | | `OK` | Below 80% of funded volume consumed | | `LOW` | `consumedFraction` ≥ 0.8 — time to fund the next milestone | | `DEPLETED` | `consumedFraction` ≥ 1.0 — funded work is exhausted | Work the AI trainer does on OpenTrain's own surfaces counts toward the same budget — your reported usage and first-party work share one ledger, so the numbers you read back are the whole picture. ## The Depletion Events Crossing a threshold **upward** emits a webhook to your [endpoints](/docs/developers/annotation-platforms/webhooks) (subscribe to the event types like any other): * `milestone.budget_low` — consumption crossed 80% * `milestone.budget_depleted` — consumption crossed 100% Each fires **once per crossing** — continued usage POSTs while the state stays `LOW` or `DEPLETED` do not re-fire it. Funding a new milestone lowers `consumedFraction`; if usage later crosses a threshold again, the event fires again. All three budget events share one payload shape (`milestone` is the active funded milestone, or `null` if none): ```json theme={null} { "contract": { "id": "", "status": "active", "jobId": "", "title": "Traffic sign annotation" }, "milestone": { "id": "", "name": "Week 2", "amountUsd": 280, "volume": 20, "status": "ACTIVE_FUNDED" }, "budget": { "contractId": "", "paymentType": "PAY_PER_HOUR", "state": "LOW", "fundedVolume": 40, "fundedAmountUsd": 560, "consumed": { "seconds": 118800, "hours": 33, "labels": 0, "tasks": 87 }, "consumedVolume": 33, "remainingVolume": 7, "consumedFraction": 0.825, "activeMilestone": { "id": "", "name": "Week 2", "amountUsd": 280, "volume": 20, "status": "ACTIVE_FUNDED" }, "lastUsageAt": "2026-06-12T18:00:00.000Z" }, "projectLink": { "id": "", "jobId": "", "externalProjectId": "42", "externalProjectName": "Traffic signs batch 3", "externalProjectUrl": "https://your-platform.example.com/projects/42", "provisioningMode": "PARTNER_WEBHOOK" } } ``` What to do with them in your UI: * **On `budget_low`** — surface a warning on the project ("\~80% of funded hours used"). The employer is simultaneously notified in OpenTrain to fund the next milestone. * **On `budget_depleted`** — tell the AI trainer in your UI that funded work is exhausted and the next milestone needs funding on OpenTrain before continuing. Both the employer and the AI trainer get OpenTrain notifications saying the same thing. Whether to hard-stop task assignment is your call — OpenTrain doesn't block your platform. ## The Re-Funding Loop Funding happens on OpenTrain, never through your platform: the employer (or their agent, [co-signed by a human](/docs/developers/concepts/human-approvals)) funds the next milestone. The moment that happens you receive: * `milestone.funded` — same payload shape as above, with the fresh budget. `state` typically returns to `OK` and `remainingVolume` grows. Clear your warnings and let work continue. That's the full conversation: **usage out → depletion events in → employer funds → `milestone.funded` in → repeat.** ## Reconciliation Like all [platform webhooks](/docs/developers/annotation-platforms/lifecycle-events#webhooks-trigger-pulls-reconcile), budget events are triggers, not the source of truth — there is no replay. Poll [`GET /contracts/{contractId}/budget`](/docs/developers/annotation-platforms/api-reference/contracts/budget) (scope `contracts:read`) on a schedule, or rely on the budget object returned by each usage POST, to recover state after downtime. Entry validation, idempotency, and the full response shape. The read-only budget view, field by field. # Platform Webhooks Source: https://opentrain.ai/docs/developers/annotation-platforms/webhooks Register webhook endpoints for platform lifecycle events, verify signatures, understand the retry schedule and auto-disable behavior, and redeliver failed events. Platform webhooks push [lifecycle events](/docs/developers/annotation-platforms/lifecycle-events) to your platform as signed HTTPS POSTs. All endpoint management requires the `webhooks:manage` scope. ## Registering an Endpoint ```bash theme={null} curl -sS -X POST https://app.opentrain.ai/api/partner/v1/webhook-endpoints \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-platform.example.com/hooks/opentrain", "eventTypes": ["contract.started", "contract.ended", "project_link.removed", "install.revoked"] }' ``` * `url` must be **https** (plain `http` is allowed only for `localhost` during development). Redirects are not followed — deliveries to a redirecting URL fail. * `eventTypes` is a non-empty subset of the eight-event catalog: `contract.started`, `contract.ended`, `project_link.created`, `project_link.removed`, `install.revoked`, `milestone.funded`, `milestone.budget_low`, `milestone.budget_depleted`. * **The signing `secret` is returned once, in the creation response only.** It cannot be retrieved later — if lost, delete the endpoint and create a new one (which mints a new secret). * **There is no backfill.** A new endpoint starts at the current event high-water mark and only receives events created after registration. Use [`GET /contracts`](/docs/developers/annotation-platforms/api-reference/contracts/list) to reconcile anything earlier. ## Anatomy of a Delivery Each event is POSTed to your URL with `Content-Type: application/json` and: | Header | Contents | | ----------------------- | ---------------------------------------------------------------- | | `X-OpenTrain-Event` | The event type (e.g. `contract.started`) | | `X-OpenTrain-Delivery` | Unique delivery ID — dedupe on it; retries reuse the same ID | | `X-OpenTrain-Signature` | `t=,v1=.")>` | | `User-Agent` | `OpenTrain-Partner-Webhooks/1.0` | The body is one [event record](/docs/developers/annotation-platforms/lifecycle-events#the-event-record). Verify the signature against the **raw request bytes** before parsing — the scheme is identical to the Public API's; the [signature verification guide](/docs/developers/guides/verify-webhook-signatures) has tested Node and Python implementations. Respond with any `2xx` within **10 seconds**. Do the actual work asynchronously if it might run long; a timeout counts as a failure. ## Retries A failed delivery (non-2xx, timeout, or connection error) is retried up to **5 total attempts** with backoff delays of **1, 5, 30, and 120 minutes** after the first failure. A delivery that exhausts all 5 attempts is marked `FAILED` and can be requeued manually with [redeliver](#redelivery). Because retries reuse the same `X-OpenTrain-Delivery` ID, idempotent handling is simple: record the IDs you have processed and return `200` immediately for repeats. ## Auto-Disable and Recovery After **10 consecutive failed deliveries**, the endpoint is auto-disabled: its `status` flips to `DISABLED` (with `disabledAt` and `disabledReason` set), all its `PENDING` deliveries are marked `FAILED`, and no new deliveries are attempted. A successful delivery at any point resets `consecutiveFailures` to 0. To recover: ```bash theme={null} # 1. Re-enable — resets the failure counter curl -sS -X PATCH https://app.opentrain.ai/api/partner/v1/webhook-endpoints/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "ACTIVE"}' # 2. Requeue everything that failed while you were down curl -sS -X POST https://app.opentrain.ai/api/partner/v1/webhook-endpoints//redeliver \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` Then run a pull-side reconciliation against [`GET /contracts`](/docs/developers/annotation-platforms/api-reference/contracts/list) for anything emitted before the endpoint existed or after deliveries were discarded. ## Redelivery [`POST /webhook-endpoints/{endpointId}/redeliver`](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/redeliver): * **With** `{"deliveryId": "..."}` — requeues that specific delivery regardless of its status (useful for replaying one event into a fixed handler). * **Without** a body — requeues **all `FAILED` deliveries** for the endpoint. Requeued deliveries are only attempted while the endpoint is `ACTIVE`. ## Managing Endpoints ```bash theme={null} # List (newest first) curl -sS https://app.opentrain.ai/api/partner/v1/webhook-endpoints \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" # Inspect one — includes delivery health (status, consecutiveFailures, disabledReason) curl -sS https://app.opentrain.ai/api/partner/v1/webhook-endpoints/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" # Change URL or subscribed events curl -sS -X PATCH https://app.opentrain.ai/api/partner/v1/webhook-endpoints/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"eventTypes": ["contract.started", "contract.ended", "install.revoked"]}' # Delete — pending deliveries are discarded curl -sS -X DELETE https://app.opentrain.ai/api/partner/v1/webhook-endpoints/ \ -H "Authorization: Bearer $OT_PARTNER_TOKEN" ``` The secret is never returned by `GET` or `PATCH`. Changing the URL via `PATCH` keeps the existing secret; only delete + create mints a new one. ## Operations Checklist * [ ] Verify the signature on **raw bytes** with a constant-time compare, and reject stale timestamps ([guide](/docs/developers/guides/verify-webhook-signatures)) * [ ] Dedupe by `X-OpenTrain-Delivery` * [ ] Return `2xx` within 10 seconds; queue slow work * [ ] Return `5xx` (or any non-2xx) when processing genuinely fails, so OpenTrain retries * [ ] Monitor `consecutiveFailures` via [`GET /webhook-endpoints/{endpointId}`](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/get) and alert before it reaches 10 * [ ] On recovery: `PATCH {"status": "ACTIVE"}` → redeliver → pull-side reconcile * [ ] Store the secret in a secrets manager at creation time — it is shown exactly once # Invite Team Member Source: https://opentrain.ai/docs/developers/api-reference/team/invite POST /api/public/v1/team/invites Invite a human to the workspace team by email — sends the same invitation as the in-app flow. Invites a human to the employer team by email, giving them shared access to the account's jobs and team inbox once they accept. This sends the same invitation email as the in-app flow. The outcome depends on the email: | `status` | What happened | | ---------------- | --------------------------------------------------------------------------------------------------------------------- | | `invite_created` | An invitation email was sent; the invite appears in [`GET /team`](/docs/developers/api-reference/team/list) until accepted | | `member_added` | The email belongs to an existing OpenTrain account — they were added to the team directly, no email round-trip | | `already_member` | Already on the team; nothing changed | **Requirements:** `team:write` scope + the `public_api_team` feature + a **claimed** account (unclaimed accounts get `403` with `details.reason: "account_claim_required"` and a `claimUrl`). ## Request Email address to invite. Must be a valid email. ## Response `true` on success. `invite_created`, `member_added`, or `already_member` (see the table above). The email that was invited. The refreshed team record — same shape as [`GET /team`](/docs/developers/api-reference/team/list) — so you can confirm the new member or pending invite without a second call. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Body missing/not valid JSON, `email` invalid (`details.issues`), or the invite was rejected by validation (`details.email`) | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `team:write`, `public_api_team` disabled, employer teams not enabled (`details.reason: "teams_not_enabled"`), or account not claimed (`details.reason: "account_claim_required"`, `details.claimUrl`) | | `409` | `CONFLICT` | The invitation email could not be sent (`details.reason: "invite_email_send_failed"`) — retry later | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/team/invites \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email": "newhire@example.com"}' ``` ```bash CLI theme={null} opentrain team invite --email newhire@example.com --json ``` ```json MCP: opentrain_invite_team_member theme={null} { "email": "newhire@example.com" } ``` ```json 200 (invite created) theme={null} { "ok": true, "status": "invite_created", "email": "newhire@example.com", "team": { "organization": { "id": "", "name": "Acme Research", "ownerUserId": "", "ownerName": "Dana Owner" }, "members": [ { "userId": "", "role": "OWNER", "name": "Dana Owner", "email": "dana@example.com", "joinedAt": "2026-05-01T09:00:00.000Z" } ], "invites": [ { "id": "", "email": "newhire@example.com", "createdAt": "2026-06-12T10:00:00.000Z", "expiresAt": "2026-06-19T10:00:00.000Z" } ] } } ``` ```json 403 (unclaimed account) theme={null} { "error": "A human must claim this agent account before it can invite team members.", "code": "FORBIDDEN", "requestId": "", "details": { "reason": "account_claim_required", "action": "invite team members", "claimUrl": "https://app.opentrain.ai/claim" } } ``` # List Team Members Source: https://opentrain.ai/docs/developers/api-reference/team/list GET /api/public/v1/team Read the workspace team: organization, members with roles, and pending email invites. Reads the employer team for the account: the organization, every member with their role, and any pending email invites. Team members share the account's jobs and team inbox — invite humans with [`POST /team/invites`](/docs/developers/api-reference/team/invite). Member `email` here is the team member's own login email — these are humans on **your** workspace, not AI trainers (AI trainer contact always goes through the [Work Email system](/docs/developers/concepts/privacy-and-work-email)). **Requirements:** `team:read` scope + the `public_api_team` feature. Works pre-claim. ## Request No parameters. ## Response `{id, name, ownerUserId, ownerName}` — the workspace organization and its owner. Each member: `{userId, role, name, email, joinedAt}`. `role` is `OWNER` or `MEMBER`; `email` can be `null`. Pending invites that have not been accepted yet: `{id, email, createdAt, expiresAt}`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Missing `team:read` (`details.requiredScopes`, `details.grantedScopes`), `public_api_team` disabled, or employer teams not enabled for the account (`details.reason: "teams_not_enabled"`) | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/team \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain team show --json ``` ```json MCP: opentrain_get_team theme={null} {} ``` ```json 200 theme={null} { "team": { "organization": { "id": "", "name": "Acme Research", "ownerUserId": "", "ownerName": "Dana Owner" }, "members": [ { "userId": "", "role": "OWNER", "name": "Dana Owner", "email": "dana@example.com", "joinedAt": "2026-05-01T09:00:00.000Z" }, { "userId": "", "role": "MEMBER", "name": "Sam Teammate", "email": "sam@example.com", "joinedAt": "2026-06-02T14:30:00.000Z" } ], "invites": [ { "id": "", "email": "newhire@example.com", "createdAt": "2026-06-12T10:00:00.000Z", "expiresAt": "2026-06-19T10:00:00.000Z" } ] } } ``` # Create Token Source: https://opentrain.ai/docs/developers/api-reference/tokens/create POST /api/public/v1/tokens Mint a new personal API token. New tokens can never exceed the caller's scopes — the secret is shown once. Mints a new `ot_pat_` personal API token on the account. This is how you hand a narrowly-scoped token to a sub-agent or integration, set an expiry on automation credentials, and [rotate](/docs/developers/concepts/authentication#token-management-and-rotation) a token (create the replacement, switch over, then [revoke](/docs/developers/api-reference/tokens/revoke) the old one). **New tokens can never escalate:** every requested scope must be covered by a scope the calling token already holds (`:write` covers its `:read`). Accounts hold at most **25** active tokens. The plaintext `token` is returned **only in this response** — store it immediately. Afterwards only the masked `preview` is visible via [`GET /tokens`](/docs/developers/api-reference/tokens/list). **Requirements:** any valid token — token management needs no specific scope or feature flag, and works pre-claim. This endpoint is HTTP-only: no CLI command or MCP tool wraps it. Humans with a claimed account can also mint scoped tokens from the OpenTrain app's settings. ## Request All fields are optional — an empty body mints a no-expiry clone of the caller's scopes named "API token". Label for the token (max 120 chars). Defaults to `API token`. Scopes for the new token, from the [scope catalog](/docs/developers/concepts/scopes-and-capabilities). Defaults to the calling token's scopes. Every entry must be covered by the caller's scopes (`403` otherwise). ISO 8601 timestamp in the future after which the token stops working. Defaults to no expiry. ## Response Returns `201`. The plaintext `ot_pat_…` secret. **Shown once — store it now.** `bearer`. The token record: `{id, name, preview, scopes, status: "active", organizationId, createdAt, lastUsedAt, expiresAt, revokedAt}` — same shape as the entries in [`GET /tokens`](/docs/developers/api-reference/tokens/list). ## Errors | Status | `code` | Meaning | | ------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `BAD_REQUEST` | Body not valid JSON; `name` empty or over 120 chars; `scopes` not a string array or contains unknown scopes (`details.unknownScopes`, `details.supportedScopes`); `expiresAt` not a valid ISO timestamp or not in the future | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `403` | `FORBIDDEN` | Requested scopes exceed the caller's (`details.requestedScopes`, `details.grantedScopes`, `details.escalatedScopes`) | | `409` | `CONFLICT` | Active token limit reached (25) — revoke unused tokens first | ```bash curl theme={null} curl -sS -X POST https://app.opentrain.ai/api/public/v1/tokens \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Read-only reporting", "scopes": ["jobs:read", "proposals:read"], "expiresAt": "2026-09-01T00:00:00.000Z" }' ``` ```json 201 theme={null} { "token": "ot_pat_", "tokenType": "bearer", "metadata": { "id": "", "name": "Read-only reporting", "preview": "ot_pat_8f2a********91cd", "scopes": ["jobs:read", "proposals:read"], "status": "active", "organizationId": null, "createdAt": "2026-06-12T10:00:00.000Z", "lastUsedAt": null, "expiresAt": "2026-09-01T00:00:00.000Z", "revokedAt": null } } ``` ```json 403 (scope escalation) theme={null} { "error": "Requested scopes exceed the scopes of the authenticating token.", "code": "FORBIDDEN", "requestId": "", "details": { "requestedScopes": ["payments:write"], "grantedScopes": ["jobs:read", "jobs:write", "proposals:read"], "escalatedScopes": ["payments:write"] } } ``` # List Tokens Source: https://opentrain.ai/docs/developers/api-reference/tokens/list GET /api/public/v1/tokens List every personal API token on the account — names, scopes, status, expiry. Secrets are never returned. Lists every personal API token on the account — active, expired, and revoked — with metadata only. **Secrets are never returned** after creation; the `preview` field shows just enough (prefix + last 4 characters) to match a token you hold against its record. Use this to audit what can access the account, find the `id` to pass to [revoke](/docs/developers/api-reference/tokens/revoke), and check how close you are to the 25-active-token cap before [creating](/docs/developers/api-reference/tokens/create) more. **Requirements:** any valid token — token management needs no specific scope or feature flag, and works pre-claim. ## Request No parameters. ## Response Token ID — pass to [`DELETE /tokens/{tokenId}`](/docs/developers/api-reference/tokens/revoke). Human-readable label set at creation. Masked secret, e.g. `ot_pat_c3f9********bd21` — prefix plus the last 4 characters. The token's [scopes](/docs/developers/concepts/scopes-and-capabilities), sorted. `active`, `expired` (past `expiresAt`), or `revoked`. The organization the token is bound to, when applicable. ISO creation timestamp. Last time the token authenticated a request — `null` means never used. When the token stops working; `null` means no expiry. When the token was revoked; `null` unless `revoked`. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ------------------------ | | `401` | `UNAUTHORIZED` | Missing or invalid token | ```bash curl theme={null} curl -sS https://app.opentrain.ai/api/public/v1/tokens \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain tokens list --json ``` ```json MCP: opentrain_list_tokens theme={null} {} ``` ```json 200 theme={null} { "tokens": [ { "id": "", "name": "Hiring agent", "preview": "ot_pat_c3f9********bd21", "scopes": ["jobs:read", "jobs:write", "proposals:read"], "status": "active", "organizationId": null, "createdAt": "2026-06-12T10:00:00.000Z", "lastUsedAt": "2026-06-12T11:30:00.000Z", "expiresAt": null, "revokedAt": null }, { "id": "", "name": "Old CI token", "preview": "ot_pat_91ab********77e2", "scopes": ["jobs:read"], "status": "revoked", "organizationId": null, "createdAt": "2026-05-27T18:30:46.565Z", "lastUsedAt": null, "expiresAt": null, "revokedAt": "2026-05-27T18:36:08.676Z" } ] } ``` # Revoke Token by ID Source: https://opentrain.ai/docs/developers/api-reference/tokens/revoke DELETE /api/public/v1/tokens/{tokenId} Revoke a personal API token — it stops working immediately and irreversibly. Revokes one personal API token by ID. The token stops authenticating **immediately** and revocation is irreversible — to restore access, [create](/docs/developers/api-reference/tokens/create) a new token. Find the `tokenId` via [`GET /tokens`](/docs/developers/api-reference/tokens/list). Revoking the token you are currently authenticating with breaks every subsequent call you make with it — revoke your own token only as the final step of a rotation. Tokens belonging to another account return `404` — the API never confirms the existence of a token the caller does not own. To revoke by presenting the token itself instead of its ID (RFC 7009), use [`POST /api/agent/identity/revoke`](/docs/developers/api-reference/agent-auth/revoke). **Requirements:** any valid token — token management needs no specific scope or feature flag, and works pre-claim. ## Request The token ID (not the `ot_pat_` secret) from [`GET /tokens`](/docs/developers/api-reference/tokens/list). ## Response The revoked token record — same shape as the entries in [`GET /tokens`](/docs/developers/api-reference/tokens/list), now with `status: "revoked"` and `revokedAt` set. ## Errors | Status | `code` | Meaning | | ------ | -------------- | ---------------------------------------------------------------------- | | `401` | `UNAUTHORIZED` | Missing or invalid token | | `404` | `NOT_FOUND` | No such token, or it belongs to another account (`details: {tokenId}`) | ```bash curl theme={null} curl -sS -X DELETE https://app.opentrain.ai/api/public/v1/tokens/ \ -H "Authorization: Bearer $OT_API_TOKEN" ``` ```bash CLI theme={null} opentrain tokens revoke --token-id --json ``` ```json MCP: opentrain_revoke_token theme={null} { "tokenId": "" } ``` ```json 200 theme={null} { "token": { "id": "", "name": "Old CI token", "preview": "ot_pat_91ab********77e2", "scopes": ["jobs:read"], "status": "revoked", "organizationId": null, "createdAt": "2026-05-27T18:30:46.565Z", "lastUsedAt": "2026-06-01T09:15:00.000Z", "expiresAt": null, "revokedAt": "2026-06-12T10:00:00.000Z" } } ``` ```json 404 theme={null} { "error": "Token not found", "code": "NOT_FOUND", "requestId": "", "details": { "tokenId": "" } } ``` # CLI Command Reference Source: https://opentrain.ai/docs/developers/cli/commands Every OpenTrain CLI command with flags, an example, and the API endpoint it wraps — the complete surface on one page. The complete `opentrain` command surface on one page. Every command supports `--json` (raw API response) and `--base-url ` (override the API origin); those two flags are omitted from the per-command tables below. Scope, feature-flag, and claim requirements are those of the wrapped endpoint — follow the **Wraps** link on each command. See the [CLI overview](/docs/developers/cli/overview) for installation, credentials, and conventions. ## Global ```bash theme={null} opentrain --version # print the CLI version (also: opentrain version) opentrain help # print usage (also: opentrain --help, or no arguments) ``` ## Auth and Identity ### whoami ```bash theme={null} opentrain whoami [--json] ``` Alias of `auth status` — prints the authenticated user, token label, scopes, and account type. Wraps [`GET /api/public/v1/auth/me`](/docs/developers/api-reference/auth/me). ### auth register ```bash theme={null} opentrain auth register [--agent-name ] [--org-name ] [--force] [--json] ``` Creates an anonymous agent account and saves the pre-claim `ot_pat_` token plus the claim token to the credentials file. Fails if credentials already exist unless `--force` is passed. | Flag | Description | | --------------------- | ----------------------------------------------------------------------------------------- | | `--agent-name ` | Display name for the agent identity (alias `--name`) | | `--org-name ` | Name for the employer organization created with the account (alias `--organization-name`) | | `--force` | Overwrite existing saved credentials | ```bash theme={null} opentrain auth register --agent-name "Hiring agent" --org-name "Acme Research" --json ``` Wraps [`POST /api/agent/identity`](/docs/developers/api-reference/agent-auth/register). ### auth claim ```bash theme={null} opentrain auth claim --email [--claim-token ] [--json] ``` Emails the human owner an invitation to claim the agent-registered account. The claim token defaults to the one saved by `auth register`. | Flag | Description | | ----------------------- | --------------------------------------------- | | `--email
` | **Required.** The human owner's email address | | `--claim-token ` | Claim token to use; defaults to the saved one | Wraps [`POST /api/agent/identity/claim`](/docs/developers/api-reference/agent-auth/claim). ### auth claim-status ```bash theme={null} opentrain auth claim-status [--wait] [--timeout ] [--claim-token ] [--json] ``` Checks whether the human completed the claim. On success the saved token is **upgraded automatically** to the claimed account's scopes. Without `--wait` it checks once and reports `pending`; with `--wait` it polls (respecting `slow_down` backoff) until claimed or the timeout elapses. | Flag | Description | | ----------------------- | ------------------------------------------------ | | `--wait` | Poll until the claim completes | | `--timeout ` | Give up waiting after this long (default `1800`) | | `--claim-token ` | Claim token to check; defaults to the saved one | ```bash theme={null} opentrain auth claim-status --wait --timeout 600 ``` Wraps [`POST /api/agent/oauth/token`](/docs/developers/api-reference/agent-auth/token). ### auth login ```bash theme={null} opentrain auth login --api-key [--base-url ] ``` Verifies an existing `ot_pat_` token against the API, then saves it to the credentials file. | Flag | Description | | ------------------- | ------------------------------------------------------ | | `--api-key ` | **Required.** The personal API token (alias `--token`) | Wraps [`GET /api/public/v1/auth/me`](/docs/developers/api-reference/auth/me) for verification. ### auth status ```bash theme={null} opentrain auth status [--json] ``` Same as `whoami`. Wraps [`GET /api/public/v1/auth/me`](/docs/developers/api-reference/auth/me). ### auth logout ```bash theme={null} opentrain auth logout ``` Deletes the saved credentials file. Purely local — it does not revoke the token; use [`tokens revoke`](#tokens-revoke) for that. ## Jobs ### jobs draft create ```bash theme={null} opentrain jobs draft create --description [--title ] [--external-id <id>] [--idempotency-key <key>] [--json] ``` Creates an unpublished draft from a plain-text job description (the primary workflow) or a structured payload. The response includes the validation state: each missing field carries an `ask:` question, type, allowed enum values, and the field name to set with `jobs draft update`. Alias: `jobs create`. | Flag | Description | | --------------------------- | ------------------------------------------------------------------- | | `--description <text>` | Plain-text job description or project brief | | `--description-file <path>` | Read the description from a file instead | | `--canonical-file <path>` | JSON file with an OpenTrain canonical job object | | `--payload-file <path>` | JSON file with a supported import payload (e.g. schema.org JSON-LD) | | `--title <title>` | Title to prepend to plain-text descriptions | | `--external-id <id>` | Source-system id for audit | | `--idempotency-key <key>` | Reuse to avoid duplicate drafts on retry | ```bash theme={null} opentrain jobs draft create \ --description "Label 5,000 street-scene images with bounding boxes. Native Spanish speakers, \$14/hr." \ --json ``` Wraps [`POST /api/public/v1/job-drafts`](/docs/developers/api-reference/job-drafts/create). ### jobs draft update ```bash theme={null} opentrain jobs draft update --job-id <id> --set <field=value> [--set <field=value> ...] [--json] ``` Fills in or corrects fields on an unpublished draft. `--set` values are auto-coerced (numbers, booleans, JSON arrays; everything else stays a string); repeat the loop until the draft is publish-ready. Alias: `jobs update`. | Flag | Description | | --------------------- | ---------------------------------------------------------------------------------- | | `--job-id <id>` | **Required.** The draft job id | | `--set <field=value>` | Set one field (repeatable) — use the `set:` field names from the validation output | | `--patch-file <path>` | JSON file with a field patch object (alternative to `--set`) | | `--patch-json <json>` | Inline JSON patch object (alternative to `--set`) | ```bash theme={null} opentrain jobs draft update --job-id <JOB_ID> \ --set paymentType=PAY_PER_HOUR --set pricePerHour=14 --json ``` Wraps [`PATCH /api/public/v1/job-drafts/{jobId}`](/docs/developers/api-reference/job-drafts/update). ### jobs list ```bash theme={null} opentrain jobs list [--status <status>] [--limit <n>] [--cursor <cursor>] [--json] ``` Lists your own jobs with publish state, proposal counts, and live URLs. | Flag | Description | | ------------------- | ------------------------------------------------------------------------------- | | `--status <status>` | Filter: `DRAFT`, `OPEN`, `ONGOING`, `COMPLETED`, `ARCHIVED`, `PENDING_APPROVAL` | | `--limit <n>` | Page size (max 100, default 25) | | `--cursor <cursor>` | Pagination cursor from the previous page | Wraps [`GET /api/public/v1/jobs/mine`](/docs/developers/api-reference/jobs/mine). ### jobs search ```bash theme={null} opentrain jobs search [--q <text>] [--category <slug>] [--language <lang>] [--country <iso>] [--pay-type <type>] [--limit <n>] [--cursor <cursor>] [--json] ``` Searches the public marketplace (all live jobs, not just yours). **No token required.** | Flag | Description | | ------------------------------ | ------------------------------------------------- | | `--q <text>` | Free-text query | | `--category <slug>` | Category filter | | `--language <lang>` | Language filter | | `--country <iso>` | ISO country code | | `--pay-type <type>` | `PAY_PER_HOUR`, `FIXED_PRICE`, or `PAY_PER_LABEL` | | `--limit <n>` / `--cursor <c>` | Pagination | Wraps [`GET /api/public/v1/jobs`](/docs/developers/api-reference/jobs/search). ### jobs publish ```bash theme={null} opentrain jobs publish --job-id <id> [--json] ``` Publishes a publish-ready draft live on the marketplace, running the same validation and moderation pipeline as the in-app flow. Subject to daily publish limits. Wraps [`POST /api/public/v1/jobs/{id}/publish`](/docs/developers/api-reference/jobs/publish). ### jobs invite ```bash theme={null} opentrain jobs invite --job-id <id> --freelancer-id <id> [--json] ``` Invites an AI trainer to a published job, creating a proposal. Idempotent — re-inviting returns the existing proposal. Wraps [`POST /api/public/v1/jobs/{id}/invites`](/docs/developers/api-reference/jobs/invite). ### jobs close ```bash theme={null} opentrain jobs close --job-id <id> [--json] ``` Archives a published job: it stops accepting proposals and leaves public listings. Existing contracts are unaffected. Idempotent. Wraps [`POST /api/public/v1/jobs/{id}/close`](/docs/developers/api-reference/jobs/close). ### jobs update-published ```bash theme={null} opentrain jobs update-published --job-id <id> --set <field=value> [--set <field=value> ...] [--json] ``` Patches a live (`OPEN`) job using the same field names and `--set`/`--patch-file`/`--patch-json` flags as `jobs draft update`. The revised listing is re-moderated; a blocked result unpublishes the job back to draft. Wraps [`PATCH /api/public/v1/jobs/{id}`](/docs/developers/api-reference/jobs/update). ## Proposals and Candidates ### proposals list ```bash theme={null} opentrain proposals list --job-id <id> [--status <status>] [--limit <n>] [--cursor <cursor>] [--json] ``` Lists proposals for a job with statuses, bids, and AI-interview scores — the ranking view for deciding who to interview or hire. | Flag | Description | | ------------------------------ | ------------------------------------------------------------- | | `--job-id <id>` | **Required.** The job to list proposals for | | `--status <status>` | Filter, e.g. `UNREVIEWED`, `SHORTLISTED`, `HIRED`, `DECLINED` | | `--limit <n>` / `--cursor <c>` | Pagination (max 100, default 25) | Wraps [`GET /api/public/v1/jobs/{id}/proposals`](/docs/developers/api-reference/jobs/list-proposals). ### proposals get ```bash theme={null} opentrain proposals get --proposal-id <id> [--interview] [--json] ``` Reads the full candidate evaluation: bid, AI-interview score and summary, verification, assessment results, and contract state. `--interview` also fetches the sanitized AI-interview transcript. Alias: `proposal get`. Wraps [`GET /api/public/v1/proposals/{proposalId}`](/docs/developers/api-reference/proposals/get) and, with `--interview`, [`GET /proposals/{proposalId}/interview`](/docs/developers/api-reference/proposals/interview). ### proposals hire ```bash theme={null} opentrain proposals hire --proposal-id <id> --amount <usd> [--milestone-name <name>] [--milestone-description <text>] [--due-date <iso>] [--confirm-not-fit-override] [--json] ``` Requests a hire — **never hires anyone or moves money**. Records a pending approval and returns `202` with an `approvalUrl` a signed-in human must confirm in the OpenTrain app; on confirm, the contract is created and the first escrow milestone funded. A `409 payment_method_required` includes a `billingUrl` a human must visit. Alias: `proposal hire`. | Flag | Description | | -------------------------------- | ---------------------------------------------------------------------------------------------------- | | `--proposal-id <id>` | **Required.** The proposal to hire from | | `--amount <usd>` | **Required.** First milestone amount in USD | | `--milestone-name <name>` | Short milestone name | | `--milestone-description <text>` | What the first milestone delivers | | `--due-date <iso>` | Milestone due date (ISO 8601) | | `--confirm-not-fit-override` | Confirm hiring a candidate previously marked "Not a fit" after a `409 not_fit_confirmation_required` | ```bash theme={null} opentrain proposals hire --proposal-id <PROPOSAL_ID> --amount 500 \ --milestone-name "First batch" --milestone-description "First 1,000 labeled images" --json ``` Wraps [`POST /api/public/v1/proposals/{proposalId}/hire`](/docs/developers/api-reference/proposals/hire). ### freelancers get ```bash theme={null} opentrain freelancers get --id <user-id-or-slug> [--json] ``` Reads a masked AI trainer profile (skills, stats, work and labeling experience, education, reviews) by user id or public profile slug. Names stay masked to first name + last initial; personal contact details are never returned. Alias: `freelancer get`. Wraps [`GET /api/public/v1/freelancers/{idOrSlug}`](/docs/developers/api-reference/freelancers/get). ## Messages ### messages list ```bash theme={null} opentrain messages list [--filter all|job|proposal] [--unread-only] [--limit <n>] [--cursor <cursor>] [--json] ``` Lists conversation summaries you participate in, with unread counts and the latest message. Wraps [`GET /api/public/v1/messages`](/docs/developers/api-reference/messages/list). ### messages unread ```bash theme={null} opentrain messages unread [--filter all|job|proposal] [--limit <n>] [--cursor <cursor>] [--json] ``` Shorthand for `messages list --unread-only` — only conversations with unread messages. ### messages read ```bash theme={null} opentrain messages read --conversation-id <id> [--limit <n>] [--direction older|newer] [--cursor <cursor>] [--json] ``` Reads messages inside one conversation, paginated in either direction. Wraps [`GET /api/public/v1/messages?conversationId=...`](/docs/developers/api-reference/messages/list). ### messages send ```bash theme={null} opentrain messages send --conversation-id <id> --content <text> [--json] ``` Sends a plain-text message (max 10,000 chars) into an existing conversation. Use `--content-file <path>` to read the message body from a file. Wraps [`POST /api/public/v1/messages`](/docs/developers/api-reference/messages/send). ### messages start-proposal-thread ```bash theme={null} opentrain messages start-proposal-thread --proposal-id <id> [--json] ``` Opens (or fetches) the pre-hire direct-message thread for a proposal — idempotent get-or-create, employer side only. Returns the `conversationId` to use with `messages send`. Wraps [`POST /api/public/v1/proposals/{proposalId}/conversation`](/docs/developers/api-reference/proposals/conversation). ## Contracts and Milestones ### contracts list ```bash theme={null} opentrain contracts list [--job-id <id>] [--status active|ended] [--json] ``` Lists contracts (hired AI trainers) with milestones, the masked freelancer identity (first name + last initial), and the post-hire job DM `conversationId`. Wraps [`GET /api/public/v1/contracts`](/docs/developers/api-reference/contracts/list). ### contracts get ```bash theme={null} opentrain contracts get --contract-id <id> [--json] ``` Reads one contract in detail. Alias: `contract get`. Wraps [`GET /api/public/v1/contracts/{contractId}`](/docs/developers/api-reference/contracts/get). ### contracts end ```bash theme={null} opentrain contracts end --contract-id <id> [--json] ``` Ends a contract. With no funded milestones it ends immediately; with funded escrow at stake it returns a pending approval with an `approvalUrl` a human must confirm. Wraps [`POST /api/public/v1/contracts/{contractId}/end`](/docs/developers/api-reference/contracts/end). ### milestones create ```bash theme={null} opentrain milestones create --contract-id <id> --description <text> [--name <name>] [--amount <usd>] [--volume <n>] [--due-date <iso>] [--json] ``` Adds an **unfunded** milestone to an active contract — no money moves at creation. | Flag | Description | | ---------------------- | ------------------------------------------------------ | | `--contract-id <id>` | **Required.** The contract to add to | | `--description <text>` | **Required.** What the milestone delivers | | `--name <name>` | Short milestone name | | `--amount <usd>` | Milestone amount in USD | | `--volume <n>` | Unit volume for per-unit milestones (e.g. label count) | | `--due-date <iso>` | Due date (ISO 8601) | Wraps [`POST /api/public/v1/contracts/{contractId}/milestones`](/docs/developers/api-reference/contracts/create-milestone). ### milestones fund ```bash theme={null} opentrain milestones fund --milestone-id <id> [--json] ``` Requests escrow funding. **No money moves**: the response is a pending approval with an `approvalUrl` a signed-in human must open and confirm (expires in \~72 h). Track with `approvals get` or `updates poll`. Wraps [`POST /api/public/v1/milestones/{milestoneId}/fund`](/docs/developers/api-reference/milestones/fund). ### milestones approve ```bash theme={null} opentrain milestones approve --milestone-id <id> [--json] ``` Requests release of a funded milestone's escrow. Same human co-sign pattern as `milestones fund`. Wraps [`POST /api/public/v1/milestones/{milestoneId}/approve`](/docs/developers/api-reference/milestones/approve). ### approvals get ```bash theme={null} opentrain approvals get --approval-id <id> [--json] ``` Checks a pending human co-sign approval: `pending`, `confirmed`, `declined`, or `expired`, plus the execution result once confirmed. Alias: `approval get`. Wraps [`GET /api/public/v1/approvals/{approvalId}`](/docs/developers/api-reference/approvals/get). ## Credits ### credits show ```bash theme={null} opentrain credits show [--json] ``` Reads the prepaid credit balance — available vs reserved (escrow-held) — plus recent ledger activity. Alias: `credits balance`. Wraps [`GET /api/public/v1/credits`](/docs/developers/api-reference/credits/balance). ### credits ledger ```bash theme={null} opentrain credits ledger [--cursor <cursor>] [--limit <n>] [--json] ``` Pages the full ledger of top-ups, holds, releases, captures, refunds, and adjustments, newest first. Wraps [`GET /api/public/v1/credits/ledger`](/docs/developers/api-reference/credits/ledger). ### credits top-up ```bash theme={null} opentrain credits top-up --amount <usd> [--json] ``` Starts a Stripe Checkout top-up ($10–$10,000) and returns a `checkoutUrl` a human must open and pay — no money moves from the command itself. Wraps [`POST /api/public/v1/credits/top-ups`](/docs/developers/api-reference/credits/create-top-up). ### credits top-up-status ```bash theme={null} opentrain credits top-up-status --top-up-id <id> [--json] ``` Checks a top-up: pending (awaiting payment), completed, canceled, or expired. Wraps [`GET /api/public/v1/credits/top-ups/{topUpId}`](/docs/developers/api-reference/credits/get-top-up). ## Updates ### updates poll ```bash theme={null} opentrain updates poll [--cursor <cursor>] [--limit <n>] [--json] ``` Polls the account delta feed — small ID-only events for new proposals, messages, contracts, milestone changes, and pending payments. Persist the returned `nextCursor` and pass it as `--cursor` on the next poll. Wraps [`GET /api/public/v1/updates`](/docs/developers/api-reference/updates/poll). ## Webhooks ### webhooks create ```bash theme={null} opentrain webhooks create --url <url> --events <type,...> [--json] ``` Subscribes an HTTPS URL to platform events. The response includes the signing secret **once** — store it and use it to [verify](/docs/developers/guides/verify-webhook-signatures) the `X-OpenTrain-Signature` header. | Flag | Description | | --------------------- | ------------------------------------------------------------------------------------ | | `--url <url>` | **Required.** HTTPS delivery endpoint | | `--events <type,...>` | **Required.** Comma-separated event types, e.g. `proposal.received,message.received` | Wraps [`POST /api/public/v1/webhooks`](/docs/developers/api-reference/webhooks/create). ### webhooks list ```bash theme={null} opentrain webhooks list [--json] ``` Lists subscriptions with status (`ACTIVE` or `DISABLED`). Secrets are never included. Wraps [`GET /api/public/v1/webhooks`](/docs/developers/api-reference/webhooks/list). ### webhooks get ```bash theme={null} opentrain webhooks get <webhook-id> [--json] ``` Reads one subscription, including disable details after sustained delivery failure. The id can also be passed as `--webhook-id`. Alias: `webhook get`. Wraps [`GET /api/public/v1/webhooks/{webhookId}`](/docs/developers/api-reference/webhooks/get). ### webhooks delete ```bash theme={null} opentrain webhooks delete <webhook-id> [--json] ``` Deletes a subscription and stops deliveries. Deleting and re-creating is also how a `DISABLED` subscription is resumed (the new one gets a new secret). Wraps [`DELETE /api/public/v1/webhooks/{webhookId}`](/docs/developers/api-reference/webhooks/delete). ## Tokens ### tokens list ```bash theme={null} opentrain tokens list [--json] ``` Lists every API token on the account — names, scopes, status, expiry. Secrets are never returned. Wraps [`GET /api/public/v1/tokens`](/docs/developers/api-reference/tokens/list). ### tokens revoke ```bash theme={null} opentrain tokens revoke --token-id <id> [--json] ``` Revokes a token by id — immediate and irreversible. Revoking the token the CLI is currently using breaks subsequent commands. Wraps [`DELETE /api/public/v1/tokens/{tokenId}`](/docs/developers/api-reference/tokens/revoke). <Note> There is no `tokens create` command — mint new tokens over HTTP with [`POST /tokens`](/docs/developers/api-reference/tokens/create) or in the OpenTrain app's settings. </Note> ## Team ### team show ```bash theme={null} opentrain team show [--json] ``` Reads the employer team: organization, members with roles, and pending invites. Wraps [`GET /api/public/v1/team`](/docs/developers/api-reference/team/list). ### team invite ```bash theme={null} opentrain team invite --email <address> [--json] ``` Emails a human an invitation to join the team with shared job and inbox access. Wraps [`POST /api/public/v1/team/invites`](/docs/developers/api-reference/team/invite). ## Payments ### payments pending ```bash theme={null} opentrain payments pending [--json] ``` Reads pending payment and milestone state visible to the token owner. Read-only — it never releases funds or changes payment settings. Wraps [`GET /api/public/v1/payments/pending`](/docs/developers/api-reference/payments/pending). # CLI Overview Source: https://opentrain.ai/docs/developers/cli/overview Install and configure the OpenTrain CLI: authentication modes, the shared credentials file, JSON output, retries, and the command namespace map. The OpenTrain CLI (`@opentrain-ai/cli`, binary `opentrain`) wraps the [Public API](/docs/developers/api-reference/overview) and the [agent auth endpoints](/docs/developers/concepts/authentication) in a command-line surface built for both humans and coding agents. Every command prints a human-readable summary by default and exact API JSON with `--json`. For a guided first run — register, draft, publish, claim — start with the [CLI quickstart](/docs/developers/quickstart-cli). This page covers configuration and conventions; the [command reference](/docs/developers/cli/commands) documents every command. ## Install ```bash theme={null} npm install -g @opentrain-ai/cli opentrain --version ``` Node.js 18+ is required. One-off runs work too: `npx -y @opentrain-ai/cli whoami`. ## Authentication Modes There are two ways to get credentials into the CLI: <Steps> <Step title="Register as a new agent (no account needed)"> ```bash theme={null} opentrain auth register --agent-name "My agent" ``` Creates an anonymous agent account via [`POST /api/agent/identity`](/docs/developers/api-reference/agent-auth/register) and saves the pre-claim `ot_pat_` token plus the claim token locally. Unlock hiring and other money-adjacent commands later with `opentrain auth claim --email <human-email>` and `opentrain auth claim-status --wait` — the [claim ceremony](/docs/developers/concepts/authentication#the-claim-ceremony). </Step> <Step title="Log in with an existing token"> ```bash theme={null} opentrain auth login --api-key ot_pat_... ``` Verifies the token against [`GET /auth/me`](/docs/developers/api-reference/auth/me) and saves it. Tokens come from the OpenTrain app's settings or [`POST /tokens`](/docs/developers/api-reference/tokens/create). </Step> </Steps> `opentrain auth status` (alias `whoami`) shows who you are, the token's scopes, and the account type at any time. ## Credentials File Credentials are stored at `${XDG_CONFIG_HOME:-~/.config}/opentrain/cli.json` with file mode `0600`: ```json theme={null} { "apiToken": "ot_pat_...", "baseUrl": "https://app.opentrain.ai", "claimToken": "ot_clm_..." } ``` `claimToken` is present only between `auth register` and a completed claim. The file is **shared with the [MCP server](/docs/developers/mcp/overview)** — registering or logging in through either surface makes the credentials available to both. `opentrain auth logout` deletes the file. ## Credential and Base URL Precedence For the API token, highest priority first: 1. `--api-key` on `opentrain auth login` (the only command that accepts a token flag) 2. `OT_API_TOKEN` or `OPENTRAIN_API_TOKEN` environment variable 3. The saved credentials file For the base URL: 1. `--base-url <url>` — accepted by every command 2. `OT_API_BASE_URL` or `OPENTRAIN_API_BASE_URL` environment variable 3. The saved credentials file, then the default `https://app.opentrain.ai` ## Conventions * **`--json` everywhere.** Every command supports `--json`, which prints the raw API response (or, for auth commands, the structured result) as pretty-printed JSON. Agents should always pass it. * **Errors go to stderr with exit code 1.** API failures print `OpenTrain API request failed (<status> <code>): <message>` plus a `Details:` line with the error envelope's [`details` object](/docs/developers/concepts/errors-pagination-limits) when present. Usage mistakes print `Usage error: ...`. * **Automatic retries for reads.** `GET` requests retry up to 3 attempts with exponential backoff (500 ms, 1 s) on `502`/`503`/`504` and transient network failures. Writes only retry failures where the request never reached the server (DNS errors, connection refused), so a retried write can never duplicate. Request timeout is 30 seconds. * **Idempotency.** `jobs draft create` accepts `--idempotency-key`; hire and invite commands are idempotent server-side (re-running returns the existing resource). ## Command Namespaces | Namespace | Commands | What it covers | | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | [`auth`](/docs/developers/cli/commands#auth-and-identity) | `register`, `claim`, `claim-status`, `login`, `status`, `logout` (+ top-level `whoami`) | Agent onboarding, claim ceremony, token management | | [`jobs`](/docs/developers/cli/commands#jobs) | `draft create`, `draft update`, `list`, `search`, `publish`, `invite`, `close`, `update-published` | Drafting, publishing, and managing jobs | | [`proposals`](/docs/developers/cli/commands#proposals-and-candidates) | `list`, `get`, `hire` | Candidate review and hiring | | [`freelancers`](/docs/developers/cli/commands#proposals-and-candidates) | `get` | Masked AI trainer profiles | | [`messages`](/docs/developers/cli/commands#messages) | `list`, `unread`, `read`, `send`, `start-proposal-thread` | Conversations and messaging | | [`contracts`](/docs/developers/cli/commands#contracts-and-milestones) | `list`, `get`, `end` | Post-hire contracts | | [`milestones`](/docs/developers/cli/commands#contracts-and-milestones) | `create`, `fund`, `approve` | Milestones and co-signed money moves | | [`approvals`](/docs/developers/cli/commands#contracts-and-milestones) | `get` | Human co-sign approval status | | [`credits`](/docs/developers/cli/commands#credits) | `show`, `ledger`, `top-up`, `top-up-status` | Prepaid credit balance and top-ups | | [`updates`](/docs/developers/cli/commands#updates) | `poll` | The account event delta feed | | [`webhooks`](/docs/developers/cli/commands#webhooks) | `create`, `list`, `get`, `delete` | Webhook subscriptions | | [`tokens`](/docs/developers/cli/commands#tokens) | `list`, `revoke` | API token audit and revocation | | [`team`](/docs/developers/cli/commands#team) | `show`, `invite` | Employer team management | | [`payments`](/docs/developers/cli/commands#payments) | `pending` | Pending payment reads | Each command's required scope, feature flag, and claim state match the endpoint it wraps — the [command reference](/docs/developers/cli/commands) links every command to its endpoint page. # MCP Server Overview Source: https://opentrain.ai/docs/developers/mcp/overview Connect AI agents to OpenTrain with the official Model Context Protocol server: install, tokenless onboarding, environment variables, and how it relates to the CLI and raw API. The OpenTrain MCP server (`@opentrain-ai/mcp`) exposes the [Public API](/docs/developers/api-reference/overview) and the [agent auth endpoints](/docs/developers/concepts/authentication) as [Model Context Protocol](https://modelcontextprotocol.io) tools, so agent frameworks like Claude Code can hire AI trainers on OpenTrain without writing any HTTP code. It runs locally over stdio and registers as the server name `opentrain`. The [tool reference](/docs/developers/mcp/tools) documents all 41 tools. For a guided first run, start with the [MCP quickstart](/docs/developers/quickstart-mcp). ## Install For Claude Code: ```bash theme={null} claude mcp add opentrain -- npx -y @opentrain-ai/mcp ``` For any other MCP client, add the server to your client's MCP configuration: ```json theme={null} { "mcpServers": { "opentrain": { "command": "npx", "args": ["-y", "@opentrain-ai/mcp"] } } } ``` Node.js 18+ is required. The server communicates over stdio — no ports, no daemon. ## Tokenless Onboarding The MCP server is designed so an agent can start from absolutely nothing: <Steps> <Step title="Register"> Call `opentrain_register_agent` (no credentials needed). It creates an anonymous agent account via [`POST /api/agent/identity`](/docs/developers/api-reference/agent-auth/register) and saves the new `ot_pat_` token to the shared config file, so every other tool works immediately. If saved credentials already exist, the tool refuses unless you pass `force: true`. </Step> <Step title="Work"> Draft and publish jobs, review candidates, and read messages right away — these work pre-claim. See the [scopes and capabilities](/docs/developers/concepts/scopes-and-capabilities) page for what is gated. </Step> <Step title="Claim"> Ask the human for their email and call `opentrain_claim_account`, then poll `opentrain_claim_status` — the [claim ceremony](/docs/developers/concepts/authentication#the-claim-ceremony). Once claimed, the stored token is upgraded automatically and money-adjacent tools (hiring, milestones, top-ups) unlock. </Step> </Steps> ## Credentials and Environment Variables The server reads credentials in this order: 1. `OPENTRAIN_PERSONAL_API_TOKEN` — if set, it wins outright; the saved credentials file is not read at all. 2. The shared credentials file at `${XDG_CONFIG_HOME:-~/.config}/opentrain/cli.json` — the **same file the [CLI](/docs/developers/cli/overview) uses**. Registering or logging in through either surface makes the credentials available to both. The base URL comes from `OPENTRAIN_API_BASE_URL`, then the saved file, then the default `https://app.opentrain.ai`. To run the server against an existing account instead of registering a new one, set the token in your MCP client config: ```json theme={null} { "mcpServers": { "opentrain": { "command": "npx", "args": ["-y", "@opentrain-ai/mcp"], "env": { "OPENTRAIN_PERSONAL_API_TOKEN": "ot_pat_..." } } } } ``` <Note> When `OPENTRAIN_PERSONAL_API_TOKEN` is set, `opentrain_register_agent` and the claim tools still write to the shared config file, but every API call keeps using the env token. Unset it if you want the freshly registered account to take effect. </Note> ## Tool Output Model Every tool returns two parallel representations: * **Text content** — a human-readable summary (status, key fields, suggested next step), useful for models reasoning over results. * **`structuredContent`** — the exact API response object, for programmatic consumption. Failures return `isError: true` with the HTTP status, the error envelope's `code` and message, and the [`details` object](/docs/developers/concepts/errors-pagination-limits) when present (e.g. a `claimUrl` on `403 account_claim_required`, or a `billingUrl` on `409 payment_method_required`). ## MCP vs CLI vs Raw HTTP | Surface | Best for | Credentials | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | **MCP server** | Agents inside MCP-capable frameworks (Claude Code, etc.) — no HTTP code needed | Shared `cli.json` or `OPENTRAIN_PERSONAL_API_TOKEN` | | **[CLI](/docs/developers/cli/overview)** | Shell-based agents and humans; scripting with `--json` | Shared `cli.json` or `OT_API_TOKEN` | | **[Raw HTTP](/docs/developers/api-reference/overview)** | Any language or runtime; surfaces not wrapped by MCP/CLI (e.g. [`POST /tokens`](/docs/developers/api-reference/tokens/create)) | `Authorization: Bearer` header | All three hit the same API with the same scopes, feature flags, and [human co-sign](/docs/developers/concepts/human-approvals) rules — pick whichever fits your runtime. Each MCP tool's requirements mirror the endpoint it wraps; the [tool reference](/docs/developers/mcp/tools) links every tool to its endpoint page. # MCP Tool Reference Source: https://opentrain.ai/docs/developers/mcp/tools Every tool exposed by the OpenTrain MCP server: purpose, parameters, scope/feature/claim requirements, and the API endpoint each one wraps. All 41 tools registered by the [OpenTrain MCP server](/docs/developers/mcp/overview), grouped by surface. Each tool's scope, feature flag, and claim requirements mirror the endpoint it wraps — the linked endpoint page has the full request/response shapes and error tables. Every tool returns a human-readable text summary plus the exact API response in `structuredContent`; failures return `isError: true` with the HTTP status and the [error envelope](/docs/developers/concepts/errors-pagination-limits). Scopes and feature flags are explained in [Scopes and Capabilities](/docs/developers/concepts/scopes-and-capabilities). | Group | Tools | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | [Auth and identity](#auth-and-identity) | `register_agent`, `claim_account`, `claim_status`, `auth_status`, `capabilities` | | [Jobs](#jobs) | `create_job_draft`, `update_job_draft_fields`, `publish_job`, `update_published_job`, `close_job`, `list_jobs`, `search_jobs` | | [Proposals and candidates](#proposals-and-candidates) | `list_proposals`, `get_proposal`, `get_freelancer_profile`, `invite_freelancer`, `hire_proposal` | | [Messages](#messages) | `read_messages`, `send_message`, `start_proposal_conversation` | | [Contracts and milestones](#contracts-and-milestones) | `list_contracts`, `get_contract`, `create_milestone`, `request_milestone_funding`, `request_milestone_approval`, `get_approval`, `end_contract` | | [Credits](#credits) | `get_credits`, `list_credit_ledger`, `create_credit_top_up`, `get_credit_top_up` | | [Updates](#updates) | `poll_updates` | | [Webhooks](#webhooks) | `create_webhook`, `list_webhooks`, `get_webhook`, `delete_webhook` | | [Tokens](#tokens) | `list_tokens`, `revoke_token` | | [Team](#team) | `get_team`, `invite_team_member` | | [Payments](#payments) | `list_pending_payments` | All tool names below carry the `opentrain_` prefix. ## Auth and Identity ### opentrain\_register\_agent Register a brand-new anonymous OpenTrain agent account — no prior credentials needed. The new `ot_pat_` token and claim token are saved to the [shared config file](/docs/developers/mcp/overview#credentials-and-environment-variables), so every other tool works immediately afterwards. Refuses to overwrite existing saved credentials unless `force` is set. Next step: ask the human for their email and call `opentrain_claim_account`. | Parameter | Type | Description | | ------------------ | ----------------- | ------------------------------------------------------------- | | `agentName` | string, optional | Display name for the agent identity (e.g. "Claude Code") | | `organizationName` | string, optional | Name for the employer organization created with the account | | `force` | boolean, optional | Overwrite existing saved credentials with a brand-new account | **Requires:** nothing — anonymous and tokenless. **Wraps:** [`POST /api/agent/identity`](/docs/developers/api-reference/agent-auth/register) ### opentrain\_claim\_account Send the human owner a claim invite for the agent-registered account — the start of the [claim ceremony](/docs/developers/concepts/authentication#the-claim-ceremony). OpenTrain emails them a verification link; once they accept, they own the account and can add billing. Follow up with `opentrain_claim_status`. | Parameter | Type | Description | | ------------ | ---------------- | -------------------------------------------------------- | | `email` | string, required | The human owner's email address | | `claimToken` | string, optional | Claim token from registration; defaults to the saved one | **Requires:** a valid, unexpired claim token (\~24h window from registration). **Wraps:** [`POST /api/agent/identity/claim`](/docs/developers/api-reference/agent-auth/claim) ### opentrain\_claim\_status Check whether the human has completed the claim. When the claim completes, the stored API token is upgraded to the claimed account automatically. A `slow_down` status means you are polling too fast — respect the poll interval from `opentrain_claim_account`. | Parameter | Type | Description | | ------------ | ---------------- | ----------------------------------------------- | | `claimToken` | string, optional | Claim token to check; defaults to the saved one | **Requires:** a valid claim token with an active claim attempt. **Wraps:** [`POST /api/agent/oauth/token`](/docs/developers/api-reference/agent-auth/token) ### opentrain\_auth\_status Confirm that the configured token can authenticate, and see the account's user id, account type, claim state, and granted scopes. No parameters. **Requires:** any valid token. **Wraps:** [`GET /auth/me`](/docs/developers/api-reference/auth/me) ### opentrain\_capabilities Discover which job-drafting features are enabled for the account plus the full job field/enum catalog — use it to probe feature availability before drafting. No parameters. **Requires:** any valid token; the `public_api_job_drafting` feature must be enabled for the account. **Wraps:** [`GET /job-drafts/capabilities`](/docs/developers/api-reference/job-drafts/capabilities) ## Jobs ### opentrain\_create\_job\_draft Create an unpublished job draft from a full job description or supported import payload — the primary drafting workflow. OpenTrain parses the description into structured job fields server-side; the response's validation state lists each missing field with an `ask:` question to relay to the human, its type, allowed enum values, and the `set:` field names for `opentrain_update_job_draft_fields`. | Parameter | Type | Description | | ---------------- | ---------------- | ------------------------------------------------------------------- | | `jobDescription` | string, optional | Full plain-text job description or project brief (primary workflow) | | `title` | string, optional | Title to prepend for plain-text imports | | `externalId` | string, optional | Source-system id for audit/idempotency | | `idempotencyKey` | string, optional | Reuse the same key to avoid duplicate drafts on retry | | `canonicalJob` | object, optional | OpenTrain canonical job object (`format=opentrain_canonical`) | | `importPayload` | object, optional | Supported import payload, e.g. schema.org JSON-LD | **Requires:** `jobs:write` + the `public_api_job_drafting` feature. **Wraps:** [`POST /job-drafts`](/docs/developers/api-reference/job-drafts/create) ### opentrain\_update\_job\_draft\_fields Fill in or correct fields on an existing unpublished draft. Ask the human each missing field's `ask:` question, then patch the answers using the `set:` field names — never ask the human to author raw JSON. This tool never publishes; use `opentrain_publish_job`. | Parameter | Type | Description | | --------- | ---------------- | ---------------------------------------------------------------------------------------------------------- | | `jobId` | string, required | Existing unpublished draft job id | | `patch` | object, required | Field patch keyed by OpenTrain field names, e.g. `{"experienceLevel": "INTERMEDIATE", "pricePerHour": 12}` | **Requires:** `jobs:write` + the `public_api_job_drafting` feature. **Wraps:** [`PATCH /job-drafts/{jobId}`](/docs/developers/api-reference/job-drafts/update) ### opentrain\_publish\_job Publish a draft live on the marketplace. Runs the same validation and moderation pipeline as the in-app publish flow and is subject to per-account daily publish limits. Returns the live job URL. | Parameter | Type | Description | | --------- | ---------------- | ----------------------------------- | | `jobId` | string, required | Unpublished draft job id to publish | **Requires:** `jobs:write` + the `public_api_job_publishing` feature. **Wraps:** [`POST /jobs/{id}/publish`](/docs/developers/api-reference/jobs/publish) ### opentrain\_update\_published\_job Update fields on a live published (`OPEN`) job. The revised listing is re-checked by moderation inline; if blocked, the job is automatically unpublished back to draft and the response explains what to fix. For unpublished drafts use `opentrain_update_job_draft_fields` instead. | Parameter | Type | Description | | --------- | ---------------- | -------------------------------------------------------------- | | `jobId` | string, required | Published (`OPEN`) job id | | `patch` | object, required | Field patch, same shape as `opentrain_update_job_draft_fields` | **Requires:** `jobs:write` + the `public_api_job_publishing` feature. **Wraps:** [`PATCH /jobs/{id}`](/docs/developers/api-reference/jobs/update) ### opentrain\_close\_job Close (archive) a published job so it stops accepting proposals and leaves public listings. Existing contracts are unaffected. Idempotent — closing an already-archived job reports `alreadyClosed`. | Parameter | Type | Description | | --------- | ---------------- | ------------------------- | | `jobId` | string, required | Published job id to close | **Requires:** `jobs:write` + the `public_api_job_publishing` feature. **Wraps:** [`POST /jobs/{id}/close`](/docs/developers/api-reference/jobs/close) ### opentrain\_list\_jobs List the account's own jobs (all statuses, newest first) with pagination. | Parameter | Type | Description | | --------- | ---------------- | ---------------------------------------------------------------------------------- | | `status` | string, optional | Filter: `DRAFT`, `OPEN`, `ONGOING`, `COMPLETED`, `ARCHIVED`, or `PENDING_APPROVAL` | | `cursor` | string, optional | Pagination cursor | | `limit` | number, optional | Max jobs to return, 1–100 (default 25) | **Requires:** `jobs:read`. **Wraps:** [`GET /jobs/mine`](/docs/developers/api-reference/jobs/mine) ### opentrain\_search\_jobs Search the public OpenTrain job marketplace — useful for calibrating rates and seeing how similar work is scoped before posting. | Parameter | Type | Description | | ---------- | ---------------- | ---------------------- | | `q` | string, optional | Free-text search query | | `category` | string, optional | Category filter | | `language` | string, optional | Language filter | | `country` | string, optional | ISO country code | | `payType` | string, optional | Payment type filter | | `limit` | number, optional | Max results, 1–50 | | `cursor` | string, optional | Pagination cursor | **Requires:** nothing — tokenless public read (120 requests/minute per IP). **Wraps:** [`GET /jobs`](/docs/developers/api-reference/jobs/search) ## Proposals and Candidates ### opentrain\_list\_proposals List proposals/candidates for a job with statuses, bids, AI interview scores, and resume match scores — the primary tool for reviewing and ranking who to hire. Use `opentrain_get_proposal` for one candidate in depth. | Parameter | Type | Description | | --------- | ---------------- | ------------------------------------------------------------------ | | `jobId` | string, required | Job id to list proposals for | | `status` | string, optional | Filter such as `UNREVIEWED`, `SHORTLISTED`, `HIRED`, or `DECLINED` | | `cursor` | string, optional | Pagination cursor | | `limit` | number, optional | Max proposals, 1–100 (default 25) | **Requires:** `proposals:read`; the job must be yours. **Wraps:** [`GET /jobs/{id}/proposals`](/docs/developers/api-reference/jobs/list-proposals) ### opentrain\_get\_proposal Read a full proposal/candidate evaluation: status, bid, masked candidate, AI-interview score and summary, location/identity verification, labeling assessment, and contract state. | Parameter | Type | Description | | ------------------ | ----------------- | ------------------------------------------------ | | `proposalId` | string, required | Proposal id to read | | `includeInterview` | boolean, optional | Also fetch the sanitized AI-interview transcript | **Requires:** `proposals:read`; the proposal must be on a job you own. **Wraps:** [`GET /proposals/{proposalId}`](/docs/developers/api-reference/proposals/get) (+ [`GET /proposals/{proposalId}/interview`](/docs/developers/api-reference/proposals/interview) when `includeInterview` is true) ### opentrain\_get\_freelancer\_profile Read a masked AI trainer profile for candidate evaluation: title, bio, skills, stats (earned, billed hours, job success), work and labeling experience, education, reviews, and languages. Names are masked to first name + last initial pre-hire, and personal contact details are never returned — see [Privacy and Work Email](/docs/developers/concepts/privacy-and-work-email). | Parameter | Type | Description | | ---------- | ---------------- | ----------------------------------------- | | `idOrSlug` | string, required | AI trainer user id or public profile slug | **Requires:** `proposals:read`. **Wraps:** [`GET /freelancers/{idOrSlug}`](/docs/developers/api-reference/freelancers/get) ### opentrain\_invite\_freelancer Invite an AI trainer to a published job, creating a proposal they can respond to. Idempotent: re-inviting the same person returns the existing proposal with `alreadyInvited: true`. | Parameter | Type | Description | | -------------- | ---------------- | ---------------------------- | | `jobId` | string, required | Published job id | | `freelancerId` | string, required | AI trainer user id to invite | **Requires:** `proposals:write` + the `public_api_hiring` feature + a **claimed** account. **Wraps:** [`POST /jobs/{id}/invites`](/docs/developers/api-reference/jobs/invite) ### opentrain\_hire\_proposal Request a hire from a proposal. **Never hires anyone or moves money** — records a pending approval (`type: "proposal_hire"`) and returns `202` with an `approvalUrl` a signed-in human must confirm in the OpenTrain app. On confirm, OpenTrain creates the contract and funds the first escrow milestone (the human picks card or credits). A `409` with `details.reason: "payment_method_required"` includes a `billingUrl`; `not_fit_confirmation_required` means retry with `confirmNotFitOverride: true` if intentional; `already_accepted` means the proposal was already hired. Re-requesting with the same terms returns the same pending approval. | Parameter | Type | Description | | ----------------------- | ----------------- | --------------------------------------------------------------------------------- | | `proposalId` | string, required | Proposal id to hire from | | `milestone` | object, required | First escrow milestone: `{name?, description?, amount (USD, required), dueDate?}` | | `confirmNotFitOverride` | boolean, optional | Confirm hiring a proposal previously marked "Not a fit" | **Requires:** `proposals:write` + the `public_api_hiring` feature + a **claimed** account + a payment method or covering [credit balance](/docs/developers/concepts/credits-and-billing). **Wraps:** [`POST /proposals/{proposalId}/hire`](/docs/developers/api-reference/proposals/hire) ## Messages ### opentrain\_read\_messages Read authorized conversation summaries or messages. Omit `conversationId` to list summaries; provide it to read messages. Never creates conversations or sends messages. | Parameter | Type | Description | | ---------------- | -------------------------------------- | ------------------------------------------------------------------ | | `conversationId` | string, optional | Conversation to read; omit to list summaries | | `cursor` | string, optional | Pagination cursor | | `limit` | number, optional | Max records, 1–100 | | `direction` | `older` \| `newer`, optional | Message pagination direction (with `conversationId`) | | `filter` | `all` \| `job` \| `proposal`, optional | Summary filter (without `conversationId`) | | `unreadOnly` | boolean, optional | Only conversations with unread messages (without `conversationId`) | **Requires:** `messages:read`; you only see conversations you participate in. **Wraps:** [`GET /messages`](/docs/developers/api-reference/messages/list) ### opentrain\_send\_message Send a plain-text message into an existing conversation the token owner participates in. Runs the same membership, rate-limit, and content-policy checks as the in-app messaging flow. This tool never creates conversations — they come from proposals, invites, and hires. | Parameter | Type | Description | | ---------------- | ---------------- | ------------------------------------- | | `conversationId` | string, required | Existing conversation id | | `content` | string, required | Plain-text message (max 10,000 chars) | **Requires:** `messages:write` + the `public_api_messaging_writes` feature + a **claimed** account. **Wraps:** [`POST /messages`](/docs/developers/api-reference/messages/send) ### opentrain\_start\_proposal\_conversation Start (or fetch) the pre-hire direct-message thread for a proposal so you can message the candidate before hiring. Idempotent get-or-create; returns the `conversationId` to use with `opentrain_send_message`. Employer side only. | Parameter | Type | Description | | ------------ | ---------------- | ---------------------------------------- | | `proposalId` | string, required | Proposal to open the pre-hire thread for | **Requires:** `messages:write` + the `public_api_messaging_writes` feature + a **claimed** account. **Wraps:** [`POST /proposals/{proposalId}/conversation`](/docs/developers/api-reference/proposals/conversation) ## Contracts and Milestones ### opentrain\_list\_contracts List contracts (hired AI trainers), each with milestones, the post-hire job DM `conversationId`, and the masked freelancer identity (first name + last initial). | Parameter | Type | Description | | --------- | ----------------------------- | ---------------------------- | | `jobId` | string, optional | Filter to one job | | `status` | `active` \| `ended`, optional | Status filter; omit for both | **Requires:** `payments:read`. **Wraps:** [`GET /contracts`](/docs/developers/api-reference/contracts/list) ### opentrain\_get\_contract Read one contract in detail: status, milestones with funding/approval state, AI trainer identity, the post-hire job DM `conversationId`, and the `budget` snapshot (funded vs consumed, state `OK`/`LOW`/`DEPLETED`). | Parameter | Type | Description | | ------------ | ---------------- | ------------------- | | `contractId` | string, required | Contract id to read | **Requires:** `payments:read`. **Wraps:** [`GET /contracts/{contractId}`](/docs/developers/api-reference/contracts/get) ### opentrain\_create\_milestone Create a new unfunded milestone on an existing contract. No money moves at creation — use `opentrain_request_milestone_funding` afterwards. | Parameter | Type | Description | | ------------- | ---------------- | ------------------------------------------------------ | | `contractId` | string, required | Contract to add the milestone to | | `description` | string, required | Description of the work to be delivered | | `name` | string, optional | Short milestone name | | `amountUsd` | number, optional | Milestone amount in USD | | `volume` | number, optional | Unit volume (e.g. label count) for per-unit milestones | | `dueDate` | string, optional | Due date (ISO 8601) | **Requires:** `payments:write` + the `public_api_payments_write` feature + a **claimed** account. **Wraps:** [`POST /contracts/{contractId}/milestones`](/docs/developers/api-reference/contracts/create-milestone) ### opentrain\_request\_milestone\_funding Request escrow funding for a milestone. This does **not** move money: it returns a pending approval with an `approvalUrl` that a signed-in human must open and confirm (expires in \~72h) — the [co-sign pattern](/docs/developers/concepts/human-approvals). Watch `opentrain_poll_updates` for `approval.confirmed`, or re-check with `opentrain_get_approval`. | Parameter | Type | Description | | ------------- | ---------------- | ----------------- | | `milestoneId` | string, required | Milestone to fund | **Requires:** `payments:write` + the `public_api_payments_write` feature + a **claimed** account + a payment method on file. **Wraps:** [`POST /milestones/{milestoneId}/fund`](/docs/developers/api-reference/milestones/fund) ### opentrain\_request\_milestone\_approval Request approval (payment release) of a funded milestone. Like funding, this only creates a pending human approval with an `approvalUrl` (\~72h expiry) — no money moves until the human confirms. | Parameter | Type | Description | | ------------- | ---------------- | --------------------------------------------- | | `milestoneId` | string, required | Funded (`ACTIVE_FUNDED`) milestone to release | **Requires:** `payments:write` + the `public_api_payments_write` feature + a **claimed** account. **Wraps:** [`POST /milestones/{milestoneId}/approve`](/docs/developers/api-reference/milestones/approve) ### opentrain\_get\_approval Check the status of a pending human co-sign approval (milestone funding, release, or contract end): `pending`, `confirmed`, `declined`, or `expired`, plus the execution result once confirmed. | Parameter | Type | Description | | ------------ | ---------------- | ------------------------------------------- | | `approvalId` | string, required | Approval id from a fund/approve/end request | **Requires:** `payments:read`. **Wraps:** [`GET /approvals/{approvalId}`](/docs/developers/api-reference/approvals/get) ### opentrain\_end\_contract End a contract. With no funded milestones it ends immediately; if funded escrow is at stake, the call instead returns a pending approval with an `approvalUrl` a human must confirm — no funds move until then. | Parameter | Type | Description | | ------------ | ---------------- | --------------- | | `contractId` | string, required | Contract to end | **Requires:** `payments:write` + the `public_api_payments_write` feature + a **claimed** account. **Wraps:** [`POST /contracts/{contractId}/end`](/docs/developers/api-reference/contracts/end) ## Credits ### opentrain\_get\_credits Read the prepaid credit balance: available, reserved (escrow holds), and total. No parameters. **Requires:** `payments:read` + the `public_api_credits` feature. **Wraps:** [`GET /credits`](/docs/developers/api-reference/credits/balance) ### opentrain\_list\_credit\_ledger Page through the credit ledger — top-ups, escrow holds, hold releases, captures, refunds, and adjustments, newest first. Each entry links the related top-up, proposal, contract, or milestone ids. | Parameter | Type | Description | | --------- | ---------------- | --------------------------------- | | `cursor` | string, optional | `nextCursor` from a previous page | | `limit` | number, optional | Page size, 1–100 (default 50) | **Requires:** `payments:read` + the `public_api_credits` feature. **Wraps:** [`GET /credits/ledger`](/docs/developers/api-reference/credits/ledger) ### opentrain\_create\_credit\_top\_up Start a credit top-up. No money moves from this call: it returns a Stripe Checkout `checkoutUrl` that a signed-in human must open and pay (expires in \~24h). Once paid, the balance updates automatically. | Parameter | Type | Description | | ----------- | ---------------- | --------------------------------------------- | | `amountUsd` | number, required | Top-up amount in USD (min \$10, max \$10,000) | **Requires:** `payments:write` + the `public_api_credits` feature + a **claimed** account. **Wraps:** [`POST /credits/top-ups`](/docs/developers/api-reference/credits/create-top-up) ### opentrain\_get\_credit\_top\_up Check a top-up's status: `pending` (awaiting human payment), `completed` (credits added), `canceled`, or `expired`. | Parameter | Type | Description | | --------- | ---------------- | ----------------------------------------------- | | `topUpId` | string, required | Top-up id from `opentrain_create_credit_top_up` | **Requires:** `payments:read` + the `public_api_credits` feature. **Wraps:** [`GET /credits/top-ups/{topUpId}`](/docs/developers/api-reference/credits/get-top-up) ## Updates ### opentrain\_poll\_updates Poll the delta feed of account events (new proposals, proposal status changes, new messages, contracts, milestone changes, pending payments, confirmed approvals, budget state changes) in one cheap call instead of re-reading every resource. Payloads carry IDs only — fetch details with the matching read tool. Event visibility follows the token's scopes. | Parameter | Type | Description | | --------- | ---------------- | ----------------------------------------------------------- | | `cursor` | string, optional | `nextCursor` from the previous poll; omit on the first poll | | `limit` | number, optional | Max events, 1–200 (default 50) | **Requires:** at least one of `proposals:read`, `messages:read`, `payments:read`. **Wraps:** [`GET /updates`](/docs/developers/api-reference/updates/poll) ## Webhooks ### opentrain\_create\_webhook Subscribe an HTTPS URL to platform events (push instead of polling). Deliveries are signed with HMAC-SHA256 — see [Verify Webhook Signatures](/docs/developers/guides/verify-webhook-signatures). The signing secret is returned **once** in this response; store it immediately. The subscription only receives events created after it. | Parameter | Type | Description | | ------------ | ------------------- | ----------------------------------------------------------------------------- | | `url` | string, required | HTTPS endpoint that will receive signed deliveries | | `eventTypes` | string\[], required | Event types to subscribe to, e.g. `["proposal.received", "message.received"]` | **Requires:** `webhooks:manage` + the `public_api_webhooks` feature + the matching read scope for each subscribed event type. **Wraps:** [`POST /webhooks`](/docs/developers/api-reference/webhooks/create) ### opentrain\_list\_webhooks List all webhook subscriptions with URL, event types, and status. Signing secrets are never included. No parameters. **Requires:** `webhooks:manage` + the `public_api_webhooks` feature. **Wraps:** [`GET /webhooks`](/docs/developers/api-reference/webhooks/list) ### opentrain\_get\_webhook Read one webhook subscription: URL, event types, status (`ACTIVE` or `DISABLED`), and failure/disable details. The signing secret is never returned — if lost, delete and re-create the subscription. | Parameter | Type | Description | | ----------- | ---------------- | ----------------------- | | `webhookId` | string, required | Webhook subscription id | **Requires:** `webhooks:manage` + the `public_api_webhooks` feature. **Wraps:** [`GET /webhooks/{webhookId}`](/docs/developers/api-reference/webhooks/get) ### opentrain\_delete\_webhook Delete a webhook subscription and stop its deliveries. This is also how a `DISABLED` subscription is resumed: delete it and create a new one (which mints a new secret). | Parameter | Type | Description | | ----------- | ---------------- | --------------------------------- | | `webhookId` | string, required | Webhook subscription id to delete | **Requires:** `webhooks:manage` + the `public_api_webhooks` feature. **Wraps:** [`DELETE /webhooks/{webhookId}`](/docs/developers/api-reference/webhooks/delete) ## Tokens ### opentrain\_list\_tokens List the account's API tokens with masked previews, scopes, status, and last-used timestamps — secrets are never shown. No parameters. **Requires:** any valid token — token management needs no specific scope or feature flag. **Wraps:** [`GET /tokens`](/docs/developers/api-reference/tokens/list) ### opentrain\_revoke\_token Revoke an API token by id. Irreversible — the token stops working immediately. <Warning> Revoking the token the MCP server itself is using breaks every subsequent call. Mint a replacement over HTTP with [`POST /tokens`](/docs/developers/api-reference/tokens/create) and switch to it first. </Warning> | Parameter | Type | Description | | --------- | ---------------- | ------------------------- | | `tokenId` | string, required | Id of the token to revoke | **Requires:** any valid token — token management needs no specific scope or feature flag. **Wraps:** [`DELETE /tokens/{tokenId}`](/docs/developers/api-reference/tokens/revoke) ## Team ### opentrain\_get\_team Read the employer team: organization, members with roles, and pending email invites. No parameters. **Requires:** `team:read` + the `public_api_team` feature. **Wraps:** [`GET /team`](/docs/developers/api-reference/team/list) ### opentrain\_invite\_team\_member Invite a human to the employer team by email, giving them shared access to jobs and the team inbox once they accept. Inviting an existing member returns `already_member`; users with existing accounts are added directly (`member_added`). | Parameter | Type | Description | | --------- | ---------------- | ---------------------------- | | `email` | string, required | Email of the human to invite | **Requires:** `team:write` + the `public_api_team` feature + a **claimed** account. **Wraps:** [`POST /team/invites`](/docs/developers/api-reference/team/invite) ## Payments ### opentrain\_list\_pending\_payments List pending payments on the account — read-only; never releases funds. No parameters. **Requires:** `payments:read`. **Wraps:** [`GET /payments/pending`](/docs/developers/api-reference/payments/pending)