Skip to content

Webhooks

Webhooks allow you to build custom integrations that react automatically to platform events on your account. When an event occurs, the platform dispatches a signed HTTP POST request containing a JSON payload directly to your configured endpoint.

Everything is on your Webhooks page (/webhooks, on the console sidebar rail), and it takes two steps:

  1. Add an endpoint under Endpoints.
  2. Switch on the events you want under Events.

Every kind starts off and receives nothing until you turn it on. The switches govern this page’s destinations: your endpoints and your Slack or Discord integrations.

  • Secret allocation: Each webhook endpoint receives a unique signing secret prefixed with whsec_ when created. The secret is shown only once and is used to cryptographically verify that payloads originate from the platform.
  • Protocol constraint: Webhook URLs must use HTTPS.
  • SSRF prevention: To prevent Server-Side Request Forgery (SSRF), the platform prohibits webhook URLs targeting private or loopback IP ranges (localhost, 127.0.0.1, RFC 1918 subnets like 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, or link-local 169.254.169.254).
  • Scope: Endpoints and event switches both belong to the account. Only the owner registers or removes one.

Every delivery carries the same JSON shape — the event kind plus the human-readable notification it mirrors (this example is representative):

{
"id": "0191e6c2-6a4e-7d0b-9c3a-2f1b0e7d8a55",
"kind": "member_added",
"title": "You joined @alex",
"body": "@alex added you to their account as editor.",
"metadata": {}
}

id is the event’s, and it is the same on every delivery of that event. We deliver at least once: a request that reached you but whose acknowledgement was lost is sent again with a fresh signature, so deduplicate on id (or on the Tormoni-Delivery-Id header, which names the delivery row and is likewise stable across its retries).

This table is the whole list of what a webhook receives today:

Event Kind Meaning
member_added Somebody added you to their account.
subscription_payment_failed A subscription payment did not go through.

Every kind is off until you switch it on, per kind, on the Webhooks page. Nothing is sent to an endpoint you have not opted in for.

A push that only repeats a run already stored is a retry, not a new run, and raises no event.

Every webhook request includes a Tormoni-Signature header in the format t=<unix_timestamp>,v1=<hex_hmac>. The signature is a SHA-256 HMAC of the timestamp and JSON body (t.body). A retried delivery carries a new timestamp and signature over the same body.

const crypto = require('crypto');
function verifyWebhook(secret, header, rawBody) {
const parts = header.split(',');
const t = parts.find(p => p.startsWith('t=')).split('=')[1];
const v1 = parts.find(p => p.startsWith('v1=')).split('=')[1];
if (!t || !v1) throw new Error('Invalid signature format');
// Prevent replay attacks (5 minute tolerance)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(t, 10)) > 300) {
throw new Error('Signature timestamp expired');
}
const signatureBase = `${t}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signatureBase)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(v1, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
}