Webhooks
Documentation
Webhooks
Receive signed, retried, offline-enriched chat events on your own services.
Webhooks are how emb.chat hands events back to your stack — push notifications, analytics, moderation, audit. Every delivery is HMAC-SHA256 signed, retried on an exponential ladder, and recorded in a per-endpoint delivery log.
Register an endpoint
const wh = await admin.webhooks.register({
url: 'https://acme.com/hooks/chat',
events: ['message.*', 'member.*', 'presence.*'],
});
// wh.secret looks like `whsec_…` and is returned EXACTLY ONCE.
// Store it now — later reads never include it.
Or from the CLI:
emb webhooks create \
--target https://acme.com/hooks/chat \
--events "message.*,member.*,presence.*"
Event subscriptions
Subscribe to exact names, prefix globs, or everything.
| Pattern | Matches |
|---|---|
message.created | one exact event |
message.* | message.created, message.edited, message.deleted |
member.* | member.added, member.removed, member.role |
presence.* | presence.online, presence.offline |
channel.* | channel.updated … |
* | every event |
The delivery envelope
Each POST carries four headers. X-Emb-Event-Id is stable across retries — use it to dedupe.
POST /hooks/chat HTTP/1.1
Content-Type: application/json
X-Emb-Event: message.created
X-Emb-Event-Id: 8f2a1c84-… # stable across retries → idempotency key
X-Emb-Signature: sha256=2c8e…
X-Emb-Timestamp: 1777920123
The signature is sha256= + the lowercase hex HMAC-SHA256 of the literal string `${timestamp}.${rawBody}` using your whsec_ secret.
Verify a delivery
Verify against the raw request body — not a re-serialized object — and reject anything older than 5 minutes. Compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyEmbWebhook(req, secret) {
const ts = Number(req.headers['x-emb-timestamp']);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // 5-min replay window
const expected =
'sha256=' +
createHmac('sha256', secret).update(`${ts}.${req.rawBody}`).digest('hex');
const got = String(req.headers['x-emb-signature'] ?? '');
return (
expected.length === got.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(got))
);
}
app.post('/hooks/chat', (req, res) => {
if (!verifyEmbWebhook(req, process.env.EMB_WHSEC)) {
return res.status(401).send('bad signature');
}
// respond 2xx fast; do slow work async
enqueue(req.headers['x-emb-event'], req.body);
res.json({ ok: true });
});
Return a
2xxquickly. Any non-2xx, network error, or response slower than the 5-second timeout counts as a failed attempt.
Offline-member enrichment
Message events include an offline_members array — the channel members who have no active socket right now. Your push service can notify exactly those users without a single callback to emb.chat.
{
"event": "message.created",
"data": {
"message": { "id": "…", "body": "ship it", "sender_id": "alice" },
"channel_id": "…",
"offline_members": ["bob", "carol"] // notify these via push
}
}
Retries & auto-disable
A delivery is attempted up to 7 times on a fixed ladder; each attempt times out after 5 seconds.
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| Delay before | now | 5s | 30s | 2m | 15m | 1h | 4h |
- A
2xxmarks the delivery delivered and resets the endpoint’s consecutive-failure count. - After all slots are used, the delivery is exhausted — and still kept in the audit log.
- After 50 consecutive failures, the endpoint auto-deactivates (
deactivated_reason: consecutive_failure_threshold). Re-enable it withadmin.webhooks.update(id, { is_active: true }).
Inspect deliveries
Every attempt — delivered, failed, exhausted — is queryable, so you can answer “did this event reach me?” without log diving.
const deliveries = await admin.webhooks.deliveries(wh.id);
// each: { status, attempts, last_attempt_at, response_code, response_body, … }
emb webhooks deliveries <id> --status failed
emb webhooks test <id> # send a one-shot test event