Wiki / Core Features / Outgoing Webhooks

Outgoing Webhooks

Updated by Maxime_48 · 1 hour ago · 4 views

A webhook sends your team's activity to a URL you own, the moment it happens. Every action that lands in your Audit Log can be pushed as a signed JSON POST to your own server, your own bot, or an internal tool — no polling, no export, no scraping the panel.

One rule decides everything on this page. A webhook carries exactly what your team's Activity Log shows, and nothing more. Not a parallel feed with its own scope, not a richer payload for machines. If it is not on that page, it is not in the body.

An active team webhook with its delivery categories and last delivery time


📍 Where to find it

Team settings → Webhooks, under your team's own page. Up to three endpoints per team, each with its own URL, its own categories and its own signing secret.

Who may set one up: the team owner, or a member whose role includes Remove a server from the team. That is the same rule that opens the Activity Log itself — a webhook forwards what that page renders, so being allowed to read it is being allowed to forward it. See Team roles and permissions.


🎯 What gets sent, and what never does

You pick the categories. Only the ones you tick are delivered:

Category What it covers
Features A feature enabled, disabled, or its settings saved
Servers A server added to or removed from the team
Team Team renamed, roles edited, webhooks changed
Members Members invited, added, removed, or leaving
Moderation Sanctions, appeals, reports, tickets, cleared histories
Subscription Subscription changes on the team
Billing Billing events on the team
Platform actions on this team What a platform admin did to one of your servers

🚫 Three things never travel

  • IP addresses. On the Activity Log an IP is shown to the person it belongs to and to nobody else. A webhook has no reader to check against, so the field is not sent — it is removed from the payload, not blanked.
  • Backoffice actions that are not about you. Platform-wide administration carries no team, so nothing in the fan-out can reach it. The Platform actions on this team category is only what an admin did to your servers.
  • Credentials. Any field that looks like a token, a key or a secret arrives as ••••••••, on both sides of a before/after pair. This happens again on the way out, independently of the masking the page does.

Sign-ins are not offered. The audit trail has an auth category, and it is deliberately absent from the list above: pushing sign-in events to a third-party server, unattended and indefinitely, is a different act from showing them on a page somebody had to open. It may be added later.


📦 The delivery

Each delivery is a single POST with a JSON body:

{
  "version": 1,
  "delivery_id": "0a4b2f0e-8c6e-4a5f-9b3d-2f6c1e0d7a11",
  "event": "server.feature.enabled",
  "category": "feature",
  "team": { "id": 12, "name": "Night Shift" },
  "data": {
    "id": 918273,
    "action": "server.feature.enabled",
    "category": "feature",
    "description": "Enabled Starboard",
    "actor_name": "Ava",
    "actor_role": "moderator",
    "is_admin_action": false,
    "team_id": 12,
    "team_name": "Night Shift",
    "server_id": 4,
    "server_name": "Night Shift HQ",
    "subject_label": "Starboard",
    "changes": { "threshold": { "before": 3, "after": 5 } },
    "metadata": null,
    "created_at": "2026-09-06T09:41:12+00:00"
  }
}

Those fifteen fields are the whole of data — the same fifteen the Activity Log renders, minus the IP address. Any of them can be null: an action with no server carries "server_id": null, an action with nothing to diff carries "changes": null. Read them defensively rather than assuming a shape per event.

  • version is the envelope version. It exists so your receiver can branch on it instead of guessing when the shape grows.
  • event is the audited action. There are over a hundred and thirty of them and the list grows with the platform — match on a prefix or on category, never on an exhaustive list of names.
  • delivery_id is stable across every retry of the same event. It is what makes deduplication possible; see below.
  • data is the audit entry itself, the same object the Activity Log renders.

📨 Headers

Header Value
User-Agent YAWBDB-Webhooks/1.0
X-YAWBDB-Event The action name, same as event in the body
X-YAWBDB-Delivery The delivery id, same as delivery_id
X-YAWBDB-Timestamp Unix time in seconds, when the request was built
X-YAWBDB-Signature v1= followed by the hex digest

🔐 Verifying the signature

Anyone who learns your endpoint URL can POST anything to it. The signature is how you tell our deliveries from theirs.

The digest is HMAC-SHA256 over the timestamp, a dot, then the raw request body, keyed with your signing secret:

signed_string = X-YAWBDB-Timestamp + "." + raw_body
signature     = "v1=" + hex( hmac_sha256(signed_string, secret) )

Three details are load-bearing, and skipping any of them leaves a hole:

  • Sign the raw bytes, before any JSON parsing. Re-serialising the body changes it — key order, escaped slashes, unicode — and the digest will not match.
  • The timestamp is inside the signed string, not merely alongside it. That is what lets you reject replays: refuse a delivery whose timestamp is more than a few minutes old (five minutes is a sensible window) and an attacker cannot re-send a request they captured last week. Because the timestamp is signed, they cannot edit it either.
  • Compare in constant time. A plain == returns on the first differing byte, and that timing difference is enough to recover a signature one byte at a time. Use hash_equals, crypto.timingSafeEqual, hmac.compare_digest — whatever your language calls it.

