Webhooks
Firemoot pushes every persisted channel event to your HTTP endpoints, as getstream.io's own webhook payloads - a handler written against Stream reads them unchanged. The queue is a Postgres table, not a broker: deliveries are retried on a fixed backoff ladder and, when they run out of attempts, dead-lettered rather than dropped so you can requeue them from the admin dashboard.
Registering an endpoint
Registration is a server-key operation, so it goes through your backend, not the browser:
curl -X POST http://localhost:6668/v1/webhooks \
-H 'content-type: application/json' \
-d '{"url": "https://example.com/hooks/firemoot"}'
# ...plus the three HMAC headers; the server SDK signs these for you{ "id": "0197…", "url": "https://example.com/hooks/firemoot", "secret": "…", "enabled": true, "format": "stream" }secret is generated for you if you do not supply one, and this 201 response is the only time it is returned - store it now. GET /v1/webhooks lists endpoints without secrets, and DELETE /v1/webhooks/{id} removes one (which cascades away its pending deliveries).
There is no per-endpoint event filter in v1: every enabled endpoint receives every deliverable event, and you filter on type at your end.
Body format
Each endpoint has a format:
| Format | Body | Status |
|---|---|---|
stream | Stream's webhook payload (message.new with message.user, channel_id, …) | The default |
firemoot | The { type, cid, seq, data } envelope the WebSocket carries | Deprecated - will be removed |
firemoot exists only so endpoints registered before stream arrived can migrate. To move one, deploy a handler that accepts Stream's payload (or run the body through normalizeWebhookEvent, which passes Stream payloads through untouched), then flip the endpoint - its secret is kept:
curl -X PATCH http://localhost:6668/v1/webhooks/0197… \
-H 'content-type: application/json' \
-d '{"format": "stream"}'PATCH also takes enabled. Deliveries already queued keep the body they were rendered with.
The delivery
POST <your url>
Content-Type: application/json
X-Firemoot-Signature: sha256=<hex>
X-Firemoot-Delivery: <uuid>
X-Firemoot-Event: message.new
X-Signature: <hex>
X-Webhook-Id: <uuid>
X-Webhook-Attempt: 1
X-Api-Key: <your server api key id>The second block is getstream.io's header naming for the same delivery, so a handler written against Stream keeps working unchanged - see migrating from Stream. Prefer the X-Firemoot-* headers in new code; the aliases exist for compatibility and carry no extra information.
The body is Stream's payload for that event:
{
"type": "message.new",
"cid": "messaging:general",
"channel_id": "general",
"channel_type": "messaging",
"message": { "id": "…", "text": "hi", "type": "regular", "user": { "id": "alice", … }, … },
"user": { "id": "alice", … },
"members": [{ "user_id": "alice", "user": { … }, "role": "owner", … }, …],
"created_at": "2026-11-08T10:00:00Z"
}Delivery is at-least-once and unordered - rows are claimed with for update skip locked and processed eight at a time, so a retry can land after a newer event. X-Firemoot-Delivery (and its alias X-Webhook-Id) is stable across retries of the same event; dedupe on it. X-Webhook-Attempt counts from 1, so a value above 1 tells you this event has been tried before. Order by the payload's timestamps (message.created_at, updated_at) where it matters.
Verifying the signature
The signature is sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed by that endpoint's secret. Verify before parsing, against the bytes you received rather than a re-serialised object:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: Buffer, header: string, secret: string): boolean {
const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && timingSafeEqual(a, b);
}This is a different scheme from the one your outbound server requests use, even though both land in a header called X-Firemoot-Signature. See authentication.
X-Signature carries the same digest without the sha256= prefix, which is the exact shape Stream's verifyWebhook(rawBody, signature) expects - so a handler ported from Stream verifies against it with no code change:
client.verifyWebhook(rawBody, req.headers["x-signature"]);X-Api-Key is your server API key id (FIREMOOT_API_KEY_ID), sent because Stream sends one. Treat it as routing metadata only: Firemoot signs with the endpoint's secret, not the API key's secret, so it is not the key to verify with.
Which events are delivered
Everything persisted and broadcast to a whole channel, under Stream's names:
message.new, message.updated, message.deleted, message.read, reaction.new, reaction.deleted, channel.updated, channel.deleted, member.added, member.removed - plus message.flagged from moderation and user.unread_message_reminder (below).
Payloads follow Stream's documented shapes, with what Firemoot can truthfully fill in:
- Users carry
id,name,image,role, timestamps,last_activeand their custom fields spread at the top level, as Stream does.bannedis alwaysfalse(Firemoot has no bans) and there is noonline. - Messages carry
reaction_counts, the tenlatest_reactions,reply_count,parent_idand custom fields spread at the top level. There is nohtmlandmentioned_usersis always empty. message.newlists the channel'smembersonly for channels of up to 100 members.message.deletedhasdeleted_atbut notext: Firemoot's soft delete scrubs it.message.updatedhas nomessage_update.old_text, channels have noconfig, and no event carriesrequest_info.
A firemoot-format endpoint gets the same events under their envelope names (read.updated, user.flagged) with camelCase data.
Ephemeral and user-directed events are not delivered: typing.start, typing.stop, presence.changed, and the per-user unread notifications. The one user-level event that is delivered is the unread reminder below.
Unread reminders
user.unread_message_reminder tells you a user has left a message unread for a while, so you can email them, push, or text. It is Stream's event of the same name, and it is off until you set an interval:
FIREMOOT_UNREAD_REMINDER_INTERVAL_SECONDS=300 # 60 to 86400A member is reminded when their oldest unread message - not their own, not a system message, not deleted - has waited that long. Each unread burst is reminded once; the next message after a reminder starts a fresh clock, and reading the channel cancels a pending one. Up to ten of a user's channels are batched into one event (more spill into a second), and channels with more than FIREMOOT_UNREAD_REMINDER_MAX_MEMBERS (default 10) members are skipped, so a busy group channel cannot turn into a reminder per member. A sweep runs every 15 seconds, so a reminder lands up to that long after it falls due.
The body is Stream's: channels keyed by cid, members nested in each channel:
{
"type": "user.unread_message_reminder",
"created_at": "2026-11-08T10:05:00Z",
"user": { "id": "bob", "name": "Bob", … },
"channels": {
"messaging:dm": {
"channel": {
"cid": "messaging:dm", "type": "messaging", "id": "dm", "member_count": 2,
"members": [{ "user_id": "alice", "user": { … }, "role": "owner" }, …],
…
},
"messages": [{ "id": "…", "text": "…", "type": "regular", "user": { "id": "alice", … }, … }]
}
}
}messages is the channel's newest five live messages, newest first - including the recipient's own and system messages, so you can see the conversation as it stands. Check messages[0].user.id if you only want to notify when the other side spoke last.
Reminders only look back an hour past due. Turning the feature on does not remind everyone about their old unread history, and after an outage longer than that, reminders that fell due while the server was down are skipped rather than sent late.
Retries and dead-letters
| Request timeout | 5s |
| Backoff ladder | 1 minute, 5 minutes, 30 minutes, 2 hours |
| Total attempts | 5 (the first, plus four retries) |
| Counts as failure | any non-2xx, a connection error, a timeout, or the endpoint being deleted/disabled mid-flight |
Anything still failing after the ladder is marked dead with its last error recorded. Dead letters are listed in the admin dashboard, and replaying one resets it to pending with attempts = 0 - so a replay gets the whole ladder again, and a permanently broken consumer will re-dead-letter rather than spin.
Successful deliveries stay in the table as delivered. There is no pruning worker yet, so on a busy instance that table is worth an occasional manual cleanup.