Webhooks and applications
Applications, bots and webhooks let services act and post in rooms; this section covers what the operator decides and how services post through webhooks.
Applications and bots
Section titled “Applications and bots”Applications (bots) act through app tokens, slash commands, interactions and event subscriptions (protocol: APPLICATIONS.md); the bot runtime and SDK are in tavrn-js.
What the operator decides:
- Who may create applications:
TAVRN_APP_CREATION=all(default),invite(an admin grants it per user,PATCH /admin/users/{id} { app_creation_grant }, or through the SSO group map) oradmins. - How many:
TAVRN_APPS_PER_USER(default 5 per owner, node admins included),TAVRN_APPS_PER_ROOM(default 25 installed per room). - Tokens and load:
TAVRN_APP_TOKEN_MAX_DAYS(default 365) caps the token lifetime;TAVRN_APP_RATE_REQUESTS(default 1200 per application and minute) caps REST and WebSocket operations. Seven days and one day before a token expires, the hourly housekeeping tells the owner in the client and, with SMTP and a confirmed address, by e-mail (once per token and step, in the owner’s language; templatesapp_token_expiring_weekandapp_token_expiring_day, see Mail languages and templates). - Command review:
TAVRN_APP_COMMANDS_REQUIRE_APPROVAL=truemakes node admins approve every command definition (and every change of one) before it is offered in this node’s rooms. - Buttons, select menus and forms (message components,
components.v1): applications put them into their own messages; clicking, choosing and submitting forms are interactions like commands (the same rate limit of 5 per 5 seconds and user, the same delivery to the bot). The node refuses components in plaintext messages of people and webhooks, so nobody can fake an application’s buttons, and accepts interactions only on messages of a bot whose application is still installed.TAVRN_COMPONENT_MAX_AGE_DAYS(default 30, 0 = no limit) ends them on old messages. Members need the permission Use buttons and menus of apps (use_components, bit 40, channel-scoped, everyone by default; at the first start with this release granted once to every role holding Send messages, markerfixup.use_components); it does not need Send messages, so announcement channels can carry sign-up buttons. In encrypted channels the values people send are end-to-end encrypted to the bot; the node sees which component of which message was used. Forms an application opens are kept with their interaction (15 minutes) and never stored. - Files channels: bots browse and download with the scope
messages:readand create folders, upload and delete their own entries withmessages:write(the channel’s Attach files, upload categories and the bot account’s upload size apply as for people; others’ entries need Manage files, which only the scoperooms:manageunlocks). The storage settings of a files channel (store URL and credentials) stay closed to bots whatever their scopes and roles. In encrypted files channels a bot that may read the channel is a member of its encryption group and encrypts names and contents itself, so the node learns no more than from people’s uploads; “no bots” and the channel setting Bots allowed decide as for text channels. - Federation:
TAVRN_APPS_FEDERATE(this node’s bots may act on other nodes) andTAVRN_FEDERATION_REMOTE_BOTS(other nodes’ bots may act here), both on by default. - Outbound requests: the node calls applications’ interactions URLs and event subscription endpoints. They must
be public
httpsaddresses unlessTAVRN_APP_ALLOW_PRIVATE_URLS=true(intranets; this turns the SSRF protection off for them). - Bot tokens are a single factor: room owners can declare rooms, categories and channels “no bots”.
With several instances, command interactions reach a bot connected to any instance (Command interactions explains how).
Incoming webhooks
Section titled “Incoming webhooks”TAVRN_WEBHOOKS_ENABLED (default true) and TAVRN_WEBHOOKS_PER_CHANNEL (default 10, 1–50, at most 50 per room)
control incoming webhooks. Webhook execution is unauthenticated apart from the URL secret; its rate limits are
shared across instances.
A tavrn webhook is a URL (https://<node>/api/v1/webhooks/<id>/<secret>) that a service such as CI or monitoring
uses to post messages into one channel. Anyone who knows the URL can post; nobody can read through it. The rules are
in protocol/APPLICATIONS.md §6.1 (payload, limits) and §6.1a (encrypted channels).
Webhooks in unencrypted channels
Section titled “Webhooks in unencrypted channels”In an unencrypted channel a service posts a message with a plain HTTPS request carrying JSON.
Post the JSON payload:
curl -X POST "$WEBHOOK_URL" -H 'content-type: application/json' \ -d '{ "content": { "type": "text", "body": "Deploy **v1.2** finished" }, "username": "CI" }'A webhook of a forum channel posts into one of its posts: add "thread_id": "<post id>" (the id is in the post’s
link). Forum channels show posts only, so a message without thread_id is refused there (400 message.thread_required); in text and announcement channels thread_id is optional and posts into a thread.
Webhooks in end-to-end encrypted channels
Section titled “Webhooks in end-to-end encrypted channels”In an end-to-end encrypted channel the node must never see the text, so every message is sealed before it reaches the node.
The person who creates the webhook chooses who seals the messages:
- Sealed by the service (default): the service encrypts every message itself, so the node never sees the text. It needs the code below.
- Sealed by the node: the service posts plaintext exactly as above, over HTTPS; the node encrypts it on arrival and stores only the encrypted message. The node sees the text at that moment, so its operator could read it.
For a webhook sealed by the service:
GET $WEBHOOK_URL/inbox-keyreturns{ "epoch": 42, "public_key": "<base64url>", "algorithm": "x25519-xsalsa20poly1305-sealedbox" }: the current public key of the channel’s members. It changes whenever the channel’s membership or keys change (an “epoch”); fetch it before every post or cache it for a short while.- Seal the payload
{ "content": { "type": "text", "body": "…", "embeds": [ … ] }, "username": "…" }(UTF-8 JSON;usernameandembedsare optional) with libsodium’s sealed box (crypto_box_seal) to that public key. POST $WEBHOOK_URLwith{ "sealed": "<base64url of the box>", "epoch": 42 }. Anything else is refused with400 webhook.sealed_required;409 webhook.pending_keymeans no member has published a key yet (a member has to open the channel once).
base64url is RFC 4648 §5 without padding. A key from a slightly older epoch still works: members of that epoch can read the message, and the node accepts any epoch up to the newest one.
Node.js (libsodium-wrappers)
Section titled “Node.js (libsodium-wrappers)”The steps above in Node.js, with libsodium-wrappers.
import sodium from "libsodium-wrappers";
const B64 = sodium.base64_variants.URLSAFE_NO_PADDING;
export async function postSealed(webhookUrl, payload) { await sodium.ready; const key = await (await fetch(`${webhookUrl}/inbox-key`)).json(); const box = sodium.crypto_box_seal(JSON.stringify(payload), sodium.from_base64(key.public_key, B64)); const res = await fetch(webhookUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sealed: sodium.to_base64(box, B64), epoch: key.epoch }), }); if (!res.ok) throw new Error(`webhook: ${res.status} ${await res.text()}`);}
await postSealed(process.env.WEBHOOK_URL, { content: { type: "text", body: "Deploy **v1.2** finished" }, username: "CI" });Python (pynacl, requests)
Section titled “Python (pynacl, requests)”The steps above in Python, with pynacl and requests.
import base64, json, osimport requestsfrom nacl.public import PublicKey, SealedBox
def b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def unb64url(text: str) -> bytes: return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def post_sealed(webhook_url: str, payload: dict) -> None: key = requests.get(f"{webhook_url}/inbox-key", timeout=10).json() box = SealedBox(PublicKey(unb64url(key["public_key"]))).encrypt(json.dumps(payload).encode()) r = requests.post(webhook_url, json={"sealed": b64url(box), "epoch": key["epoch"]}, timeout=10) r.raise_for_status()
post_sealed(os.environ["WEBHOOK_URL"], {"content": {"type": "text", "body": "Backup finished"}, "username": "Backup"})Go (golang.org/x/crypto/nacl/box)
Section titled “Go (golang.org/x/crypto/nacl/box)”The steps above in Go, with golang.org/x/crypto/nacl/box.
package main
import ( "bytes" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "net/http" "os"
"golang.org/x/crypto/nacl/box")
func postSealed(webhookURL string, payload any) error { resp, err := http.Get(webhookURL + "/inbox-key") if err != nil { return err } defer resp.Body.Close() var key struct { Epoch int64 `json:"epoch"` PublicKey string `json:"public_key"` } if err := json.NewDecoder(resp.Body).Decode(&key); err != nil { return err } raw, err := base64.RawURLEncoding.DecodeString(key.PublicKey) if err != nil || len(raw) != 32 { return fmt.Errorf("bad inbox key") } var pk [32]byte copy(pk[:], raw) msg, _ := json.Marshal(payload) sealed, err := box.SealAnonymous(nil, msg, &pk, rand.Reader) if err != nil { return err } body, _ := json.Marshal(map[string]any{"sealed": base64.RawURLEncoding.EncodeToString(sealed), "epoch": key.Epoch}) post, err := http.Post(webhookURL, "application/json", bytes.NewReader(body)) if err != nil { return err } defer post.Body.Close() if post.StatusCode >= 300 { return fmt.Errorf("webhook: %s", post.Status) } return nil}
func main() { payload := map[string]any{"content": map[string]any{"type": "text", "body": "Nightly build green"}} if err := postSealed(os.Getenv("WEBHOOK_URL"), payload); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) }}What sealing protects, and what not
Section titled “What sealing protects, and what not”Only devices that are members of the channel in that epoch (and members who later receive the epoch’s keys through
the channel’s history setting) can open the box. The node stores it, cannot open it, and never logs payloads. Two
limits remain (protocol/ENCRYPTION.md §5b): a malicious node could hand the service its own public key instead of the
members’ key (the service cannot tell), and in “sealed by the node” mode the node sees each text once when it arrives.
Keep the webhook URL secret like a password; it also guards the key route.
This page is generated from tavrn-node/docs/HANDBOOK.md (AGPL-3.0-only) when the website is built; its links were adapted for this site.