Skip to content
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.

PatternMatches
message.createdone 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 2xx quickly. 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.

Attempt1234567
Delay beforenow5s30s2m15m1h4h
  • A 2xx marks 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 with admin.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