Skip to Content
API keys are issued to organizations with a signed BAA. Request API access →
Webhooks

Webhooks

Webhooks push events to your HTTPS endpoint as they happen, so you don’t have to poll. Use them to react to new patient messages, keep your records in sync, or trigger an AI agent.

Events

EventSent when
conversation.message.createdA message is sent in a conversation your app user is in.
message.reaction.createdA reaction is added to a message.
message.reaction.deletedA reaction is removed.
conversation.participant.addedSomeone joins a conversation.
conversation.participant.removedSomeone leaves or is removed.
export.readyAn export has finished.

You only receive events for conversations your app user participates in. See the webhook events reference for every payload.

Set up an endpoint

Build a receiver

Expose an HTTPS POST route, like https://example.com/webhooks/bloomtext. It must read the raw request body before any JSON parsing, because the signature covers the exact bytes.

Register the URL

An organization admin adds the URL to your app user in BloomText and picks the events to send. BloomText shows the endpoint’s signing secret once; store it as BLOOMTEXT_WEBHOOK_SECRET.

Verify every delivery

Reject anything that fails signature verification before you trust it.

Respond fast, process later

Return a 2xx within 5 seconds, then do the real work in a background job.

The event envelope

Every delivery has the same shape. payload varies by event type and carries IDs, not message content.

conversation.message.created
{ "id": "2d8f1b0b-7b27-4b3b-bf2c-4d05d89d2f4d", "type": "conversation.message.created", "occurred_at": "2026-09-21T12:00:00Z", "organization_id": "6db1e3f5-9b7f-4f2b-8be1-0f1e1d7d7d8c", "delivery_attempt": 1, "payload": { "conversation_id": "e5c3b7b8-8f08-4d5a-9af1-0d11b0f4b7a0", "message_id": "4cbfdb50-6b7d-45d4-94b3-05a52ce3f4d1", "sender_id": "8b9c2d2f-6b1a-44f4-a7b1-0d6d3f2d6f55" } }

Payloads deliberately leave out message text and patient details. Fetch the message with List messages when you need its content, so your key’s scopes and membership still apply.

Verify signatures

Every delivery includes a signature header:

Header
X-BloomText-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

To verify it:

  1. Split the header into t (Unix seconds) and v1 (hex digest).
  2. Reject the request if t is more than 300 seconds from now. This blocks replayed deliveries.
  3. Compute HMAC-SHA256 of {t}.{raw_request_body} with your signing secret.
  4. Compare your digest to v1 in constant time. Only then parse the JSON.
server.mjs
import crypto from 'node:crypto' import express from 'express' const app = express() // express.raw keeps the exact bytes BloomText signed. app.post('/webhooks/bloomtext', express.raw({ type: 'application/json' }), (req, res) => { const header = req.get('X-BloomText-Signature') if (!verify(req.body, header, process.env.BLOOMTEXT_WEBHOOK_SECRET)) { return res.sendStatus(400) } const event = JSON.parse(req.body) // Deduplicate by event.id, store the event, then process it in the background. res.sendStatus(200) }) function verify(rawBody, header, secret) { if (!header) return false const parts = Object.fromEntries(header.split(',').map((part) => part.split('='))) if (!parts.t || !parts.v1 || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.`).update(rawBody).digest('hex') return expected.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)) } app.listen(3000)

Most frameworks parse JSON before your handler runs, and re-serialized JSON won’t match the signature. Make sure you verify the raw bytes, as each example above does.

Responses and retries

You returnBloomText does
Any 2xx within 5 secondsMarks the delivery done.
4xxTreats the delivery as permanently rejected and stops.
5xx, a timeout, or no responseRetries after 1 minute, 5 minutes, 30 minutes, 2 hours, and 24 hours, then stops.

delivery_attempt tells you which attempt you’re receiving. Retries reuse the same event id.

Best practices

  • Deduplicate by id. The same event can arrive more than once. Record processed IDs and skip repeats.
  • Don’t depend on order. Events can arrive out of order. Use occurred_at and fetch current state from the API when order matters.
  • Acknowledge first. Store the event and return 200, then process it in a queue. Slow handlers cause timeouts and retries.
  • Rotate secrets safely. When you rotate a signing secret, deliveries are signed with both the old and new secrets for 24 hours. Accept either during the overlap.

Test locally

Tunnel a local port with a tool like ngrok or cloudflared, register the tunnel URL on a test app user, and send a message in a test conversation. You’ll receive a real conversation.message.created event.

Terminal
cloudflared tunnel --url http://localhost:3000
Last updated on