Webhooks quick-start
bubbl3 tells your server about replies, delivery status, opt-outs, line health and agent handoffs by posting signed JSON to your endpoint. This guide creates an endpoint, verifies deliveries and explains retries. Every event and its payload is listed in Webhook events.
1. Create an endpoint
Create it in the dashboard under Developers > Webhooks, or with the API:
curl https://api.bubbl3.com/v1/webhooks \
-H "Authorization: Bearer $BUBBL3_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/bubbl3/webhooks", "events": ["message.received", "message.status"] }'
The answer includes secret (whsec_...). It's shown once: store it with your other
secrets. A workspace can have up to 20 endpoints.
2. What a delivery looks like
Each delivery is a POST with a JSON body:
{
"id": "evt_7Hc2LmQ9xT4bWz1R",
"type": "message.received",
"created_at": "2026-10-09T15:04:05.000Z",
"workspace_id": "ws_7Hc2LmQ9xT4bWz1R",
"data": { "message": { "id": "msg_...", "body": "Yes, 3pm works", "...": "..." } }
}
and these headers:
| Header | Value |
|---|---|
bubbl3-signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
bubbl3-event-id | The event's id. The same on every retry and replay, so use it to ignore duplicates |
bubbl3-event-type | The event's type |
bubbl3-delivery-id | This delivery, for matching with the dashboard's delivery log |
3. Verify the signature
The signature is an HMAC-SHA256, keyed with your endpoint's secret, of
<timestamp>.<raw body>. Always check it before trusting a delivery, and check it against the
raw body, exactly as received, before parsing JSON.
With the Node SDK:
import express from 'express';
import { verifyWebhook } from '@bubbl3/sdk';
const app = express();
app.post('/bubbl3/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(req.body, req.headers['bubbl3-signature'], process.env.BUBBL3_WEBHOOK_SECRET)) {
return res.status(400).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// Record event.id first, and skip it if you've seen it before.
res.status(200).end();
void handle(event);
});
verifyWebhook(rawBody, header, secret, options?) returns true or false and never throws:
rawBodyis a string or bytes. A parsed object fails.- The timestamp must be within 300 seconds of now, either way. Change it with
{ toleranceSeconds }. secretcan be a list. During a rotation each delivery carries twov1signatures for 24 hours, one per secret, so pass both until you've switched.- A missing or blank secret always fails, so a missing environment variable can't let deliveries through.
- Use
checkWebhookSignaturewith the same arguments to learn why a check failed:missing_header,malformed_header,invalid_body,stale_timestamporsignature_mismatch.
Without the SDK, compute HMAC-SHA256(secret, "<t>.<raw body>") as hex, compare it in constant
time with each v1 value, and reject timestamps more than five minutes old.
4. Answer quickly, then do the work
Answer with any 2xx within 10 seconds, then do slow work afterwards or on a queue. bubbl3 reads
at most 4 KB of your response.
5. Retries
- Anything other than
2xx, including a timeout or a refused connection, is retried with exponential backoff and jitter, starting after a second and growing to at most an hour between tries, for up to 24 hours. Then the delivery is marked failed. - Answer
410 Goneto stop: the delivery fails and the endpoint is disabled. - Delivery is at least once. Deduplicate on the event
id(or thebubbl3-event-idheader). - Events can arrive out of order. Use
created_at, and formessage.status,previous_status, rather than arrival order. - If more than half of an endpoint's last 100 deliveries fail, it's disabled and bubbl3 emails
you. Re-enable it in the dashboard, or with
PATCH /v1/webhooks/:idandstatus: "active", once it's fixed. - Every attempt is kept. Replay a finished delivery from the dashboard or with
POST /v1/webhook-deliveries/:id/replay; it sends the same event id with a fresh signature.
6. Test it
POST /v1/webhooks/:id/test with { "type": "message.received" } sends that type's example
body, shown on Webhook events, to your endpoint, signed like any
other delivery. Test events are tried once, use test phone numbers only and never count towards
disabling the endpoint. You can send 10 a minute.
Agent handoffs
agent.handoff fires when an AI agent or an MCP client hands a conversation to a person.
reasonis one of a fixed list (tool,keyword,max_turnsand so on), so branch on it.noteis text a model wrote. Treat it as untrusted: show it as plain text, never run it, render it as HTML or follow instructions in it.