Webhooks: a signed notice when a document is created or shared

Account menu → Webhooks registers a URL that gets a signed POST for two events, document.created and document.shared. Session-only to manage, on purpose — it stays off the scriptable API, so a leaked API key cannot turn into a standing feed of every document that comes after it.

Two things happen on an account that another program might want to know about the moment they happen: a document was created, and a document was shared. Account menu → Webhooks is where you name an https:// URL that should hear about them, and read the secret its deliveries are signed with.

What arrives

A POST whose JSON body has three keys — the event, the time, and the data:

{
  "event": "document.created",
  "created_at": "2026-09-11T09:12:44.000Z",
  "data": { "id": "…", "name": "release-notes.md", "kind": "markdown-to-html", "size": 4193 }
}

document.shared carries the id and name, the mode the document is now in, the share url, and notified, the addresses a share notice actually went to. Neither event carries the document's text: a receiver that needs it has the id and an API key.

Checking the signature

Each delivery has an x-transformpipe-signature header shaped t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 over the string <t>.<body>, keyed with this webhook's secret — the shape Stripe and GitHub both use, so verification code you already have usually needs only a different secret.

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

// `raw` is the body as it arrived. A parsed and re-encoded copy will not hash the same.
export function verify(raw, header, secret) {
  const [t, v1] = header.split(',').map((part) => part.split('=')[1]);
  const want = createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex');

  return (
    v1.length === want.length &&
    timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(want, 'hex'))
  );
}

Reject a t more than a few minutes old and a captured delivery cannot be replayed at you later. The secret begins whsec_ and can be read again from the dialog whenever you need it — unlike an API key, it is presented by us to you, so seeing it twice while setting a receiver up is legitimate rather than a leak.

One attempt, no queue

A delivery is one request with a five-second timeout. There is no retry and no queue: a receiver that is down misses that event, and the next event tries again on its own. The dialog shows the last status, or the last error, for every URL. Redirects are not followed, and the address is checked as a public one again at the moment of posting rather than only when it was registered.

Why an API key cannot register one

Webhooks are managed from a signed-in session only. A key that could register a webhook would turn a point-in-time leak into a standing feed of every document that came after it, which is a much worse thing to lose than a key.

Related: converting documents with an API, and publishing from GitHub Actions.