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

# 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": "<EVENT_ID>",
  "type": "contract.started",
  "apiVersion": "v1",
  "createdAt": "2026-06-12T10:00:00.000Z",
  "resourceId": "<CONTRACT_ID>",
  "jobId": "<OPENTRAIN_JOB_ID>",
  "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": "<CONTRACT_ID>",
    "status": "active",
    "jobId": "<OPENTRAIN_JOB_ID>",
    "title": "Traffic sign annotation",
    "startDate": "2026-06-12T10:00:00.000Z",
    "endDate": null
  },
  "projectLink": {
    "id": "<LINK_ID>",
    "jobId": "<OPENTRAIN_JOB_ID>",
    "externalProjectId": "42",
    "externalProjectName": "Traffic signs batch 3",
    "externalProjectUrl": "https://your-platform.example.com/projects/42",
    "provisioningMode": "PARTNER_WEBHOOK"
  },
  "freelancer": {
    "opentrainUserId": "<USER_ID>",
    "displayName": "Maria S.",
    "country": "Philippines",
    "profileUrl": "https://app.opentrain.ai/profile/<SLUG>",
    "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": "<LINK_ID>",
    "jobId": "<OPENTRAIN_JOB_ID>",
    "externalProjectId": "42",
    "externalProjectName": "Traffic signs batch 3",
    "externalProjectUrl": "https://your-platform.example.com/projects/42",
    "provisioningMode": "PARTNER_WEBHOOK"
  }
}
```

## Install Revoked

```json theme={null}
{
  "installId": "<INSTALL_ID>",
  "partnerAppId": "<APP_ID>",
  "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": "<CONTRACT_ID>",
    "status": "active",
    "jobId": "<OPENTRAIN_JOB_ID>",
    "title": "Traffic sign annotation"
  },
  "milestone": {
    "id": "<MILESTONE_ID>",
    "name": "Week 2",
    "amountUsd": 280,
    "volume": 20,
    "status": "ACTIVE_FUNDED"
  },
  "budget": {
    "contractId": "<CONTRACT_ID>",
    "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": "<MILESTONE_ID>", "name": "Week 2", "amountUsd": 280, "volume": 20, "status": "ACTIVE_FUNDED" },
    "lastUsageAt": "2026-06-12T18:00:00.000Z"
  },
  "projectLink": {
    "id": "<LINK_ID>",
    "jobId": "<OPENTRAIN_JOB_ID>",
    "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.
