Zum Inhalt springen

Receiving webhooks

Dokumentation 9 Min. Lesezeit Quelle auf codefloeNur auf Englisch

Auf dieser Seite

Catodon sends a webhook as an HTTP POST with a JSON body, for the webhooks a user sets up under Settings > Developer > Webhook and for the ones an administrator sets up under Control Panel > Webhooks. This page is for whoever writes the receiving end: how to check that a delivery really comes from your server.

Headers

Header Value
X-Catodon-Signature t=<unix seconds>,v1=<hex>, see below. Absent when the webhook has no secret.
X-Catodon-Host The host of the sending server, for example example.social.
X-Catodon-Hook-Id The id of the webhook.
X-Misskey-Host, X-Misskey-Hook-Id The same values, kept for receivers written for Misskey.
X-Misskey-Hook-Secret The secret itself. Deprecated: it lets anyone who sees one delivery forge the next ones. Check the signature instead; this header will be removed in 27.1.0, see below.
User-Agent Misskey-Hooks; will become Catodon-Hooks in 27.1.0, see below.

Webhook changes in 27.1.0

Two changes to what a delivery carries, announced ahead of 27.1.0 so that receivers have time to adapt.

  • X-Misskey-Hook-Secret will be removed. From 27.1.0 on the secret no longer travels with each request. Check X-Catodon-Signature, as described below.
  • User-Agent will become Catodon-Hooks, where it is Misskey-Hooks today. It is a single value, so it cannot be sent in both forms the way the X-Catodon-* headers are sent next to the X-Misskey-* ones. A receiver that filters on Misskey-Hooks should accept both values from now on. Better still, stop relying on it: the signature is what shows a delivery comes from your server, a User-Agent can be set by anyone.

X-Misskey-Host and X-Misskey-Hook-Id stay.

Checking the signature

v1 is the HMAC-SHA256, in hexadecimal, of the timestamp t, a dot, and the request body, keyed with the webhook's secret:

v1 = hex(HMAC-SHA256(secret, t + "." + body))

To accept a delivery:

  1. Hash the raw bytes of the body as received, before any JSON parsing. A body parsed and serialised again will not give the same bytes, and the signature will not match.
  2. Compare your result with v1 in constant time (crypto.timingSafeEqual in Node, hmac.compare_digest in Python), never with ==.
  3. Reject a timestamp too far from your own clock, five minutes for instance, so that a delivery someone captured cannot be replayed later. The timestamp is set when the request is sent, so a retried delivery carries a fresh one.

In Node, with no dependency:

import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error('WEBHOOK_SECRET is not set');

createServer((req, res) => {
    const chunks = [];
    req.on('data', chunk => chunks.push(chunk));
    req.on('end', () => {
        const raw = Buffer.concat(chunks);
        const header = req.headers['x-catodon-signature'];
        const { t, v1 = '' } = Object.fromEntries((typeof header === 'string' ? header : '').split(',').map(part => part.split('=')));
        const expected = createHmac('sha256', secret).update(`${t}.`).update(raw).digest();
        const given = Buffer.from(v1, 'hex');
        const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
        if (!fresh || given.length !== expected.length || !timingSafeEqual(given, expected)) {
            res.writeHead(401).end();
            return;
        }
        const delivery = JSON.parse(raw);
        // ... handle delivery.type and delivery.body
        res.writeHead(204).end();
    });
}).listen(8080);

The secret

The secret is shown in the webhook's form, where its owner can copy it. When a user creates a webhook without one, or clears the field and saves, the server generates a random one. A webhook created before signatures existed with an empty secret is sent unsigned until its owner saves it once. System webhooks always have a secret, since the form requires one.

Direct messages and chat

A direct message is a post whose visibility is specified. A webhook on note receives the direct messages its account sends, as any other post. A direct message addressed to the account reaches a webhook on mention instead: the sender's addressees count as mentioned, whether or not the text names them. That event is not created for a muted sender or thread, for an invite-only event's invitation, or for a message this server learned about too late to recover, or one with no publication time of its own, so a missing delivery does not by itself mean the message was never sent. Chat is not covered: no webhook event is sent for a chat message. A webhook limited by the post filters to specified relays those direct messages and no other post.

Answering

Answer with a 2xx status. A 4xx answer other than 429 is taken as final and the delivery is not retried; a 429 or 5xx answer, a timeout or a connection error is retried, up to four attempts in all.

Every attempt is logged on the webhook's edit page, with the status you answered or why no answer came. The last 30 are kept, and the daily clean deletes those older than 30 days. From there its owner can send a delivery again: the body is the same, eventId included, so a receiver that keeps the ids it has handled can skip one it already processed; only the timestamp and the signature are new.

An event that fails for good, after its retries or at once on a 4xx, counts against the webhook. After 10 such events in a row, the first of them at least an hour earlier, the webhook is disabled and its owner is notified: the account for an account webhook, every moderator and admin for a system webhook. A single success starts the count again, and turning the webhook back on clears it. Test deliveries and deliveries sent again by hand do not count.

The notification event

