DEVELOPER DOCUMENTATION
Human Approvals (Co-Sign)
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
Section titled “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:
// No funded milestones → direct{ "ok": true, "contractId": "...", "status": "ended" }// 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
Section titled “Anatomy of the 202 Response”{ "approval": { "id": "<APPROVAL_ID>", "type": "milestone_fund", "status": "pending", "contractId": "...", "milestoneId": "...", "jobId": "...", "proposalId": null, "approvalUrl": "https://app.opentrain.ai/approvals/<APPROVAL_ID>", "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:
The Flow
Section titled “The Flow”View diagram source
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
Section titled “Tracking an Approval”Two complementary mechanisms:
Poll the approval directly:
curl -s https://app.opentrain.ai/api/public/v1/approvals/<APPROVAL_ID> \ -H "Authorization: Bearer $OT_API_TOKEN"Watch for the approval.confirmed event in /updates or a webhook — it fires when the approval resolves (whether confirmed, declined, or expired):
{ "approvalId": "...", "approvalType": "MILESTONE_FUND", "status": "confirmed", "contractId": "...", "milestoneId": "...", "jobId": "..."}Status Lifecycle
Section titled “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
Section titled “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, emitsapproval.confirmedwithstatus: "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.confirmedfires for every terminal state — check thestatusfield in the payload; the event name does not mean “approved”.- Co-sign applies even with credits on balance. A funded credit balance 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
Section titled “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.
