msgbubblesDocs

Webhooks

Webhooks are how messages reach you: replies, edits, 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. Pass your own secret (16–200 characters) to sign deliveries with a key you already control, or omit it and we generate a high-entropy whsec_… one. Either way the response’s data includes the signing secretstore it now: a generated secret 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.

Both success responses also include other_webhooks — the endpoints your account already had (secrets omitted), newest first. Every enabled endpoint receives every event, so a non-empty array means each event will now be delivered to more than one place. If that’s not what you wanted, review your endpoints with GET /v1/webhooks and deregister the extra one with DELETE /v1/webhooks/:id. An empty array means the endpoint you just registered is your only one.

Manage endpoints

List your endpoints with GET /v1/webhooks and fetch one with GET /v1/webhooks/:id. Neither ever returns the secret.

Update an endpoint

PATCH/v1/webhooks/:id

Change the delivery url, replace the subscribed events, set a new signing secret, or pause and resume deliveries with enabled. Send only the fields you want to change.

Body for PATCH /v1/webhooks/:id
{ "url": "https://example.com/msgbubbles/events-v2" }    // repoint deliveries
{ "events": ["message.received", "message.reaction"] }   // replace the subscription
{ "secret": "whsec_your_own_signing_key_value" }         // set/replace the signing secret
{ "enabled": false }                                     // pause without deleting

Set or rotate the signing secret

You decide how the secret is set: pass your own secret (16–200 characters) on create or PATCH — the same key you use to change the url — or let msgbubbles generate a whsec_… one. A generated secret is shown only when it’s issued (at creation and on rotation), never on a read, so store it then.

POST/v1/webhooks/:id/rotate-secret

To roll the secret without choosing a value — you never stored it, or it leaked — rotate it: msgbubbles issues a fresh whsec_…, returns it in the response, and signs deliveries with it immediately (setting your own via PATCH takes effect the same way). Roll the new value out to your verifier in the same deploy.

Delete an endpoint

DELETE/v1/webhooks/:id

Deliveries stop right away, and in-flight retries are abandoned.

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.editedA message was edited (iMessage or WhatsApp) — the stored message now holds the new body; includes the revised text and edited_at. Also fires as the confirmation of your own /v1/messages/:id/edit landing.
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"
}
Example: message.edited
{
  "type": "message.edited",
  "conversation_id": "7f2c9e1b-…",
  "message_id": "0d4b1f3a-…",
  "text": "sounds good — 7pm!",
  "edited_at": "2026-06-11T18:26:02.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 are retried up to 8 attempts spread over roughly a day: the first retries come seconds to minutes apart to ride out a blip, the last are hours apart (5s, 1m, 10m, 1h, 4h, 8h, 12h between attempts). After the final attempt the delivery is marked failed.
  • Deliveries are at-least-once — key your processing on message_id + type to dedupe.