> ## Documentation Index
> Fetch the complete documentation index at: https://opentrain.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Approve Milestone

> 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

<ParamField path="milestoneId" type="string" required>
  The milestone to release payment for. Must be `ACTIVE_FUNDED`, not cancelled, on an active contract you own.
</ParamField>

## Response

Returns `202` — the request is recorded, nothing has been released yet.

<ResponseField name="approval" type="object">
  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}`.
</ResponseField>

<ResponseField name="message" type="string">
  Explains that a signed-in human must confirm the approval before any money moves.
</ResponseField>

## 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`           |

<RequestExample>
  ```bash curl theme={null}
  curl -sS -X POST https://app.opentrain.ai/api/public/v1/milestones/<MILESTONE_ID>/approve \
    -H "Authorization: Bearer $OT_API_TOKEN"
  ```

  ```bash CLI theme={null}
  opentrain milestones approve --milestone-id <MILESTONE_ID> --json
  ```

  ```json MCP: opentrain_request_milestone_approval theme={null}
  {
    "milestoneId": "<MILESTONE_ID>"
  }
  ```
</RequestExample>

<ResponseExample>
  ```json 202 theme={null}
  {
    "approval": {
      "id": "<APPROVAL_ID>",
      "type": "milestone_approve",
      "status": "pending",
      "contractId": "<CONTRACT_ID>",
      "milestoneId": "<MILESTONE_ID>",
      "jobId": "<JOB_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": "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": "<REQUEST_ID>",
    "details": {
      "milestoneId": "<MILESTONE_ID>",
      "status": "NOT_FUNDED",
      "reason": "milestone_not_funded"
    }
  }
  ```
</ResponseExample>
