Skip to main content
The platform integration emits eight event types. They are delivered as signed POSTs to your webhook endpoints and answer two questions: who should have access to your workspace right now, and is there funded budget for the work?
EventFires whenWhat you should do
contract.startedThe employer hires an AI trainer on a linked jobProvision the AI trainer in your workspace (by Work Email)
contract.endedThe contract ends (a human co-signed action in OpenTrain)Remove the AI trainer’s access
project_link.createdA project link is created on your installRecord the mapping; optionally reconcile existing contracts
project_link.removedA project link is deleted (permanent)Stop associating that job’s contracts with your project
install.revokedThe customer disconnects (or reconnects) your appCease work for that customer; mark the connection disconnected
milestone.fundedThe employer funds a milestone on a linked contractClear budget warnings; work can continue
milestone.budget_lowBudget consumption crosses 80% of funded volumeWarn on the project; the employer is nudged to fund more
milestone.budget_depletedBudget 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:
{
  "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 before trusting any of it.

Contract Events

contract.started and contract.ended share one payload shape:
{
  "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. If workEmail is missing, you can retry via GET /contracts/{contractId}/participants (same consent gates apply) or skip provisioning and surface the gap to the customer. project_link.created and project_link.removed carry the link (for removed, a final snapshot — the row is hard-deleted):
{
  "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

{
  "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.

Milestone Budget Events

milestone.funded, milestone.budget_low, and milestone.budget_depleted close the funding loop for platforms that report usage. All three share one payload shape — the contract, the active funded milestone (or null), the full budget object, and the project link:
{
  "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 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 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.
  • Resolve details by pulling. On any contract event you can re-fetch participants for the authoritative current state rather than relying solely on the payload snapshot.
The reference integration shows this pattern: webhook-driven provisioning with a participants-endpoint fallback.