# Webhooks

> Receive signed BloomText webhook events when messages, reactions, participants, or exports change.

Source: https://www.bloomtext.com/developers/api/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.

```mermaid
sequenceDiagram
  participant BT as BloomText
  participant You as Your endpoint
  BT->>You: POST event + X-BloomText-Signature
  You->>You: Verify signature, deduplicate by id
  You-->>BT: 200 OK (within 5 seconds)
  You->>You: Process the event asynchronously
```

## Events

| Event | Sent when |
| --- | --- |
| [`conversation.message.created`](https://www.bloomtext.com/developers/api/reference/webhook-events/#conversationmessagecreated) | A message is sent in a conversation your app user is in. |
| [`message.reaction.created`](https://www.bloomtext.com/developers/api/reference/webhook-events/#messagereactioncreated) | A reaction is added to a message. |
| [`message.reaction.deleted`](https://www.bloomtext.com/developers/api/reference/webhook-events/#messagereactiondeleted) | A reaction is removed. |
| [`conversation.participant.added`](https://www.bloomtext.com/developers/api/reference/webhook-events/#conversationparticipantadded) | Someone joins a conversation. |
| [`conversation.participant.removed`](https://www.bloomtext.com/developers/api/reference/webhook-events/#conversationparticipantremoved) | Someone leaves or is removed. |
| [`export.ready`](https://www.bloomtext.com/developers/api/reference/webhook-events/#exportready) | An export has finished. |

You only receive events for conversations your app user participates in. See the [webhook events reference](https://www.bloomtext.com/developers/api/reference/webhook-events/) 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](#verify-signatures) 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.

```json filename="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"
  }
}
```

> **Note:** Payloads deliberately leave out message text and patient details. Fetch the message with [List messages](https://www.bloomtext.com/developers/api/reference/list-messages/) when you need its content, so your key's scopes and membership still apply.

## Verify signatures

Every delivery includes a signature header:

```http filename="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.

```js filename="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)
```

```python filename="app.py"
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["BLOOMTEXT_WEBHOOK_SECRET"].encode()


def verify(raw_body: bytes, header: str | None) -> bool:
    if not header:
        return False
    parts = dict(part.split("=", 1) for part in header.split(","))
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature or abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/webhooks/bloomtext")
def bloomtext_webhook():
    if not verify(request.get_data(), request.headers.get("X-BloomText-Signature")):
        abort(400)
    event = request.get_json()
    # Deduplicate by event["id"], store the event, then process it in the background.
    return "", 200
```

```go filename="main.go"
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func verify(body []byte, header, secret string) bool {
	var timestamp, signature string
	for _, part := range strings.Split(header, ",") {
		key, value, _ := strings.Cut(part, "=")
		switch key {
		case "t":
			timestamp = value
		case "v1":
			signature = value
		}
	}
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || signature == "" {
		return false
	}
	if age := time.Now().Unix() - ts; age > 300 || age < -300 {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}

func webhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil || !verify(body, r.Header.Get("X-BloomText-Signature"), os.Getenv("BLOOMTEXT_WEBHOOK_SECRET")) {
		http.Error(w, "invalid signature", http.StatusBadRequest)
		return
	}

	var event map[string]any
	_ = json.Unmarshal(body, &event)
	// Deduplicate by event["id"], store the event, then process it in the background.
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/webhooks/bloomtext", webhook)
	http.ListenAndServe(":3000", nil)
}
```

```ruby filename="app.rb"
require "sinatra"
require "openssl"
require "json"

def verify(raw_body, header, secret)
  return false unless header

  parts = header.split(",").to_h { |part| part.split("=", 2) }
  timestamp, signature = parts["t"], parts["v1"]
  return false unless timestamp && signature && (Time.now.to_i - timestamp.to_i).abs <= 300

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
  Rack::Utils.secure_compare(expected, signature)
end

post "/webhooks/bloomtext" do
  raw_body = request.body.read
  halt 400 unless verify(raw_body, request.env["HTTP_X_BLOOMTEXT_SIGNATURE"], ENV.fetch("BLOOMTEXT_WEBHOOK_SECRET"))

  event = JSON.parse(raw_body)
  # Deduplicate by event["id"], store the event, then process it in the background.
  status 200
end
```

> **Warning:** 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_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.

```bash filename="Terminal"
cloudflared tunnel --url http://localhost:3000
```
