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
| Event | Sent when |
|---|---|
conversation.message.created | A message is sent in a conversation your app user is in. |
message.reaction.created | A reaction is added to a message. |
message.reaction.deleted | A reaction is removed. |
conversation.participant.added | Someone joins a conversation. |
conversation.participant.removed | Someone leaves or is removed. |
export.ready | An 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.
{
"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:
X-BloomText-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdTo verify it:
- Split the header into
t(Unix seconds) andv1(hex digest). - Reject the request if
tis more than 300 seconds from now. This blocks replayed deliveries. - Compute HMAC-SHA256 of
{t}.{raw_request_body}with your signing secret. - Compare your digest to
v1in constant time. Only then parse the JSON.
JavaScript
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 return | BloomText does |
|---|---|
Any 2xx within 5 seconds | Marks the delivery done. |
4xx | Treats the delivery as permanently rejected and stops. |
5xx, a timeout, or no response | Retries 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_atand 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.
cloudflared tunnel --url http://localhost:3000