Guide to Sendbird Webhooks: Features and Best Practices
Sendbird webhooks tell your server what is happening inside your chat application: a message is sent, edited or deleted, a user joins or leaves a channel, a channel is frozen, a user is blocked, banned or reported, or the profanity filter catches something. Teams use them to send push, SMS or email notifications to offline users, moderate content, sync chat activity to a CRM, or feed analytics.
This guide covers webhooks for Sendbird Chat (Platform API v3): how they are configured and signed, the events and their payloads, the short timeout and retry behaviour, and the best practices for running a Sendbird receiver in production.
What are Sendbird webhooks?
Each Sendbird application has one webhook endpoint. You set its URL and the events to send on the Sendbird Dashboard, and when one of those events happens, Sendbird sends an HTTP POST with a JSON body to the endpoint.
Every request carries an x-sendbird-signature header: a hex HMAC-SHA256 of the request body, keyed with the application's master API token. There is no separate webhook secret, and secondary API tokens don't work.
Requests also carry an older x-signature header. Sendbird says it never matches when the body contains non-ASCII characters, such as emoji, and that it may be deprecated. Verify x-sendbird-signature and ignore x-signature.
Sendbird webhook features
| Feature | Details |
|---|---|
| Configuration | Settings > Chat > Webhooks on the Sendbird Dashboard |
| Endpoints | One per application |
| Endpoint requirements | HTTP/1.1 with keep-alive, accepts POST, parses JSON |
| Signature header | x-sendbird-signature |
| Scheme | HMAC-SHA256, lowercase hex |
| Signed content | The raw request body |
| Key | The application's master API token |
| Legacy header | x-signature, which fails on non-ASCII bodies. Don't use it |
| Replay window | None. No timestamp is signed |
| Event name | category in the body, as <resource>:<action> |
| Other headers | user-agent: SendBird, content-type: application/json |
| Response window | 5 seconds |
| Retries | The same request is sent up to three times until a response arrives |
Sendbird publishes no source IP list for webhooks. Its IP whitelist setting restricts inbound API calls, not where webhooks come from.
Common events
Sendbird documents 48 events. The name is in category and uses a colon.
| Area | Events |
|---|---|
| Open channels | open_channel:create, :remove, :enter, :exit, :message_send, :message_update, :message_delete |
| Group channels | group_channel:create, :changed, :remove, :invite, :decline_invite, :join, :leave, :message_send, :message_read, :message_update, :message_delete, :freeze_unfreeze, :reaction_add, :reaction_delete |
| Users | user:block, user:unblock, user:mute, user:ban, user:deactivate, user:push_preference, user:push_, user:device_, user:device_ |
| Operators | operators:register_, operators:unregister_ |
| Reports | message:report, user:report, open_channel:report, group_channel:report |
| Moderation | profanity_filter:replace, :block, :moderate, :detect, advanced_, :no_action, :replace, message:moderation_, image_moderation:block |
| Other | alert:user_, announcement:create_, announcement:send_ |
group_ fires when a user has no more unread messages in a channel, not for every message they read. user:mute and user:ban cover both a single channel and the whole application.
Here is Sendbird's open_channel:create example:
{
"category": "open_channel:create",
"created_at": 1540866408000,
"operators": [
{
"user_id": "Jay",
"nickname": "Mighty",
"profile_url": "https://sendbird.com/main/img/profiles/profile_26_512px.png",
"metadata": {}
}
],
"channel": {
"name": "Jeff and friends",
"channel_url": "sendbird_open_channel_1_2681099203cd6b78414fe672927a43fcf3a30f09",
"custom_type": "",
"is_distinct": false,
"is_public": false,
"is_super": false,
"is_ephemeral": false,
"is_discoverable": false,
"data": ""
},
"app_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
Every payload has category and app_id. The rest depends on the event: channel, sender, members, user, blocker and so on. Timestamps such as created_at are in milliseconds, and channels are identified by channel_url.
Setting up Sendbird webhooks
- On the Sendbird Dashboard, go to Settings > Chat > Webhooks.
- Turn webhooks on, enter your endpoint URL, and choose the events to send.
- Copy the application's master API token from the dashboard and store it server-side, for example as
SENDBIRD_.MASTER_ API_ TOKEN
If you use Hookdeck, create a Sendbird source, paste the master API token into it, and use the Hookdeck source URL as the webhook endpoint.
Your endpoint must support HTTP/1.1 with keep-alive. Most web frameworks and load balancers do this by default, but check any proxy that sits in front of your receiver.
Securing Sendbird webhooks
Compute an HMAC-SHA256 of the raw request body with the master API token, encode it as hex, and compare it with x-sendbird-signature in constant time.
const crypto = require('crypto');
const express = require('express');
function verifySendbirdSignature(rawBody, signatureHeader, masterToken) {
if (!signatureHeader) return false;
const expected = crypto.createHmac('sha256', masterToken).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/sendbird', express.raw({ type: '*/*' }), (req, res) => {
const token = process.env.SENDBIRD_MASTER_API_TOKEN;
if (!token) return res.sendStatus(500); // fail closed
// Use x-sendbird-signature. The legacy x-signature fails on non-ASCII bodies.
if (!verifySendbirdSignature(req.body, req.get('x-sendbird-signature'), token)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.category. Persist the event, then process it asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_sendbird_signature(raw_body: bytes, signature_header, master_token: str) -> bool:
if not signature_header:
return False
expected = hmac.new(master_token.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
received = signature_header.strip().lower().encode("utf-8")
return hmac.compare_digest(received, expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_sendbird_signature(raw_body, request.headers.get("x-sendbird-signature"),
# os.environ["SENDBIRD_MASTER_API_TOKEN"])
Sendbird's own Python sample builds the payload as a string and compares with ==. Hash the raw bytes you received instead, and compare in constant time.
To check your verifier, including non-ASCII handling, use the token sendbird-test-master-token and this exact body, encoded as UTF-8:
{"category":"group_channel:message_send","payload":{"message":"Hello 👋"},"app_id":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}
The signature should be:
855298b4fbc3df6592adf293c8e30aa98f548306078af48342aa22abe3406183
Make Sendbird webhooks production-ready. Hookdeck Event Gateway verifies the
x-sendbird-signatureheader, answers Sendbird without waiting for your service, and durably queues every message and channel event before your service sees it.
Sendbird webhook limitations and pain points
The signing key is your master API token
The Problem: Every service that verifies Sendbird webhooks needs the application's most powerful credential.
Why It Happens: Sendbird signs with the master API token, and secondary tokens don't verify. The master token has full access to the Platform API.
Workarounds:
- Verify in one place, then pass verified events on, so only that component holds the token.
- Keep the token in a secrets manager and out of logs.
How Hookdeck Can Help: Hookdeck verifies the signature with the token stored on the source, so your services receive already-verified events and never need the master token for webhooks.
Five seconds and three attempts
The Problem: Slow handlers cause duplicate deliveries, and an outage loses events.
Why It Happens: Sendbird waits five seconds for a response and sends the same request up to three times until it gets one. Sendbird warns that synchronous processing can stop the endpoint working when many events happen at once. After the third attempt, the event isn't sent again.
Workarounds:
- Respond
200immediately and process asynchronously, as Sendbird recommends. - Deduplicate, because a slow first attempt and its retry can both be processed.
- After an outage, use Sendbird's Get failed webhooks API to see what failed, and reconcile through the Platform API.
How Hookdeck Can Help: Hookdeck answers Sendbird as soon as each request is stored, then delivers to your service with its own retries, rate limits and replay, so bursts and outages don't lose events.
The legacy x-signature header
The Problem: Verification works in testing and fails in production on messages with emoji or accented text.
Why It Happens: Sendbird still sends x-signature, and on non-ASCII bodies it never matches. Code that verifies against it passes on simple English test messages.
Workarounds:
- Verify
x-sendbird-signatureonly. - Include non-ASCII characters in your verification tests, such as the test vector above.
How Hookdeck Can Help: Hookdeck's Sendbird source verifies x-sendbird-signature and ignores x-signature.
High volume from busy apps
The Problem: A popular chat app sends a request for every message, and your receiver can't keep up.
Why It Happens: Message events fire for every message in every channel you subscribe to. There's no batching.
Workarounds:
- Subscribe only to the events you act on.
- Filter early, for example on
channel.custom_type, and drop what you don't need before doing any work.
How Hookdeck Can Help: Hookdeck filters can drop events by category or any body field before they reach your service, and destination rate limits smooth out bursts.
No event ID or replay protection
The Problem: You can't tell a retry from a new event by an ID, and a captured request still verifies.
Why It Happens: Sendbird documents no delivery or event ID, and the signature covers only the body.
Workarounds:
- Use IDs in the payload, such as a message's
message_id, or build a key fromcategory,channel_urland the payload's timestamp. - Keep the endpoint on HTTPS.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries based on the fields you choose.
Best practices
Verify against the raw body
The signature covers the exact bytes Sendbird sent, including multibyte UTF-8. In Express, mount express.raw() on the webhook route and parse after verification. In a Next.js route handler, use Buffer.from(await request.arrayBuffer()). In FastAPI, read await request.body().
Route on category
Split category on the colon to get the resource and action, and acknowledge categories you don't handle with 200.
Keep test and production apps separate
Each application has its own endpoint and master token. Point each environment's app at its own endpoint, and check app_id before acting.
Acknowledge fast and process asynchronously
See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
Sendbird signs each Chat webhook with a hex HMAC-SHA256 of the raw body in x-sendbird-signature, keyed with the application's master API token. Ignore the legacy x-signature, verify against the raw UTF-8 bytes, route on category, deduplicate, and answer within five seconds, because Sendbird only makes three attempts.
Hookdeck Event Gateway sits in front of your receiver, checks every Sendbird signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Sendbird webhooks reliably in minutes.