Webhooks
Receiving and verifying a signed webhook. The body, the signature header, the replay window, choosing events, retries, the delivery log, and rotation.
A webhook is an HTTPS endpoint of yours that Telmoni posts a project's notices to
as signed JSON. You connect one from the project's Connectors page
(/{organization}/{project}/connectors), choose which notices it receives, and
it answers with a signing secret shown exactly once. This page is the receiver's
side of that contract: what arrives, how to verify it, and what your response
means.
On a self-hosted deployment without CONNECTOR_KEK, the webhook tile shows
Soon, with Connect turned off (see
Connectors & secret encryption).
The endpoint must be https://, carry no username or password, and be at most
2,048 characters. Connecting refuses an IP address that is private, loopback,
link-local (a cloud's metadata address among them), shared carrier space or
reserved, and a name such as localhost or one ending in .local or
.internal. Any other name is accepted without a lookup, and every send checks
the address it resolves to against the same ranges: a name that resolves only
to such addresses fails each send, and is retried like any other failure. A
project connects an endpoint once; connecting the same URL again is refused
(409), and the row's Rotate secret or Disconnect is the way to change
it. The signing secret is whsec_ followed by 64 hexadecimal characters.
What you receive
One POST per notice, Content-Type: application/json, with a body of exactly
this shape and nothing the notice did not carry (the values here are
illustrative):
{
"id": "019a0b8c-3f2e-7d41-9c5a-6e2b1f0d8a47",
"kind": "member_added",
"title": "Sam joined",
"body": "Sam accepted your invitation.",
"sent_at": "2027-01-15T12:00:00Z"
}Two headers matter. Telmoni-Signature is t=<unix seconds>,v1=<hex>: an
HMAC-SHA256, keyed with your signing secret, over the string {t}.{body} where
body is the exact bytes of the request. While a rotated secret is still
working it carries a second v1, one per secret (see
Rotating the secret). Telmoni-Delivery-Id is the same
value as the body's id, and it is stable across every retry and every resend
of one notice.
Verify the signature
Read the raw body before anything parses it. The signature is over the bytes sent, and a body that has been parsed and re-serialised will not match. Then, in order:
- Split the header on commas into
tand everyv1. - Refuse the delivery if
tis more than five minutes from your clock. - Compute HMAC-SHA256 with your secret over
{t}.{body}. - Accept the delivery if any
v1matches it, compared in constant time.
The scheme is the one Stripe uses, so a verifier you already have for Stripe needs only the header name and the secret changed.
Node:
import { createHmac, timingSafeEqual } from "node:crypto";
const REPLAY_TOLERANCE_SECS = 300;
export function verifyTelmoniSignature(secret, header, rawBody) {
let t;
const candidates = [];
for (const part of header.split(",")) {
const eq = part.indexOf("=");
if (eq < 0) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === "t") t = Number(value);
else if (key === "v1") candidates.push(value);
}
if (t === undefined || !Number.isInteger(t) || candidates.length === 0) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > REPLAY_TOLERANCE_SECS) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody) // the exact bytes received, never a re-serialised parse
.digest();
return candidates.some((v1) => {
const given = Buffer.from(v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}Python:
import hmac, hashlib, time
REPLAY_TOLERANCE_SECS = 300
def verify_telmoni_signature(secret: str, header: str, raw_body: bytes) -> bool:
t, candidates = None, []
for part in header.split(","):
key, _, value = part.partition("=")
if key == "t":
t = value
elif key == "v1":
candidates.append(value)
try:
t = int(t)
except (TypeError, ValueError):
return False
if not candidates or abs(int(time.time()) - t) > REPLAY_TOLERANCE_SECS:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).digest()
for v1 in candidates:
try:
if hmac.compare_digest(bytes.fromhex(v1), expected):
return True
except ValueError:
continue
return FalseTest your verifier before you trust it
With the secret
whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef, the
header
t=1800000000,v1=396556d0e74d35a762e7d75d387e1a7dcb4c728ffcc9bc857830a03f13dd740fis the correct signature over this body, byte for byte, at t=1800000000:
{"id":"019a0b8c-3f2e-7d41-9c5a-6e2b1f0d8a47","kind":"member_added","title":"Sam joined","body":"Sam accepted your invitation.","sent_at":"2027-01-15T12:00:00Z"}A verifier that accepts that triple (standing at that timestamp) and refuses it
with one byte of the body changed computes the signature as Telmoni does. That
secret is a published test fixture; a real one is whsec_ followed by 32 random
bytes in hex, minted for each endpoint and at each rotation, and the whole
string, whsec_ included, is the HMAC key.
The replay window
Refuse any delivery whose t is more than five minutes from your own clock.
That bounds how long a captured request stays useful to whoever captured it.
Every attempt, each retry and resend included, is signed when it is sent: t is
the moment that request left Telmoni, not the moment the notice was raised, so a
retry after a long backoff carries a fresh t and a fresh signature. sent_at
in the body is the same instant, for your logs.
Choosing events
A webhook receives every kind of notice unless you choose. On the Connectors
page, pick All events or Selected events when you connect it, and
change the choice later with Events on its row. All events includes
kinds added after you connected; a selection is exactly the kinds you switched
on. A
change applies from the next notice. The body's kind is one of:
kind | Sent when |
|---|---|
member_added | Someone joined the project: they accepted an invitation, or an organization Owner or Admin added them from the organization. |
member_left | Someone left or was removed from the project. |
connector_connected | A destination was connected to the project. |
connector_disconnected | A destination stopped accepting notices. |
organization_alert | A notice for the organization's Owner and Admins, posted to the organization rather than a project: on the hosted service, once a day while the plan's ceiling refuses the organization its data out past the day's, its metadata naming the lane (data_out), the count refused and since when; a self-hosted deployment raises none. One goes to every connection in the organization that takes the kind. |
Slack channels always receive every kind.
Send test on the webhook's row posts one signed connector_connected body
titled "Webhook is connected", whatever events you chose. It is not in the
delivery log, and a 410 answer to it marks the webhook Needs attention
as any other would.
What your response means
Any 2xx means delivered. Return it as soon as you have stored the event, and do your real work afterwards: the request has a ten-second budget, and work done inside it is work a slow database turns into a retry.
Anything else is retried, up to five attempts: after 30 seconds, then 2, 8
and 32 minutes, roughly three quarters of an hour in all. These are the
defaults, which the hosted service uses and a self-hosted operator can change
(NOTIFICATIONS_DELIVERY_* in the configuration
reference). A Retry-After
header is honoured as a floor (you can ask for longer, never for sooner), capped
at a day. A 404 during your deploy and a 500 under your load both come back;
Telmoni does not give up on your endpoint over one bad answer.
Except 410 Gone. That status means what it says in the standard: the endpoint has left, permanently. One 410 marks the webhook Needs attention at once and fails every notice queued for it. Do not return it from a path you are moving or a proxy you are reconfiguring; return 404 or 503 and let the retries carry you. A webhook marked Needs attention is brought back by rotating its secret from the Connectors page. No notice is queued for it while it is marked, so the notices raised meanwhile are not delivered when it comes back.
Ten failed sends in a row also mark the endpoint Needs attention, and the project's other channels are told. Every scheduled attempt counts, so with the default five attempts, two notices that each run through them trip it; one bad hour on one notice does not. One success anywhere in that run resets the count. A failed resend from the delivery log, or a failed Send test, does not count toward it.
Expect duplicates. A retry after a timeout you had actually served carries
the same Telmoni-Delivery-Id, and so does a resend of something you already
have: deduplicate on it.
Rotating the secret
Select Rotate secret on the webhook's row on the Connectors page. A new secret is generated and displayed once.
You choose how long the old secret keeps signing beside the new one: Keep for
24 hours (the default), Keep for 1 hour, or Stop immediately for a
secret that has leaked. During the overlap, the Telmoni-Signature header
carries two v1 signatures, the new secret's first, so a receiver holding
either secret verifies each delivery while you deploy the new one.
Delivery logs and resending
Every delivery is kept, with each of its attempts, for 30 days after it was last delivered or failed. From the Connectors page, you can inspect past deliveries, view response status codes and timing, and trigger a Resend of one. The log opens 25 deliveries at a time, with Load older for the rest, and marks each attempt Scheduled or Manual resend. A delivery can be resent up to ten times, while its webhook is Connected; each resend is written to the project's audit log. Every role on the project reads the log, and the Owner and Admins resend. Disconnecting a webhook deletes its log.