Aibo Platform docs

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.

ts
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); // 204
bash
curl -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.

json
{
  "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:

text
Aibo-Signature: t=1761652803,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

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)

js
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

ts
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