Navigation

Webhooks

A webhook lets an external system post a message into a room without a user account. A member with the ManageWebhooks permission creates one from room settings, copies the secret url it produces, and anything that can send an HTTP request can then speak in that room. The message arrives under a bot identity of its own rather than under the creator's name, and is marked as such in the message list.

This page is about inbound webhooks only. Posting room events out to a user-registered endpoint is a separate, unbuilt idea — see outbound webhooks.

Data model

Two tables, one-to-one. webhooksInMessage holds the room membership and the secret: roomId, creatorId (the member who made it), token, an isActive switch, a display name, and a userId that is unique and references the second table. appUsersInMessage is the bot identity — nothing but id, name and image. Separating them is what lets a webhook message carry an author at all: the identity is a row a message can point at, so rendering does not have to special-case "no user".

The pairing is enforced by construction. createWebhook inserts both rows in one transaction, and deleteWebhook deletes the app user, which cascades the webhook away with it. A room may hold WEBHOOK_MAX_LENGTH webhooks — currently one — checked inside that same transaction.

Delivery

sequenceDiagram
  participant Ext as External system
  participant Nitro as App route for the webhook url
  participant Push as PushWebhook function
  participant Grid as Event Grid
  participant Proc as ProcessWebhook function
  participant Table as Azure Table messages
  participant Client as Room clients

  Ext->>Nitro: POST the payload to the secret url
  Nitro->>Nitro: webhookRateLimiter.consume on the webhook id — 429 when over budget
  Nitro->>Push: forward with the function key header
  Push->>Push: find an active webhook matching id and token — 404 otherwise
  Push->>Grid: publish a ProcessWebhook event
  Push-->>Ext: 202 accepted
  Grid->>Proc: deliver the event
  Proc->>Table: createMessage with MessageType.Webhook
  Proc->>Client: Web PubSub broadcast — best effort
  Proc->>Grid: publishNotification — best effort

The url a member copies points at the app, not at Azure: server/api/webhooks/[id]/[token].post.ts validates the two route parameters against the schema shapes, spends a point from the per-webhook budget described in rate limiting, and forwards the body to the Function App with the function key. It forwards the answer as faithfully as the request: the function's own status and body are passed straight back (ignoreResponseError), because the statuses below are its to give and a proxy that turned each of them into a 500 would leave a sender unable to tell a rotated token from an Esposter outage. Keying the budget on the webhook id rather than on a caller is the point — a webhook is a machine identity, and one misconfigured integration should exhaust only its own allowance.

PushWebhook is the app's one public Azure Function HTTP trigger, mounted at webhooks/{id}/{token} with authLevel: "function". The token in the url is the credential: the handler looks for a webhook row whose id and token both match and whose isActive is true, and answers 404 when there is none — an inactive webhook and a wrong token are indistinguishable from outside. It then parses the body against webhookPayloadSchema (a Discord-shaped content / username / avatar_url payload with content required — and sanitized by sanitizeTextHtml before its length bound applies, since it renders as message markup like every other body — and strict: a key it does not declare fails the request with 400 — Discord's embeds among them, beside content or alone, since a message has nowhere to store one and a field accepted but never stored would be dropped silently), publishes an Event Grid event, and answers 202 without waiting for the message to exist. That split is deliberate: the sender gets a fast, cheap acknowledgement, and the work that can fail happens behind a retrying event.

ProcessWebhook turns the payload into a MessageType.Webhook message — the payload's username and avatar_url override the stored bot name and image for that message — writes it to Azure Table, broadcasts over Web PubSub, and publishes a push notification event titled with the bot's name. Both of those last two steps are best-effort: the message is already persisted, and Event Grid delivery is at-least-once, so throwing after the write would replay the event and duplicate the message.

Rendering

MessageComponentMap points MessageType.Webhook at the ordinary message component — a webhook message is a message, not a distinct surface. What differs is authorship. WebhookMessageEntity carries an appUser instead of a userId, useCreator resolves the author from the app-user store merged over the identity embedded in the message, and an "app" badge renders beside the name. Grouping consecutive messages compares the app user's id rather than a user id, and thread follows skip the root-author follow entirely for these messages because there is no user to follow.

Procedures

All webhook procedures live in the webhook router.

ProcedureAuthPurpose
createWebhookManageWebhookscreates the app user and the webhook together, on the slow budget
readWebhooksManageWebhookslists a room's webhooks with their tokens
updateWebhookManageWebhooksrenames a webhook or toggles isActive
rotateTokenManageWebhooksmints a new token, invalidating the old url
deleteWebhookManageWebhooksdeletes the app user, cascading the webhook
readAppUsersroom memberresolves bot identities for rendering the message list

createWebhook is the one procedure on the slow rate-limit budget, because it is the only one that mints a credential. readAppUsers is deliberately open to any member: rendering a room's history requires the identities of everything that has spoken in it, while the tokens stay behind the permission gate.

UI

The webhook surface is the Integrations → Webhooks tab of the room settings dialog, permission-gated like every other tab there. New Webhook creates the row outright, named DEFAULT_WEBHOOK_NAME — Discord's arrangement, and the reason the panel holds no create form: the name is the only thing a create could ask for, and the row it lands on already renames it. Each row is an inline-editable name, a copy button for the full url, rotate and delete buttons, and an active switch. Rotating is the revocation story — the url is a bearer credential, so the answer to a leak is a new token rather than an access list.

Key files

Paths are relative to apps/web; an entry that begins with packages/ is relative to the repository root instead.

FileRole
packages/db-schema/src/schema/message/webhooksInMessage.tsthe webhook row — room, creator, token, active flag
packages/db-schema/src/schema/message/appUsersInMessage.tsthe bot identity a webhook message is authored by
packages/db-schema/src/models/message/webhook/WebhookPayload.tsthe accepted request body
packages/db-schema/src/models/message/WebhookMessageEntity.tsmessage entity carrying appUser in place of userId
server/trpc/routers/webhook.tscreate, read, update, rotate, delete, and identity lookup
server/api/webhooks/[id]/[token].post.tsthe public url — rate limits, then forwards to the function
apps/functions/src/functions/pushWebhook.tsthe HTTP trigger registration and its route
apps/functions/src/handlers/pushWebhookHandler.tstoken validation, payload parsing, event publish
apps/functions/src/handlers/processWebhookHandler.tsmessage creation, broadcast, push notification
apps/functions/src/services/message/getWebhookCreateMessageInput.tspayload to MessageType.Webhook message input
app/store/message/room/webhook.tsclient store for the settings tab
app/store/message/user/appUser.tscache of bot identities for the message list
app/composables/message/room/useCreator.tsresolves a message's author, app user or user
app/components/Message/Model/Room/Settings/Type/Webhook/Index.vuethe Integrations tab content
app/components/Message/Model/Message/AppUserBadge.vuethe "app" badge on a webhook message

Details

Command palette

Keyboard shortcuts