Events & webhooks

The event catalog, the long-poll cursor, and the signed-webhook contract with retries, rotation and verification.

Envelope

Every event, over either transport:

{ "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 | null, accessDenied? } Someone sent your agent a 1:1 message. A voice note carries message.audio and, a moment later, its words in a separate message.transcript event. If the sender didn't grant messages:read, message is null and accessDenied: { scope: "messages:read", message } explains — the event still fires so you know they wrote; don't treat it as empty.
contact.added { user: AgentUser, scopes: AgentScope[] } Someone added your agent as a contact — a good moment to say hello. scopes is what they granted on the consent card; it's what every later call from your agent toward them is checked against.
action.approved { conversationId, action: AgentAction } A proposal was approved; action.editedPayload is set if the person edited it. For a skill proposal, action.execution says whether nmbr created the record (status, recordType, recordId or error, auto). Not sent when the person auto-executes the skill — the propose response is already resolved then.
action.rejected { conversationId, action: AgentAction } A proposal was rejected.
action.expired { conversationId, action: AgentAction } Nobody decided before expiresAt. Treat as rejected.
email.received { threadId, subject, from: AgentUser, email: AgentEmailMessage | null, accessDenied? } Someone emailed your agent (new thread or reply). Masked exactly like message.received when they didn't grant emails:read (subject is null too, unless your agent started the thread). See Email.
task.due { user: AgentUser, task: AgentTask } A task of a person who granted tasks:read came due — at the same moment their own push goes out. Sent only with the grant.
reminder.due { user: AgentUser, reminder: AgentReminder } Same, for a reminder (reminders:read).
message.transcript `{ conversationId, messageId, from: AgentUser, transcript \ null, accessDenied? }`
call.ended { callId, user, participants, isGroup, startedAt, endedAt, duration, summary | null, transcript | null, transcriptTruncated } A call of a person who granted calls:read ended and their summary is ready. summary is that person's own; transcript is shared, capped at 32 KB. Sent only with the grant. See Calls.

Long-poll Available

GET /api/agent/v1/updates?afterSeq=<seq>&limit=<≤100>&wait=<≤25>
→ { "events": [ … ], "nextSeq": <seq> }

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.

Stream Available

wss://nmbr.ai/ws/agent/v1/stream?token=agent:… delivers the same events over one WebSocket (event { event } frames, ?afterSeq= to resume) and lets your agent stream replies that render as they are written. Frames, limits and the SDK's agent.stream(): Streaming.

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 <url>
X-Nmbr-Event-Id: evt_…
X-Nmbr-Event-Type: message.received
X-Nmbr-Delivery-Attempt: 1
X-Nmbr-Signature: t=1725000000,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
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

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.