PHP

$raw       = file_get_contents('php://input');
$timestamp = (int) ($_SERVER['HTTP_X_YAWBDB_TIMESTAMP'] ?? 0);
$given     = $_SERVER['HTTP_X_YAWBDB_SIGNATURE'] ?? '';

if (abs(time() - $timestamp) > 300) {
    http_response_code(400);   // too old, or clock is wrong
    exit;
}

$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);

if (!hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

http_response_code(200);       // accepted

Node.js (Express)

import crypto from 'node:crypto';

// The raw body is required — express.json() alone destroys it.
app.post('/yawbdb', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = Number(req.get('X-YAWBDB-Timestamp'));
  const given     = req.get('X-YAWBDB-Signature') ?? '';

  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);

  const expected = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${req.body}`)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(given);

  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  res.sendStatus(200);
});

The v1= prefix names the scheme. If it ever changes, a receiver that checks the prefix can refuse an unknown one deliberately instead of silently mis-reading it.

🔑 The secret

64 hexadecimal characters, generated for you, shown exactly once — in the panel, right after you create the endpoint or press New secret. Nothing reads it back afterwards: not the page, not the API, not an export. Lose it and you generate a new one.

Rotating takes effect immediately for everything sent from that moment. Deliveries already queued keep going out signed with the old secret until the queue drains, so expect a short overlap and update your receiver first.


🔁 Delivery, retries and deduplication

  • Only a 2xx counts as received. Anything else — 3xx, 4xx, 5xx — is a failure. Answer fast and do the work afterwards.
  • Redirects are not followed. A 302 is a failure, not a hop. Give us the final URL.
  • Timeouts are short: three seconds to connect, five seconds to answer. This is a notification, not a remote procedure call — acknowledge first, process later.
  • Four attempts, spaced 30 seconds, 2 minutes, then 10 minutes: roughly twelve minutes end to end. That covers a deploy, not an outage.
  • Retries reuse the same delivery_id. A receiver that treats every POST as new will double-count the day our first attempt succeeds on your side but times out on ours. Store the id and ignore one you have already handled.
  • Order is not guaranteed. Deliveries are queued independently; a retried one arrives after events that came later. Use data.created_at if order matters to you.

⛔ Automatic switch-off

After 20 consecutive failures, the endpoint is switched off and the panel says so on the card. An endpoint that stopped answering usually stopped for good, and nobody comes to tell us — retrying for ever would cost a request and a history row per action, indefinitely.

To restart it: fix your receiver, then Edit the webhook and tick Deliver events to this endpoint again. Turning it back on is what clears the counter, so a repaired endpoint is not one failure away from going dark again.


🧪 Send a test

Send a test fires one real, signed request at your endpoint immediately and shows you the status code and the round-trip time. It is not a simulation: it goes through the same code every real delivery goes through, with the same headers, so an endpoint that passes the test cannot then reject production traffic over a header the test never sent.

The test body carries "event": "panel.webhook.test" and "category": "test", so your receiver can recognise it.

Expect two arrivals, not one. Pressing the button is itself an audited team action. So your endpoint gets the test delivery, and — if it subscribes to the Team category — a second, ordinary delivery for team.webhook.tested a moment later. That is correct, and it is not a loop.

A test never counts towards the 20-failure ceiling, and never clears it. Debugging an endpoint cannot switch it off.


📜 Recent attempts

Recent attempts shows the last 20 tries for that endpoint: when, which event, which attempt number, the status code or the error, and how long it took. Failures carry a short excerpt of whatever your server answered — which is usually the fastest way to find out that a reverse proxy, not your code, is the one saying no.

Attempts are kept for 30 days and pruned nightly. This is debugging exhaust, not a record: the audit trail itself has its own, much longer retention.


🛡️ Where we refuse to send

An endpoint URL must point at the public internet. A URL that resolves to a loopback address, a private range, a link-local address or a cloud metadata address is refused — when you save it, and again before every single send.

The second check is the one that matters over time. A name that pointed somewhere public the day it was typed can point at 127.0.0.1 a month later, and a webhook fires for as long as it exists. A refusal counts as a failure for the endpoint and is not retried: asking the resolver the same question three more times would change nothing.

Prefer an https endpoint. The signature proves who sent the body, it does not hide it — over plain http, your activity travels in clear.


💡 What people build with it

  • Post your team's activity into a Discord channel of your own, formatted your way, with your own filters.
  • Mirror the trail into your own log store and keep it past the panel's retention window.
  • Page someone when a moderation action lands out of hours.
  • Trigger a build, a sync, or a backup when a feature setting changes.

🔗 See also