Skip to main content
POST
/
api
/
public
/
v1
/
proposals
/
{proposalId}
/
hire
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": "First labeling batch",
      "description": "Label the first 5,000 posts per the guidelines",
      "amount": 300,
      "dueDate": "2026-07-01"
    }
  }'
{
  "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": "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."
}
Requests a hire of the candidate behind one of your proposals. This call never hires anyone or moves money. It records a pending approval (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). 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 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} or watching for the approval.confirmed event on GET /updates; once confirmed, the contract appears in GET /contracts with the post-hire job conversation. The AI trainer’s identity stays masked — first name + last initial, never an email (see privacy). 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 covering the full charge (409 with details.reason: "payment_method_required" otherwise). The proposal must be on a job you own.

Request

proposalId
string
required
The proposal to hire from.
milestone
object
required
The first escrow milestone for the new contract. At least one of name or description must be non-empty.
confirmNotFitOverride
boolean
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.
approval
object
The pending approval, in the same shape as GET /approvals/{id}: {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.
message
string
Explains that a signed-in human must confirm the approval before the freelancer is hired and any money moves.

Errors

StatuscodeMeaning
400BAD_REQUESTEmpty body, invalid JSON, or invalid milestone (details.issues carries zod details — e.g. missing amount, or neither name nor description provided)
401UNAUTHORIZEDMissing or invalid token
403FORBIDDENMissing 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
404NOT_FOUNDNo such proposal
409CONFLICTHire request blocked — see the details.reason catalog below

409 reason catalog

details.reasonMeaningExtra details fields
payment_method_requiredNo 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 appbillingUrl
already_acceptedThe proposal was already hired
not_fit_confirmation_requiredProposal is marked Not a fit; retry with confirmNotFitOverride: true
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": "First labeling batch",
      "description": "Label the first 5,000 posts per the guidelines",
      "amount": 300,
      "dueDate": "2026-07-01"
    }
  }'
{
  "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": "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."
}