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 } |
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=<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.
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.