Skip to content

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 & pathPurpose
POST /v1/webhooksCreate a subscription; the secret is returned once.
GET /v1/webhooksList 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}/testSend a synthetic webhook.test delivery.
  1. 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…"
    }
  2. 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"
  3. 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:

HeaderValue
X-Bubbles-Eventthe event type, e.g. meeting_recording.ready
X-Bubbles-Deliverya delivery id (dlv_…), stable across retries
X-Bubbles-Signaturet=<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.js
const 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.