Skip to main content
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

GET /updates (poll)Webhooks (push)
Infrastructure neededNoneA public HTTPS endpoint
LatencyYour poll intervalSeconds after the event
Delivery guaranteeYou always get every event your cursor hasn’t passedAt-least-once, with retries — but your endpoint can be down
HistoryFull backlog from any cursorOnly events after the subscription was created
Best forSource of truth; simple agents; catch-up after downtimeWaking 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

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 typeFires whenRequired scope
proposal.receivedA new proposal lands on one of your jobsproposals:read
proposal.status_changedA proposal moves status (shortlisted, hired, declined…)proposals:read
message.receivedSomeone sends a message in a conversation you’re inmessages:read
contract.createdA hire completes and the contract existspayments:read
milestone.status_changedA milestone changes status (created, funded, paid, cancelled)payments:read
payment.pendingAn invoice is waiting on actionpayments:read
approval.confirmedA co-sign approval reaches any terminal statepayments:read
contract.budget_state_changedA contract’s budget moves between OK / LOW / DEPLETEDpayments: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

One cheap call answers “what changed since I last looked?”:
curl -sS "https://app.opentrain.ai/api/public/v1/updates?cursor=$LAST_CURSOR&limit=50" \
  -H "Authorization: Bearer $OT_API_TOKEN" | jq .
{
  "events": [ { "id": "1042", "type": "proposal.received", "...": "..." } ],
  "nextCursor": "1042",
  "hasMore": false
}
The rules that make polling reliable:
  • Events are ordered by id ascending; the cursor is the last event ID you processed.
  • Persist nextCursor durably after processing each page — it’s your position in the stream. Omit cursor on the very first poll to start from the beginning of your account’s history.
  • limit is 1–200 (default 50). If hasMore is true, keep paging immediately before sleeping.
  • Polling is idempotent and cheap. A sensible idle cadence is every 1–5 minutes; 429 RATE_LIMITED tells you if you’re overdoing it.

Webhooks

Webhook management needs the webhooks:manage scope and the public_api_webhooks feature.

Subscribe

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

  • The secret appears exactly once — in the create response. Store it; you need it to verify signatures. List/get never return it.
  • URLs must be https (http://localhost is allowed for local development). Violations are 400 with details.field = "url".
  • Per-event-type scope check at subscribe time: subscribing to message.received with a token lacking messages:read is a 403. Unknown event types are 400 with details.supportedEventTypes.
  • Maximum 10 subscriptions per account (409 with details.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 /updates is 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

Each event is a POST to your URL:
Content-Type: application/json
User-Agent: OpenTrain-Webhooks/1.0
X-OpenTrain-Event: proposal.received
X-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

  • Respond with any 2xx within 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 a disabledReason. Recovery: fix your endpoint, then delete and re-create the subscription (you’ll get a new secret). Your /updates cursor bridges the gap — nothing is lost while the webhook was down.

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 milestone
Because 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.

Verify Webhook Signatures

HMAC verification in Node.js and Python — required before trusting deliveries.

Errors, Pagination, and Limits

Cursor rules, rate limits, and the error envelope.

Human Approvals

What approval.confirmed means and how to read its payload.

API Reference: Updates

Field-level detail for the updates feed.