Skip to content
OpenTrain AIOpenTrain AIOpenTrain AIDocs

Ask OpenTrain

Answers from the documentation, with sources.

What would you like to do with OpenTrain?

AI answers can be mistaken. Check the linked sources. Don’t include private account information.

Open app

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.

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.

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>" }
}

One cheap call answers “what changed since I last looked?”:

curl

Terminal window
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.

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

curl

Terminal window
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. ..."
}
  • 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).

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.

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

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.