DEVELOPER DOCUMENTATION
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, and every action that hires a person or moves money pauses for a human co-sign — 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.
View diagram source
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 200Preconditions Checklist
Section titled “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 | 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)
Section titled “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) on the confirmation screen.
curl
curl -sS -X POST https://app.opentrain.ai/api/public/v1/proposals/<PROPOSAL_ID>/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 .CLI
opentrain proposals hire --proposal-id "<PROPOSAL_ID>" \ --amount 450 \ --milestone-name "Batch 1" \ --milestone-description "First 10k dashcam images with bounding boxes" \ --due-date 2026-07-15 \ --jsonMCP
Call opentrain_hire_proposal:
{ "proposalId": "<PROPOSAL_ID>", "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:
{ "approval": { "id": "<APPROVAL_ID>", "type": "proposal_hire", "status": "pending", "contractId": null, "milestoneId": null, "jobId": "<JOB_ID>", "proposalId": "<PROPOSAL_ID>", "approvalUrl": "https://app.opentrain.ai/approvals/<APPROVAL_ID>", "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)
Section titled “Hire Request Conflicts (409)”All hire-time conflicts use the standard error envelope 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
Section titled “Step 2: Read the Contract”Once the human confirms, pull contractId from the approval’s result (poll GET /approvals/{id} or watch the approval.confirmed event), then read the contract:
curl
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/<CONTRACT_ID> \ -H "Authorization: Bearer $OT_API_TOKEN" | jq .CLI
opentrain contracts list --status active --jsonopentrain contracts get --contract-id "<CONTRACT_ID>" --jsonMCP
Call opentrain_list_contracts (optional jobId, status), then opentrain_get_contract with { "contractId": "<CONTRACT_ID>" }.
{ "contract": { "id": "<CONTRACT_ID>", "status": "active", "title": "Image Segmentation Contract", "jobId": "<JOB_ID>", "proposalId": "<PROPOSAL_ID>", "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": "<CONVERSATION_ID>" }}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).
jobDmConversationIdis the post-hire 1:1 thread with your new AI trainer. Use it directly withGET/POST /messages— no separate “create conversation” step.
Step 3: Add Milestones (Direct — No Money Moves)
Section titled “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:
curl
curl -sS -X POST https://app.opentrain.ai/api/public/v1/contracts/<CONTRACT_ID>/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 .CLI
opentrain milestones create --contract-id "<CONTRACT_ID>" \ --name "Batch 2" \ --description "Batch 2: 5k bounding boxes, night-time footage" \ --amount 250 \ --jsonMCP
Call opentrain_create_milestone:
{ "contractId": "<CONTRACT_ID>", "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)
Section titled “Step 4: Fund a Milestone (Co-Signed)”Funding moves money into escrow, so it returns 202 with a pending approval instead of executing:
curl
curl -sS -X POST https://app.opentrain.ai/api/public/v1/milestones/<MILESTONE_ID>/fund \ -H "Authorization: Bearer $OT_API_TOKEN" | jq .CLI
opentrain milestones fund --milestone-id "<MILESTONE_ID>" --jsonMCP
Call opentrain_request_milestone_funding with { "milestoneId": "<MILESTONE_ID>" }.
{ "approval": { "id": "<APPROVAL_ID>", "type": "milestone_fund", "status": "pending", "approvalUrl": "https://app.opentrain.ai/approvals/<APPROVAL_ID>", "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.
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)
Section titled “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):
curl
curl -sS -X POST https://app.opentrain.ai/api/public/v1/milestones/<MILESTONE_ID>/approve \ -H "Authorization: Bearer $OT_API_TOKEN" | jq .CLI
opentrain milestones approve --milestone-id "<MILESTONE_ID>" --jsonMCP
Call opentrain_request_milestone_approval with { "milestoneId": "<MILESTONE_ID>" }.
Tracking Either Approval to Completion
Section titled “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):
curl
curl -sS https://app.opentrain.ai/api/public/v1/approvals/<APPROVAL_ID> \ -H "Authorization: Bearer $OT_API_TOKEN" | jq '.approval | {status, result}'CLI
opentrain approvals get --approval-id "<APPROVAL_ID>" --jsonMCP
Call opentrain_get_approval with { "approvalId": "<APPROVAL_ID>" }.
On confirmation, result carries execution evidence (invoiceId, paymentIntentId / payoutTransactionId). Pending invoices also appear in GET /payments/pending.
Step 6: End the Contract
Section titled “Step 6: End the Contract”Ending is dual-mode — it only needs a co-sign when funded milestones are at stake:
curl
curl -sS -X POST https://app.opentrain.ai/api/public/v1/contracts/<CONTRACT_ID>/end \ -H "Authorization: Bearer $OT_API_TOKEN" | jq .CLI
opentrain contracts end --contract-id "<CONTRACT_ID>" --jsonMCP
Call opentrain_end_contract with { "contractId": "<CONTRACT_ID>" }.
- No funded milestones → executes directly:
200 {"ok": true, "contractId": "...", "status": "ended"}. - Funded milestones exist →
202with an approval of typecontract_end— same human-confirm flow as funding, because ending decides what happens to escrowed money.
Related
Section titled “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.