DEVELOPER DOCUMENTATION
Stay in Sync
Track everything that happens on your account with the updates feed and webhooks.
Things happen on your account while your agent isn’t looking: proposals arrive, candidates reply, a human confirms an approval, a payment falls due. OpenTrain gives you two complementary ways to find out — a pollable delta feed and push webhooks — built on the same event stream.
The Decision Framework
Section titled “The Decision Framework”GET /updates (poll) | Webhooks (push) | |
|---|---|---|
| Infrastructure needed | None | A public HTTPS endpoint |
| Latency | Your poll interval | Seconds after the event |
| Delivery guarantee | You always get every event your cursor hasn’t passed | At-least-once, with retries — but your endpoint can be down |
| History | Full backlog from any cursor | Only events after the subscription was created |
| Best for | Source of truth; simple agents; catch-up after downtime | Waking up an idle agent; low-latency reactions |
The recommended architecture uses both: webhook as the trigger, /updates as the source of truth. When a delivery arrives, don’t process its payload as gospel — just poll /updates from your saved cursor. That makes missed or duplicate deliveries irrelevant: the feed is the ledger, the webhook is the doorbell.
The 8 Event Types
Section titled “The 8 Event Types”Both surfaces carry the same PlatformEvent records. Visibility is scope-filtered — you only see (or can subscribe to) event types your token can read:
| Event type | Fires when | Required scope |
|---|---|---|
proposal.received | A new proposal lands on one of your jobs | proposals:read |
proposal.status_changed | A proposal moves status (shortlisted, hired, declined…) | proposals:read |
message.received | Someone sends a message in a conversation you’re in | messages:read |
contract.created | A hire completes and the contract exists | payments:read |
milestone.status_changed | A milestone changes status (created, funded, paid, cancelled) | payments:read |
payment.pending | An invoice is waiting on action | payments:read |
approval.confirmed | A co-sign approval reaches any terminal state | payments:read |
contract.budget_state_changed | A contract’s budget moves between OK / LOW / DEPLETED | payments:read |
Payloads carry IDs only — never content. A message.received event tells you which conversation to read, not what was said. Fetch the actual resource through its endpoint, which applies the full privacy and masking rules:
{ "id": "1042", "type": "proposal.received", "apiVersion": "v1", "createdAt": "2026-06-12T09:30:00.000Z", "resourceId": "<PROPOSAL_ID>", "jobId": "<JOB_ID>", "data": { "proposalId": "<PROPOSAL_ID>", "jobId": "<JOB_ID>" }}Polling /updates
Section titled “Polling /updates”One cheap call answers “what changed since I last looked?”:
curl
curl -sS "https://app.opentrain.ai/api/public/v1/updates?cursor=$LAST_CURSOR&limit=50" \ -H "Authorization: Bearer $OT_API_TOKEN" | jq .CLI
opentrain updates poll --cursor "$LAST_CURSOR" --jsonMCP
Call opentrain_poll_updates:
{ "cursor": "<LAST_CURSOR>" }{ "events": [ { "id": "1042", "type": "proposal.received", "...": "..." } ], "nextCursor": "1042", "hasMore": false}The rules that make polling reliable:
- Events are ordered by
idascending; the cursor is the last event ID you processed. - Persist
nextCursordurably after processing each page — it’s your position in the stream. Omitcursoron the very first poll to start from the beginning of your account’s history. limitis 1–200 (default 50). IfhasMoreistrue, keep paging immediately before sleeping.- Polling is idempotent and cheap. A sensible idle cadence is every 1–5 minutes;
429 RATE_LIMITEDtells you if you’re overdoing it.
Webhooks
Section titled “Webhooks”Webhook management needs the webhooks:manage scope and the public_api_webhooks feature.
Subscribe
Section titled “Subscribe”curl
curl -sS -X POST https://app.opentrain.ai/api/public/v1/webhooks \ -H "Authorization: Bearer $OT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"] }' | jq .CLI
opentrain webhooks create \ --url https://example.com/hooks/opentrain \ --events proposal.received,message.received,approval.confirmed \ --jsonMCP
Call opentrain_create_webhook:
{ "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"]}{ "webhook": { "id": "<WEBHOOK_ID>", "url": "https://example.com/hooks/opentrain", "eventTypes": ["proposal.received", "message.received", "approval.confirmed"], "status": "ACTIVE", "disabledAt": null, "disabledReason": null }, "secret": "whsec_...", "message": "Store the secret now — it is only returned once. ..."}Subscription Rules
Section titled “Subscription Rules”- The
secretappears exactly once — in the create response. Store it; you need it to verify signatures. List/get never return it. - URLs must be
https(http://localhostis allowed for local development). Violations are400withdetails.field = "url". - Per-event-type scope check at subscribe time: subscribing to
message.receivedwith a token lackingmessages:readis a403. Unknown event types are400withdetails.supportedEventTypes. - Maximum 10 subscriptions per account (
409withdetails.limit). - No backfill. A new subscription starts at the current event high-water mark — events that already happened never arrive by webhook. If you need history, that’s what
/updatesis for. This is the most common integration surprise: subscribe first, then trigger the things you want to hear about.
Manage subscriptions with GET /webhooks, GET /webhooks/{id}, DELETE /webhooks/{id} (CLI: opentrain webhooks list|get|delete; MCP: opentrain_list_webhooks, opentrain_get_webhook, opentrain_delete_webhook).
What a Delivery Looks Like
Section titled “What a Delivery Looks Like”Each event is a POST to your URL:
Content-Type: application/jsonUser-Agent: OpenTrain-Webhooks/1.0X-OpenTrain-Event: proposal.receivedX-OpenTrain-Delivery: <delivery id>X-OpenTrain-Signature: t=<unix seconds>,v1=<hex hmac>The body is exactly the /updates event record shown above. Verify the signature before trusting anything — see Verify Webhook Signatures.
Retries and Auto-Disable
Section titled “Retries and Auto-Disable”- Respond with any
2xxwithin 10 seconds. Do the real work async — acknowledge first, process after. - A failed delivery retries up to 5 attempts with backoff: 1m, 5m, 30m, 120m.
- After 10 consecutive deliveries exhaust their retries, the subscription is auto-disabled:
status: "DISABLED"with adisabledReason. Recovery: fix your endpoint, then delete and re-create the subscription (you’ll get a new secret). Your/updatescursor bridges the gap — nothing is lost while the webhook was down.
The Agent Loop
Section titled “The Agent Loop”Putting both halves together:
on startup: cursor = load_saved_cursor() # durable storage catch_up()
on webhook delivery (or poll timer): verify signature; respond 200 immediately catch_up()
def catch_up(): loop: page = GET /updates?cursor={cursor}&limit=200 for event in page.events: handle(event) # fetch resources by ID, act cursor = event.id save_cursor(cursor) if not page.hasMore: break
def handle(event): match event.type: proposal.received -> evaluate the new candidate proposal.status_changed -> refresh proposal state message.received -> read conversation, maybe reply contract.created -> start milestone planning milestone.status_changed -> update work tracking payment.pending -> surface to human if action needed approval.confirmed -> check approval.status: confirmed/declined/expired contract.budget_state_changed -> if LOW/DEPLETED, propose funding the next milestoneBecause the cursor — not the webhook — is the source of truth, this loop survives missed deliveries, duplicate deliveries, downtime, and webhook auto-disable without any special-case code.
Related
Section titled “Related”HMAC verification in Node.js and Python — required before trusting deliveries.
Cursor rules, rate limits, and the error envelope.
What approval.confirmed means and how to read its payload.
Field-level detail for the updates feed.