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

# 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

|                       | `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

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](/docs/developers/concepts/human-approvals) reaches any terminal state                  | `payments:read`  |
| `contract.budget_state_changed` | A contract's [budget](/docs/developers/api-reference/contracts/get) 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:

```json theme={null}
{
  "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?":

<Tabs>
  <Tab title="curl">
    ```bash theme={null}
    curl -sS "https://app.opentrain.ai/api/public/v1/updates?cursor=$LAST_CURSOR&limit=50" \
      -H "Authorization: Bearer $OT_API_TOKEN" | jq .
    ```
  </Tab>

  <Tab title="CLI">
    ```bash theme={null}
    opentrain updates poll --cursor "$LAST_CURSOR" --json
    ```
  </Tab>

  <Tab title="MCP">
    Call `opentrain_poll_updates`:

    ```json theme={null}
    { "cursor": "<LAST_CURSOR>" }
    ```
  </Tab>
</Tabs>

```json theme={null}
{
  "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

<Tabs>
  <Tab title="curl">
    ```bash theme={null}
    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 .
    ```
  </Tab>

  <Tab title="CLI">
    ```bash theme={null}
    opentrain webhooks create \
      --url https://example.com/hooks/opentrain \
      --events proposal.received,message.received,approval.confirmed \
      --json
    ```
  </Tab>

  <Tab title="MCP">
    Call `opentrain_create_webhook`:

    ```json theme={null}
    {
      "url": "https://example.com/hooks/opentrain",
      "eventTypes": ["proposal.received", "message.received", "approval.confirmed"]
    }
    ```
  </Tab>
</Tabs>

```json theme={null}
{
  "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](/docs/developers/guides/verify-webhook-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:

```text theme={null}
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](/docs/developers/guides/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:

```text theme={null}
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.

## Related

<CardGroup cols={2}>
  <Card title="Verify Webhook Signatures" href="/docs/developers/guides/verify-webhook-signatures" icon="shield-check">
    HMAC verification in Node.js and Python — required before trusting deliveries.
  </Card>

  <Card title="Errors, Pagination, and Limits" href="/docs/developers/concepts/errors-pagination-limits" icon="triangle-alert">
    Cursor rules, rate limits, and the error envelope.
  </Card>

  <Card title="Human Approvals" href="/docs/developers/concepts/human-approvals" icon="user-check">
    What approval.confirmed means and how to read its payload.
  </Card>

  <Card title="API Reference: Updates" href="/docs/developers/api-reference/updates/poll" icon="file-code">
    Field-level detail for the updates feed.
  </Card>
</CardGroup>
