Skip to main content

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:

HeaderValue
bubbl3-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
bubbl3-event-idThe event's id. The same on every retry and replay, so use it to ignore duplicates
bubbl3-event-typeThe event's type
bubbl3-delivery-idThis 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:

  • rawBody is a string or bytes. A parsed object fails.
  • The timestamp must be within 300 seconds of now, either way. Change it with { toleranceSeconds }.
  • secret can be a list. During a rotation each delivery carries two v1 signatures 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 checkWebhookSignature with the same arguments to learn why a check failed: missing_header, malformed_header, invalid_body, stale_timestamp or signature_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 Gone to stop: the delivery fails and the endpoint is disabled.
  • Delivery is at least once. Deduplicate on the event id (or the bubbl3-event-id header).
  • Events can arrive out of order. Use created_at, and for message.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/:id and status: "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.

  • reason is one of a fixed list (tool, keyword, max_turns and so on), so branch on it.
  • note is 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.