Webhooks
A webhook sends your server an HTTPS request when something happens in BloomText: a patient writes in, or a text your key sent is delivered or fails. You stop polling GET /patient-messages and react within seconds.
Webhooks carry IDs and a status, never message text. Your server fetches the details with the API, using the same key, so patient data only ever travels over an authenticated call.
Set up a webhook
Add the URL to a key
Each API key has at most one webhook. Under Settings → Account → API keys, an Owner or IT person opens the key and clicks Webhook, then sets:
- the URL:
httpsonly, on a server you control. BloomText doesn’t follow redirects. - the events to send. Only events the key’s scopes allow are offered.
Store the signing secret
BloomText shows the webhook’s signing secret once, starting with whsec_. Store it with the key, in your secrets manager.
Send a test
Click Send test event. BloomText sends a webhook.test event, and the webhook’s page shows your server’s response.
Events
| Event | Sent when | The key needs |
|---|---|---|
message.received | A patient sends a message, on any channel, in an organization the key reaches. A key with a phone-number limit gets only messages to its numbers’ teams. | messages.read |
message.status | A text this key sent changes delivery status: sent, delivered, failed, undelivered or rejected. See Delivery status. | messages.write |
webhook.test | You click Send test event. | — |
The payload
Every event has the same envelope:
{
"id": "0f8e2a1c-6b4d-4f7e-9a2b-3c5d7e9f1a2b",
"type": "message.received",
"occurred_at": "2026-11-02T14:05:12Z",
"org_id": "6db1e3f5-9b7f-4f2b-8be1-0f1e1d7d7d8c",
"data": {
"message_id": "9c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"conversation_id": "e5c3b7b8-8f08-4d5a-9af1-0d11b0f4b7a0",
"patient_id": "5a1c9e2b-7d44-4f1a-9c3e-2b8d6f0a1e77",
"channel": "sms"
}
}{
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"type": "message.status",
"occurred_at": "2026-11-02T14:06:40Z",
"org_id": "6db1e3f5-9b7f-4f2b-8be1-0f1e1d7d7d8c",
"data": {
"message_id": "7b8c9d0e-1f2a-4b3c-8d4e-5f6a7b8c9d0e",
"conversation_id": "e5c3b7b8-8f08-4d5a-9af1-0d11b0f4b7a0",
"patient_id": "5a1c9e2b-7d44-4f1a-9c3e-2b8d6f0a1e77",
"status": "failed",
"error_code": "30006"
}
}id is the event’s ID and stays the same on every retry. To read the message, call Retrieve a patient message with message_id and org_id.
Check the signature
BloomText signs every request following Standard Webhooks . Each request has three headers:
| Header | Value |
|---|---|
webhook-id | The event’s id |
webhook-timestamp | When it was sent, in seconds since the Unix epoch |
webhook-signature | v1, and the Base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the secret after whsec_, Base64-decoded. During a secret rotation it lists two signatures, separated by spaces. |
Check the signature against the raw request body, before parsing it, and reject a timestamp more than 5 minutes from your clock.
Node.js
import crypto from 'node:crypto'
export function verifyWebhook(headers, rawBody, secret) {
const id = headers['webhook-id']
const timestamp = Number(headers['webhook-timestamp'])
if (!id || !timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest()
return headers['webhook-signature'].split(' ').some((part) => {
const [version, signature] = part.split(',')
const given = Buffer.from(signature ?? '', 'base64')
return version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected)
})
}Standard Webhooks libraries in most languages do the same check.
Delivery
- Answer fast. Return any
2xxwithin 5 seconds. Do the work afterwards, for example from a queue. - Retries. Anything else, or no answer, is retried at 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours after the first try.
410 Gonestops retries for that event. - At least once, in any order. The same event can arrive twice, and events can arrive out of order. Skip an
idyou’ve already handled, and useoccurred_at, or the message itself, for order. - No fixed addresses. Requests don’t come from a fixed set of IP addresses. Trust the signature, not the sender’s address.
If your server was down for longer than the retries cover, catch up by reading messages since the last event you handled.
Rotate the secret
On the key’s Webhook page, click Rotate secret. BloomText shows the new secret once. For the next 24 hours it signs every request with both the old and the new secret, so deploy the new one, then let the old one lapse.
Turn a webhook off
Remove the URL from the key, or revoke the key. Revoking a key stops its webhook at once.