Webhooks
Webhooks tell your backend about new messages without holding a stream open — useful for push notifications, analytics, moderation queues, or syncing transcripts into your own database.
Register an endpoint
Endpoints are managed by the tenant (API key or console session). The signing secret is returned once, at creation.
const hook = await server.webhooks.create({ url: 'https://acme.test/aibo/webhooks' });
saveSecret(hook.secret); // store it like a password
await server.webhooks.list(); // { data: WebhookEndpoint[] }
await server.webhooks.delete(hook.id); // 204curl -sS -X POST "$AIBO_URL/v1/webhooks" \
-H "Authorization: Bearer $AIBO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"url":"https://acme.test/aibo/webhooks"}'
# → 201 { "id": "whk_…", "url": "https://acme.test/aibo/webhooks", "createdAt": "…", "secret": "…" }
curl -sS "$AIBO_URL/v1/webhooks" -H "Authorization: Bearer $AIBO_API_KEY"
curl -sS -X DELETE "$AIBO_URL/v1/webhooks/whk_…" -H "Authorization: Bearer $AIBO_API_KEY"message.created
Sent whenever a message is stored in any chat in your tenant — both user messages and agent replies.
{
"id": "evt_01J…",
"type": "message.created",
"createdAt": "2026-10-28T12:00:03Z",
"data": {
"chatId": "cht_…",
"ownerExternalId": "cust_42",
"message": {
"id": "msg_…",
"chatId": "cht_…",
"sender": { "type": "agent", "agentId": "agt_billing…" },
"kind": "text",
"content": "I see two charges on the 3rd — one was a pre-authorization…",
"createdAt": "2026-10-28T12:00:03Z"
}
}
}ownerExternalId is the human who owns the chat, so you can route the event to the right user without another lookup.
Signature scheme
Every delivery carries an Aibo-Signature header:
Aibo-Signature: t=1761652803,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt— Unix timestamp (seconds) when the delivery was signed.v1— hex-encoded HMAC-SHA256 of the string{t}.{body}, keyed with your endpoint's secret, where{body}is the raw request body bytes exactly as received.
To verify: parse t and v1, recompute the HMAC over `${t}.${rawBody}`, compare in constant time, and reject timestamps too far from now (we suggest 5 minutes) to limit replay.
Verify against the raw body. If your framework parses JSON first and you re-serialize it, the bytes will differ and the signature won't match.
Node.js (built-in crypto)
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.AIBO_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
(header ?? '').split(',').map((kv) => kv.trim().split('=', 2)),
);
const t = Number(parts.t);
if (!parts.v1 || !Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/aibo/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8');
if (!verify(raw, req.get('Aibo-Signature'), SECRET)) return res.sendStatus(400);
const event = JSON.parse(raw);
if (await alreadyProcessed(event.id)) return res.sendStatus(200); // duplicate delivery
await enqueue(event); // do slow work asynchronously
await markProcessed(event.id);
res.sendStatus(200);
});With the SDK helper
import { verifyWebhookSignature } from '@aibo/sdk';
app.post('/aibo/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
const payload = req.body.toString('utf8');
const valid = await verifyWebhookSignature({
payload, // raw body string
header: req.get('Aibo-Signature'), // "t=…,v1=…"
secret: process.env.AIBO_WEBHOOK_SECRET,
});
if (!valid) return res.sendStatus(400);
const event = JSON.parse(payload);
// … dedupe by event.id, then handle
res.sendStatus(200);
});Delivery guarantees
- At-least-once. The same event may be delivered more than once. Deduplicate on the event
id(store processed ids for at least the retry window). - Retries. Any non-
2xxresponse is retried with backoff for 24 hours. - Acknowledge fast. Return
2xxas soon as the event is verified and queued; do heavy work out of band. - Don't rely on delivery order, especially across retries. Use
message.createdAt(or list messages for the chat) when order matters.