Skip to main content

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 (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) or admins.
  • 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; templates app_token_expiring_week and app_token_expiring_day, see Mail languages and templates).
  • Command review: TAVRN_APP_COMMANDS_REQUIRE_APPROVAL=true makes 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, marker fixup.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:read and create folders, upload and delete their own entries with messages: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 scope rooms:manage unlocks). 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) and TAVRN_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 https addresses unless TAVRN_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).

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).

In an unencrypted channel a service posts a message with a plain HTTPS request carrying JSON.

Post the JSON payload:

Terminal window
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.

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:

  1. GET $WEBHOOK_URL/inbox-key returns { "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.
  2. Seal the payload { "content": { "type": "text", "body": "…", "embeds": [ … ] }, "username": "…" } (UTF-8 JSON; username and embeds are optional) with libsodium’s sealed box (crypto_box_seal) to that public key.
  3. POST $WEBHOOK_URL with { "sealed": "<base64url of the box>", "epoch": 42 }. Anything else is refused with 400 webhook.sealed_required; 409 webhook.pending_key means 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.

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" });

The steps above in Python, with pynacl and requests.

import base64, json, os
import requests
from 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"})

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)
}
}

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.