MsgBubblesDocs

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

Create a webhook
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

EventFires when
message.receivedA contact sends you a message (text and/or media).
message.sentAn outbound message left the queue and was sent.
message.deliveredThe recipient’s device acknowledged delivery.
message.readThe recipient read the message (read receipts on).
message.failedAn outbound message permanently failed.
message.reactionA contact reacted (tapback or emoji) to one of your messages — includes reaction and a removed flag.
conversation.typingA contact started or stopped typing — includes a typing boolean.
conversation.renamedA group chat’s title changed.
Example: message.received
{
  "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:

  1. Parse t and v1 from the header.
  2. Reject if |now − t| exceeds your replay window (we recommend 300 seconds).
  3. Recompute the HMAC over "<t>.<raw-body>" and compare constant-time against v1.
Node.js verification
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 2xx within 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 + type to dedupe.