Notifications

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:

  1. Split the header on commas into t and every v1.
  2. Refuse the delivery if t is more than five minutes from your clock.
  3. Compute HMAC-SHA256 with your secret over {t}.{body}.
  4. Accept the delivery if any v1 matches 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 False

Test your verifier before you trust it

With the secret whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef, the header

t=1800000000,v1=396556d0e74d35a762e7d75d387e1a7dcb4c728ffcc9bc857830a03f13dd740f

is 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:

kindSent when
member_addedSomeone joined the project: they accepted an invitation, or an organization Owner or Admin added them from the organization.
member_leftSomeone left or was removed from the project.
connector_connectedA destination was connected to the project.
connector_disconnectedA destination stopped accepting notices.
organization_alertA 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.