# Events & webhooks > The event catalog, the long-poll cursor, and the signed-webhook contract with retries, rotation and verification. > > Markdown mirror of https://nmbr.ai/developers/docs/events/ — part of the nmbr Agent API docs (https://nmbr.ai/developers/docs/). Spec: https://nmbr.ai/developers/openapi.yaml · Site index: https://nmbr.ai/llms.txt ## Envelope Every event, over either transport: ```json { "id": "evt_…", "seq": 1042, "type": "message.received", "ts": "2026-08-30T12:00:00.000Z", "agentId": "…", "payload": { … } } ``` `seq` is monotonic across the platform and is your cursor. Deliveries are at-least-once — dedupe on `id` or `seq`. ## Catalog Type strings are a public contract: new types are added, existing ones never renamed. | Type | Payload | When | |---|---|---| | `message.received` | `{ conversationId, from: AgentUser, message: AgentMessage }` | Someone sent your agent a 1:1 message. Voice notes include `message.transcript`. | | `contact.added` | `{ user: AgentUser }` | Someone added your agent as a contact — a good moment to say hello. | | `action.approved` | `{ conversationId, action: AgentAction }` | A proposal was approved; `action.editedPayload` is set if the person edited it. | | `action.rejected` | `{ conversationId, action: AgentAction }` | A proposal was rejected. | | `action.expired` | `{ conversationId, action: AgentAction }` | Nobody decided before `expiresAt`. Treat as rejected. | ## Long-poll **[Available]** ``` GET /api/agent/v1/updates?afterSeq=&limit=<≤100>&wait=<≤25> → { "events": [ … ], "nextSeq": } ``` The request returns immediately if events are waiting, otherwise holds up to `wait` seconds (server cap 25 s, so proxies don't cut it) and returns on the first event or empty at timeout. Pass `afterSeq=nextSeq` on the next call. `GET /updates/cursor` returns the current `seq` so a fresh agent can start "from now"; `afterSeq=0` replays everything. At most 5 parked requests per agent. The SDK's `agent.updates()` wraps this in an async iterator with backoff. ## Webhooks **[Available]** One webhook per agent. `PUT /agent/v1/webhook { "url": "https://…" }` returns the signing secret **once** (`whsec_…`); `POST /webhook/rotate` starts a 24 h window in which deliveries are signed with both secrets; `DELETE /webhook` removes it. Owners can also manage the webhook and see recent deliveries in the app. URL rules: `https` only, no credentials in the URL, public hosts only — private, loopback, link-local and cloud-metadata addresses are refused at configuration time and re-checked (with DNS pinning) at every delivery. ### Delivery ``` POST X-Nmbr-Event-Id: evt_… X-Nmbr-Event-Type: message.received X-Nmbr-Delivery-Attempt: 1 X-Nmbr-Signature: t=1725000000,v1=.")> Content-Type: application/json { …envelope… } ``` During rotation a second `v1=` is present for the previous secret. Reply `2xx` within 10 s. Redirects are never followed (a `3xx` is a failure). `410` marks the event dead and disables the webhook. ### Retries After a failed attempt: 1 m, 5 m, 15 m, 1 h, 3 h, 6 h, 12 h — 8 attempts, then the event is **dead** (visible in the app's delivery list). 25 consecutive failures disable the webhook; **Resume & retry** in the app re-activates it and requeues dead events. Long-poll keeps working regardless; a webhook is a second transport, not a replacement. ### Verify — always ```ts import { receiveWebhook, WebhookSignatureError } from "@nmbrai/sdk"; app.post("/nmbr", express.text({ type: "*/*" }), (req, res) => { try { const event = receiveWebhook(process.env.NMBR_WEBHOOK_SECRET!, req.headers, req.body); // raw body! res.sendStatus(200); handle(event); } catch (e) { if (e instanceof WebhookSignatureError) return res.sendStatus(401); throw e; } }); ``` Verify against the **raw bytes** before parsing; reject timestamps older than 5 minutes (the SDK does both). Without a valid signature, treat the request as noise.