Webhooks
Webhooks are how messages reach you: replies, delivery and read receipts, reactions, and typing indicators are POSTed to your endpoint as they happen. Each delivery is signed with your webhook’s secret so you can verify it came from MsgBubbles.
Register an endpoint
POST/v1/webhooks
curl -X POST https://api.msgbubbles.com/v1/webhooks \
-H "Authorization: Bearer sk_live_…" \
-H "content-type: application/json" \
-d '{
"url": "https://example.com/msgbubbles/events",
"events": ["message.received", "message.delivered", "message.read"]
}'Omit events to subscribe to everything. The response’s data includes the webhook’s signing secret (whsec_…) — store it now: it is returned only at creation and rotation, never on reads. (Webhook deliveries below are signed event payloads, not API responses, so they are not wrapped in the data envelope.)
The url must be a public http(s) endpoint. Private, loopback, and link-local addresses — localhost, 10.x / 192.168.x, the cloud-metadata IP, and the like — are rejected with 400 invalid_url, and a delivery whose host later resolves to one is dropped.
Pass an Idempotency-Key header to make retries safe: a repeated key returns the original webhook (secret included) with 200 instead of registering a duplicate with 201.
Manage endpoints with GET /v1/webhooks, GET/PATCH/DELETE /v1/webhooks/:id (PATCH updates url, events, enabled), and rotate a leaked secret with POST /v1/webhooks/:id/rotate-secret — deliveries sign with the new secret immediately.
Events
| Event | Fires when |
|---|---|
message.received | A contact sends you a message (text and/or media). |
message.sent | An outbound message left the queue and was sent. |
message.delivered | The recipient’s device acknowledged delivery. |
message.read | The recipient read the message (read receipts on). |
message.failed | An outbound message permanently failed. |
message.reaction | A contact reacted (tapback or emoji) to one of your messages — includes reaction and a removed flag. |
conversation.typing | A contact started or stopped typing — includes a typing boolean. |
conversation.renamed | A group chat’s title changed. |
{
"type": "message.received",
"conversation_id": "7f2c9e1b-…",
"message_id": "0d4b1f3a-…",
"from": "+15555550123",
"to": "+18005551111",
"text": "sounds good!",
"channel": "imessage",
"has_attachments": false,
"created_at": "2026-06-11T18:25:31.000Z"
}Verifying signatures
Every delivery carries a Stripe-style signature header over the raw body:
X-MsgBubbles-Signature: t=<unix-seconds>,v1=<hex>where v1 = HMAC-SHA256(secret, "<t>.<raw-body>"). Verify before trusting a payload:
- Parse
tandv1from the header. - Reject if
|now − t|exceeds your replay window (we recommend 300 seconds). - Recompute the HMAC over
"<t>.<raw-body>"and compare constant-time againstv1.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, header, toleranceSec = 300) {
const { t, v1 } = Object.fromEntries(header.split(",").map((s) => s.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Compute the HMAC over the raw request body, before any JSON parsing — re-serialized JSON will not match.
Delivery and retries
- Respond with any
2xxwithin 10 seconds to acknowledge. Do slow work after acknowledging, not before. - Failed deliveries retry with exponential backoff (capped at 1 hour) up to 8 attempts, then the delivery is marked failed.
- Deliveries are at-least-once — key your processing on
message_id+typeto dedupe.