An account webhook that ticks When receiving a notification is sent every notification its account receives, whatever its kind, with type set to "notification". The body holds one field, notification: the notification as i/notifications returns it to the account, so it carries nothing the account could not see in the app.

{
    "server": "https://example.social",
    "hookId": "…",
    "userId": "…",
    "eventId": "…",
    "createdAt": 1793203200000,
    "type": "notification",
    "body": {
        "notification": {
            "id": "…",
            "createdAt": "2026-10-28T16:00:00.000Z",
            "type": "reaction",
            "userId": "…",
            "user": { "id": "…", "username": "bob", "host": null, "…": "…" },
            "note": { "id": "…", "text": "…", "…": "…" },
            "reaction": "👍"
        }
    }
}

notification.type names the kind, and the other fields depend on it: user and userId for those caused by an account, note for those about a post, reaction for a reaction, and so on. The kinds and their fields are those of the Notification schema in the API documentation of your server (/api-doc); notificationTypes in misskey-js lists them. New kinds can appear in a later version: ignore those you do not handle and still answer 2xx, since an event answered with a 4xx counts towards turning the webhook off.

What is sent follows the account's notification settings: a kind turned off, a muted account, or a notification the filtering policy sets aside under Notification requests sends nothing, and accepting such a request later does not send what it held. Every reaction is sent, including those the app groups without a sound, so a popular post means one delivery per reaction.

This event overlaps the others. A mention reaches a webhook that ticks both When being mentioned and When receiving a notification twice: once as mention, with the post in body.note, and once as notification.

reaction is still accepted in a webhook's events through the API, but nothing is sent for it: a reaction reaches a webhook as a notification whose type is reaction.

Filtering posts

An account webhook can be limited to some posts, under Post filters in its form, or with noteVisibilities and noteOrigin through i/webhooks/create and i/webhooks/update. The filters apply to the events whose body carries a post in body.note: note, edited, reply, renote and mention. A post is sent only if it passes both.

Field Values Default
noteVisibilities A list of public, home, followers and specified: only posts with one of these visibilities are sent. An empty list sends no post. null, every visibility.
noteOrigin local sends only posts whose author is on this server, remote only those whose author is on another one. combined, both.

A webhook created before the filters existed has both defaults and sends what it did. To mirror your public posts elsewhere, tick When publishing a post with noteVisibilities set to ["public"], or to ["public", "home"] to include those that are public but not discoverable. The post checked is the one in body.note: for renote, the repost, whose author is the account that reposted. On note and edited the author is always the webhook's own account, so noteOrigin matters for reply, renote and mention.

The filters do not apply to follow, followed and unfollow, which carry no post, nor to notification, even when the notification is about a post: it is about the account, and filtering it on the post would drop a reaction or a poll result without the receiver ever knowing. The test button ignores them, so that it always shows the shape of an event.

The moderationAction event

A system webhook that ticks When a moderator takes an action is sent every action written to the moderation log, the one under Control Panel > Moderation logs, with type set to "moderationAction". Every action the log records is covered, those of forum moderators included, and an action added in a later version is sent without ticking anything new. The body is the log entry:

{
    "server": "https://example.social",
    "hookId": "…",
    "eventId": "…",
    "createdAt": 1761667200000,
    "type": "moderationAction",
    "body": {
        "id": "…",
        "createdAt": "2026-10-28T16:00:00.000Z",
        "type": "suspend",
        "moderatorId": "…",
        "moderator": { "id": "…", "username": "alice", "host": null, "…": "…" },
        "forumId": null,
        "info": { "userId": "…", "userUsername": "bob", "userHost": null }
    }
}

body.type names the action, with the names the log uses: suspend, unsuspend, approve, assignRole, suspendRemoteInstance, resolveAbuseReport and so on, the values admin/show-moderation-logs returns in type. forumId is set for an action taken in a forum. moderator is null when the moderator's account no longer exists by the time the event is sent. info is what the log stores for that action, with three exceptions, since a webhook takes it out of the server:

body.type info sent
updateServerSettings changedKeys, the names of the settings that changed, never their values. The keys and passwords the log stores redacted (captcha secrets, the SMTP password, the object storage keys and the like) are not named when they change, since the log does not keep enough to tell, so changedKeys can be empty. A change to the default role policies is sent the same way, with the names of the policies.
createSystemWebhook, updateSystemWebhook, deleteSystemWebhook systemWebhookId and name, never the webhook's URL or secret.
createInvitation invitations, each with its id and expiresAt but never its code.

Like a notification, a type you do not handle should be ignored and still answered with a 2xx. The event is not ticked on a new system webhook: every action is sent, a settings change or an emoji edit as much as a suspension, so filter on body.type on the receiving side.

The userPendingApproval event

When sign-ups need approval, all of them from Control Panel > Registration or those from an email domain held for approval, a system webhook that ticks When a sign-up is waiting for approval is sent each local sign-up that waits for a moderator, once the account exists: after the email address is confirmed, when the server asks for one. The body holds user, the account as the UserLite schema describes it, and reason, the text given at sign-up, or null. The account is sent as userCreated too, for a webhook that ticks both. An account an admin creates from the control panel is not sent.