Webhooks
Webhooks push events to your HTTPS endpoint as they happen, so you don’t have to poll. Each delivery
is an HMAC-signed POST you verify before trusting. Managing subscriptions requires the
webhooks:manage scope (and a Pro-plan workspace).
You can manage subscriptions two ways. In the app — open Settings → Webhooks to create a subscription (choose the URL and event types), copy the signing secret shown once at creation, send a test delivery, re-enable a subscription that was auto-disabled after repeated failures, and delete a subscription. Or over the API — the endpoints below, using a personal access token. Both surfaces share the same data, and the signing secret is shown only once whichever way you create it.
Subscription lifecycle
| Method & path | Purpose |
|---|---|
POST /v1/webhooks | Create a subscription; the secret is returned once. |
GET /v1/webhooks | List your subscriptions (secret omitted). |
GET /v1/webhooks/{id} | Fetch one subscription. |
PATCH /v1/webhooks/{id} | Update url, events, or filters; or re-enable a disabled subscription (status accepts "active" only — there is no manual disable). |
DELETE /v1/webhooks/{id} | Delete a subscription. |
POST /v1/webhooks/{id}/test | Send a synthetic webhook.test delivery. |
-
Create a subscription. Save the
secret— it is shown only in this response.Terminal window curl -X POST "https://api-tom.usebubbles.com/v1/webhooks" \-H "Authorization: Bearer $BUBBLES_TOKEN" \-H "Content-Type: application/json" \-d '{"url": "https://example.com/hooks/bubbles","events": ["bubble.ready", "meeting_recording.ready"],"filters": { "only_my_bubbles": true }}'{"id": "wh_9f1c…","url": "https://example.com/hooks/bubbles","events": ["bubble.ready", "meeting_recording.ready"],"team_id": null,"space_id": null,"filters": { "only_my_bubbles": true },"status": "active","created_at": "2026-07-03T15:45:50.193Z","last_success_at": null,"last_failure_at": null,"disabled_at": null,"disabled_reason": null,"secret": "whsec_1a2b…"} -
List your subscriptions any time:
GET /v1/webhooks — your subscriptions
Terminal window curl "https://api-tom.usebubbles.com/v1/webhooks" \-H "Authorization: Bearer $BUBBLES_TOKEN" -
Delete one when you’re done:
DELETE /v1/webhooks/{id}.
Filters
Beyond the coarse team_id / space_id scoping, an optional filters object narrows deliveries:
bubble_types: only deliver for these bubble types (meeting_recording|screen_recording|custom_image).only_my_bubbles: only deliver for bubbles you authored.
PATCH /v1/webhooks/{id} updates filters the same way it updates url and events: each field you
include is replaced wholesale (a partial update at the top level, not a deep merge). Sending
{"filters": {"only_my_bubbles": false}} therefore replaces the entire filters object — a
previously-set bubble_types is dropped — and {"filters": {}} clears all filters. Omit filters
entirely to leave it unchanged.
Events
A subscription receives the event types you list. The deliverable types:
| Name | Event type | Fires when | Delivery gating |
|---|---|---|---|
| Any bubble ready | bubble.ready | The catch-all: fires ONCE when ANY bubble is fully READY — a screen recording processed (transcribed if it had audio), a meeting recording processed + transcribed with AI notes + action items, or a custom image processed. Fires at the ready point, never at creation, and never for zero-media notes. | Delivered to subscription owners who participate in the bubble; team-plan gated at dispatch. |
| Meeting recording ready | meeting_recording.ready | When a meeting recording is fully processed and transcribed with its AI summary + action items ready — not at creation. | Delivered to subscription owners who participate; team-plan gated at dispatch. |
| Screen recording ready | screen_recording.ready | When a screen recording (recorded or uploaded video) is fully processed — transcribed if it had audio, otherwise at the processed signal (so a silent recording still fires). Not at creation. The payload’s data.bubble.media.is_processed reflects transcoding. | Delivered to subscription owners who participate; team-plan gated at dispatch. |
Delivery envelope
Every delivery POSTs this JSON envelope. data.bubble is the same BubbleDTO you get from
GET /v1/bubbles/{id}, so payloads and API reads are byte-consistent.
{ "id": "evt_4c2f…", "type": "meeting_recording.ready", "created_at": "2026-07-03T15:45:50.193Z", "data": { "bubble": { "id": "1dd21223-…", "type": "meeting_recording", "title": "Weekly sync", "url": "https://tom.usebubbles.com/…" } }}The type is one of the event types above (plus the synthetic webhook.test). Each delivery
carries three headers:
| Header | Value |
|---|---|
X-Bubbles-Event | the event type, e.g. meeting_recording.ready |
X-Bubbles-Delivery | a delivery id (dlv_…), stable across retries |
X-Bubbles-Signature | t=<unix-seconds>,v1=<hex HMAC-SHA256> |
Verifying the signature
The signature is HMAC-SHA256(secret, "<t>.<rawBody>"), hex-encoded, where <rawBody> is the exact
bytes received. Recompute it over the raw body and compare in constant time; reject deliveries whose
t is more than 300 seconds from now (replay guard). This Node.js recipe is complete and
self-verifying — run it with node verify-webhook.js:
Verify an X-Bubbles-Signature (Node.js)
const crypto = require('crypto')
// Verify an X-Bubbles-Signature header. rawBody MUST be the exact received bytes (never re-serialized).function verifyBubblesSignature(rawBody, header, secret, toleranceSeconds = 300) { if (!header) return false const parts = {} for (const kv of header.split(',')) { const i = kv.indexOf('=') if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim() } const t = Number(parts.t) if (!Number.isFinite(t) || !parts.v1) return false // Reject stale timestamps (replay guard). if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex') const a = Buffer.from(expected, 'hex') const b = Buffer.from(parts.v1, 'hex') return a.length === b.length && crypto.timingSafeEqual(a, b)}
// --- Self-test: sign a sample exactly as Bubbles does, then verify it. Run: node verify-webhook.jsconst secret = 'whsec_demo_0123456789abcdef'const rawBody = '{"id":"evt_demo","type":"bubble.ready","created_at":"2026-07-01T00:00:00.000Z","data":{}}'const t = Math.floor(Date.now() / 1000)const v1 = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex')const header = 't=' + t + ',v1=' + v1
if (!verifyBubblesSignature(rawBody, header, secret)) { console.error('valid signature rejected'); process.exit(1) }if (verifyBubblesSignature(rawBody + ' ', header, secret)) { console.error('tampered body accepted'); process.exit(1) }const stale = 't=' + (t - 400) + ',v1=' + crypto.createHmac('sha256', secret).update((t - 400) + '.' + rawBody).digest('hex')if (verifyBubblesSignature(rawBody, stale, secret)) { console.error('stale timestamp accepted'); process.exit(1) }
console.log('T=' + t)console.log('SIGNATURE=' + v1)console.log('OK')Retries and auto-disable
A delivery is a success on a 2xx response. On any other response (or a timeout), Bubbles retries
with exponential backoff: 60·2^(n-1) seconds, capped at 900 seconds, for up to 5
attempts. After 20 consecutive failures the subscription is automatically disabled
(status: "disabled", disabled_reason: "delivery_failures"); re-enable it with a
PATCH … {"status": "active"} once your endpoint is healthy. A successful delivery resets the
failure counter to zero.