# Receive webhooks

A webhook sends each event as a signed `POST` to your HTTPS URL, so your server or workflow reacts when notes change without polling.

## Before you start

- Pro with Sync turned on.
- A connection that reads: Read only, Read and add or Edit and organize.
- What arrives by default: changes the owner makes in ThreadPage. Notes your own token adds never reach your own webhooks, and changes made through other connections aren’t sent unless you add their origins.

## Try it first

1. In ThreadPage, open **Settings → Connections** and set up **Note updates** for a connection that reads. Paste any public `https` URL that shows the requests it gets, and copy the signing secret: it is shown once.
2. ThreadPage sends that URL a **Test event** at once. It is signed like every event, and its `data.test` is `true`.
3. In ThreadPage, add a note or change one in a thread the connection can read. About 30 seconds after your last edit, the event arrives. **Deliveries** in the connection’s details shows the attempt and your URL’s answer.

## Run a receiver

A receiver reads the raw body, verifies the signature before it parses any JSON, drops repeats by `webhook-id`, answers `204` at once and handles the event afterwards.

curl:

```bash
#!/usr/bin/env bash
# Sends your local receiver a webhook signed as ThreadPage signs it, without a public address.
# Needs bash, curl, jq, openssl, base64 and xxd. Start the receiver with the same secret, then:
#   bash send_signed.sh event.json http://localhost:8080/
# event.json: one event, for example from GET /events/sample (jq -c '.events[0]').
set -euo pipefail
SECRET=${THREADPAGE_WEBHOOK_SECRET:-whsec_dGhyZWFkcGFnZS1kb2NzLXRlc3QtdmVjdG9yLWtleSE=}

BODY=$(jq -c . "$1")
ID=$(jq -r .id <<<"$BODY")
TS=$(date +%s)
KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
SIGNATURE=$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary | base64)
curl -sS -i "$2" \
  -H "Content-Type: application/json" \
  -H "webhook-id: $ID" \
  -H "webhook-timestamp: $TS" \
  -H "webhook-signature: v1,$SIGNATURE" \
  --data-binary "$BODY"
```

JavaScript (receiver.mjs):

```javascript
// A ThreadPage webhook receiver: it verifies the signature on the raw body, drops repeats,
// answers 204 at once and handles the event afterwards.
// Run with Node.js 24: THREADPAGE_WEBHOOK_SECRET=whsec_… node receiver.mjs
// It listens on PORT (8080 unless set).
import crypto from 'node:crypto';
import http from 'node:http';
import { pathToFileURL } from 'node:url';

const TOLERANCE = 5 * 60; // seconds between webhook-timestamp and now

// secret: the whsec_… value. headers: the request's headers, lowercase names. body: the raw
// body as a Buffer, before any JSON parsing. now: seconds since 1970, for the test vector.
export function verify(secret, headers, body, now = Date.now() / 1000) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'] ?? '';
  const signatures = headers['webhook-signature'] ?? '';
  if (!id || !/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > TOLERANCE) return false;
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const digest = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.`).update(body).digest('base64');
  const expected = Buffer.from(`v1,${digest}`);
  // During a secret rotation the header carries two signatures, separated by a space.
  return signatures.split(' ').some(value => {
    const given = Buffer.from(value);
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

const handled = new Set(); // webhook-ids already handled; keep them in a database in production

function handle(event) {
  const { data } = event;
  console.log(event.type, data.note?.id ?? data.thread?.id ?? data.folder?.id ?? data.request?.id);
}

function serve(secret, port) {
  http.createServer((request, response) => {
    const chunks = [];
    request.on('data', chunk => chunks.push(chunk));
    request.on('end', () => {
      const body = Buffer.concat(chunks);
      if (request.method !== 'POST' || !verify(secret, request.headers, body)) {
        response.writeHead(400).end(); // never 401 or 403: those pause the webhook
        return;
      }
      response.writeHead(204).end(); // answer within 15 seconds; work afterwards
      const id = request.headers['webhook-id'];
      if (handled.has(id)) return; // the same event again
      handle(JSON.parse(body));
      handled.add(id);
    });
  }).listen(port);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const secret = process.env.THREADPAGE_WEBHOOK_SECRET;
  if (!secret?.startsWith('whsec_')) throw new Error('Set THREADPAGE_WEBHOOK_SECRET to the whsec_… secret.');
  serve(secret, Number(process.env.PORT ?? 8080));
}
```

JavaScript (vector.mjs):

```javascript
// Checks verify() in receiver.mjs against ThreadPage's published test vector: node vector.mjs
import assert from 'node:assert/strict';
import { verify } from './receiver.mjs';

const secret = 'whsec_dGhyZWFkcGFnZS1kb2NzLXRlc3QtdmVjdG9yLWtleSE=';
const headers = {
  'webhook-id': 'evt_7m2kq9v4x8p1r6t3w5y0z2b4nc',
  'webhook-timestamp': '1761904200',
  'webhook-signature': 'v1,JS6sm/+dgftSURDnm00D8dvKSyW5vBAe6uL0lStjqNo=',
};
const body = Buffer.from('{"specversion":"1.0","id":"evt_7m2kq9v4x8p1r6t3w5y0z2b4nc","type":"note.created"}');

// The vector's timestamp is in the past, so check it as of that moment.
assert.equal(verify(secret, headers, body, 1761904200), true);
assert.equal(verify(secret, headers, Buffer.concat([body, Buffer.from('\n')]), 1761904200), false);
assert.equal(verify(secret, headers, body), false); // too old today
console.log('The test vector verifies.');
```

Python (receiver.py):

```python
# A ThreadPage webhook receiver: it verifies the signature on the raw body, drops repeats,
# answers 204 at once and handles the event afterwards.
# Run with Python 3.13 (standard library only): THREADPAGE_WEBHOOK_SECRET=whsec_… python receiver.py
# It listens on PORT (8080 unless set).
import base64
import hashlib
import hmac
import json
import os
import queue
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

TOLERANCE = 5 * 60  # seconds between webhook-timestamp and now

def verify(secret: str, headers, body: bytes, now: float | None = None) -> bool:
    """secret: the whsec_… value. headers: the request's headers. body: the raw body, before any
    JSON parsing. now: seconds since 1970, for the test vector."""
    message_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")
    if not message_id or not (timestamp.isascii() and timestamp.isdigit()):
        return False
    if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{message_id}.{timestamp}.".encode() + body
    expected = b"v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
    # During a secret rotation the header carries two signatures, separated by a space.
    signatures = headers.get("webhook-signature", "").split()
    return any(hmac.compare_digest(value.encode(), expected) for value in signatures)

received = queue.Queue()
handled = set()  # webhook-ids already handled; keep them in a database in production

def handle(event):
    data = event["data"]
    item = data.get("note") or data.get("thread") or data.get("folder") or data.get("request") or {}
    print(event["type"], item.get("id"), flush=True)

def work():
    while True:
        message_id, body = received.get()
        if message_id in handled:
            continue  # the same event again
        handle(json.loads(body))
        handled.add(message_id)

class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if not verify(os.environ["THREADPAGE_WEBHOOK_SECRET"], self.headers, body):
            self.send_response(400)  # never 401 or 403: those pause the webhook
            self.end_headers()
            return
        self.send_response(204)  # answer within 15 seconds; work afterwards
        self.end_headers()
        received.put((self.headers["webhook-id"], body))

if __name__ == "__main__":
    if not os.environ.get("THREADPAGE_WEBHOOK_SECRET", "").startswith("whsec_"):
        raise SystemExit("Set THREADPAGE_WEBHOOK_SECRET to the whsec_… secret.")
    threading.Thread(target=work, daemon=True).start()
    ThreadingHTTPServer(("", int(os.environ.get("PORT", "8080"))), Receiver).serve_forever()
```

Python (vector.py):

```python
# Checks verify() in receiver.py against ThreadPage's published test vector: python vector.py
from receiver import verify

secret = "whsec_dGhyZWFkcGFnZS1kb2NzLXRlc3QtdmVjdG9yLWtleSE="
headers = {
    "webhook-id": "evt_7m2kq9v4x8p1r6t3w5y0z2b4nc",
    "webhook-timestamp": "1761904200",
    "webhook-signature": "v1,JS6sm/+dgftSURDnm00D8dvKSyW5vBAe6uL0lStjqNo=",
}
body = b'{"specversion":"1.0","id":"evt_7m2kq9v4x8p1r6t3w5y0z2b4nc","type":"note.created"}'

# The vector's timestamp is in the past, so check it as of that moment.
assert verify(secret, headers, body, now=1761904200)
assert not verify(secret, headers, body + b"\n", now=1761904200)
assert not verify(secret, headers, body)  # too old today
print("The test vector verifies.")
```

Your framework must hand you the raw body, byte for byte:

| Framework | Raw body |
|---|---|
| Express | `express.raw({type: 'application/json'})` |
| Next.js Route Handler | `await req.text()` |
| Flask | `request.get_data()` |
| FastAPI | `await request.body()` |

Verify before parsing. Answer 400 to a bad signature, never 401 or 403: those pause the webhook.

## Give it a public HTTPS address

ThreadPage delivers only to public `https` URLs, and refuses `localhost` and private addresses. While you develop, use one of these:

- An HTTPS tunnel to your machine, such as Cloudflare Tunnel or ngrok. Use its URL as the webhook’s.
- Sign requests yourself. Start your receiver with the test vector’s secret (below) and send it events signed the same way, for example one from `GET /events/sample`:

```bash
BODY=$(jq -c . "$1")
ID=$(jq -r .id <<<"$BODY")
TS=$(date +%s)
KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
SIGNATURE=$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary | base64)
curl -sS -i "$2" \
  -H "Content-Type: application/json" \
  -H "webhook-id: $ID" \
  -H "webhook-timestamp: $TS" \
  -H "webhook-signature: v1,$SIGNATURE" \
  --data-binary "$BODY"
```

- A [pull feed](https://thread.page/help/connections/feed.md) instead of a webhook. It needs no public address.

## Add the webhook

In ThreadPage, the owner sets up **Note updates** with your URL. They choose the [groups of events](https://thread.page/help/connections/events.md#groups-in-settings), whether events carry note text and Markdown or only IDs and links, and whose changes to send.

Through the API, send `POST /subscriptions` with `"kind": "webhook"`, a `url` and the `event_types` to hear. It works only when the owner turned on **Let the script add note-update URLs through the API** in the connection’s details; otherwise `403` `webhooks_not_allowed`. A token’s webhook carries IDs only and hears the owner’s changes only. Note text and other origins are the owner’s to add in ThreadPage.

See [Subscriptions API](https://thread.page/help/connections/events.md#subscriptions-api).

## Verify the signature

ThreadPage signs with [Standard Webhooks](https://www.standardwebhooks.com). Each request has three headers:

| Header | Value |
|---|---|
| `webhook-id` | The event’s `id`, the same in every attempt. |
| `webhook-timestamp` | Seconds since 1970 when this attempt was signed. |
| `webhook-signature` | `v1,` and the base64 HMAC-SHA256 of `webhook-id.webhook-timestamp.body`, keyed with the base64-decoded part of the secret after `whsec_`. Two signatures, separated by a space, while a secret is replaced. |

Refuse a timestamp more than 5 minutes from now.

The Standard Webhooks libraries do this in one call. They are published as `standardwebhooks` on npm and on PyPI, and take the secret as ThreadPage shows it, `whsec_` included:

JavaScript:

```javascript
import { Webhook } from 'standardwebhooks';

const webhook = new Webhook(process.env.THREADPAGE_WEBHOOK_SECRET);
const event = webhook.verify(rawBody, headers); // throws when the signature is wrong
```

Python:

```python
import os
from standardwebhooks.webhooks import Webhook

webhook = Webhook(os.environ["THREADPAGE_WEBHOOK_SECRET"])
event = webhook.verify(raw_body, headers)  # raises when the signature is wrong
```

Without a library:

JavaScript (receiver.mjs):

```javascript
// secret: the whsec_… value. headers: the request's headers, lowercase names. body: the raw
// body as a Buffer, before any JSON parsing. now: seconds since 1970, for the test vector.
export function verify(secret, headers, body, now = Date.now() / 1000) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'] ?? '';
  const signatures = headers['webhook-signature'] ?? '';
  if (!id || !/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > TOLERANCE) return false;
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const digest = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.`).update(body).digest('base64');
  const expected = Buffer.from(`v1,${digest}`);
  // During a secret rotation the header carries two signatures, separated by a space.
  return signatures.split(' ').some(value => {
    const given = Buffer.from(value);
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}
```

Python (receiver.py):

```python
def verify(secret: str, headers, body: bytes, now: float | None = None) -> bool:
    """secret: the whsec_… value. headers: the request's headers. body: the raw body, before any
    JSON parsing. now: seconds since 1970, for the test vector."""
    message_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")
    if not message_id or not (timestamp.isascii() and timestamp.isdigit()):
        return False
    if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{message_id}.{timestamp}.".encode() + body
    expected = b"v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
    # During a secret rotation the header carries two signatures, separated by a space.
    signatures = headers.get("webhook-signature", "").split()
    return any(hmac.compare_digest(value.encode(), expected) for value in signatures)
```

### Test vector

Check your code against these values before you connect it:

|  |  |
|---|---|
| secret | `whsec_dGhyZWFkcGFnZS1kb2NzLXRlc3QtdmVjdG9yLWtleSE=` |
| `webhook-id` | `evt_7m2kq9v4x8p1r6t3w5y0z2b4nc` |
| `webhook-timestamp` | `1761904200` |
| body | `{"specversion":"1.0","id":"evt_7m2kq9v4x8p1r6t3w5y0z2b4nc","type":"note.created"}` |
| `webhook-signature` | `v1,JS6sm/+dgftSURDnm00D8dvKSyW5vBAe6uL0lStjqNo=` |

The timestamp is in the past, so turn off the time check for this vector, or check it as of `1761904200`, as the examples do:

JavaScript:

```javascript
// Checks verify() in receiver.mjs against ThreadPage's published test vector: node vector.mjs
import assert from 'node:assert/strict';
import { verify } from './receiver.mjs';

const secret = 'whsec_dGhyZWFkcGFnZS1kb2NzLXRlc3QtdmVjdG9yLWtleSE=';
const headers = {
  'webhook-id': 'evt_7m2kq9v4x8p1r6t3w5y0z2b4nc',
  'webhook-timestamp': '1761904200',
  'webhook-signature': 'v1,JS6sm/+dgftSURDnm00D8dvKSyW5vBAe6uL0lStjqNo=',
};
const body = Buffer.from('{"specversion":"1.0","id":"evt_7m2kq9v4x8p1r6t3w5y0z2b4nc","type":"note.created"}');

// The vector's timestamp is in the past, so check it as of that moment.
assert.equal(verify(secret, headers, body, 1761904200), true);
assert.equal(verify(secret, headers, Buffer.concat([body, Buffer.from('\n')]), 1761904200), false);
assert.equal(verify(secret, headers, body), false); // too old today
console.log('The test vector verifies.');
```

Python:

```python
# Checks verify() in receiver.py against ThreadPage's published test vector: python vector.py
from receiver import verify

secret = "whsec_dGhyZWFkcGFnZS1kb2NzLXRlc3QtdmVjdG9yLWtleSE="
headers = {
    "webhook-id": "evt_7m2kq9v4x8p1r6t3w5y0z2b4nc",
    "webhook-timestamp": "1761904200",
    "webhook-signature": "v1,JS6sm/+dgftSURDnm00D8dvKSyW5vBAe6uL0lStjqNo=",
}
body = b'{"specversion":"1.0","id":"evt_7m2kq9v4x8p1r6t3w5y0z2b4nc","type":"note.created"}'

# The vector's timestamp is in the past, so check it as of that moment.
assert verify(secret, headers, body, now=1761904200)
assert not verify(secret, headers, body + b"\n", now=1761904200)
assert not verify(secret, headers, body)  # too old today
print("The test vector verifies.")
```

### Replacing the secret

**Replace secret…** on the webhook gives it a new secret, shown once. For 24 hours each request carries two signatures, one with each secret, so your check passes with either while you update your workflow; then only the new secret signs. A second replacement within those 24 hours stops the oldest secret at once.

## Respond

Answer with any `2xx` status within 15 seconds, and do slow work afterwards. Any other answer, or one that comes later, counts as a failure.

## Delivery behavior

- Delivery is at least once. The same event can arrive more than once, with the same `webhook-id`, equal to the event’s `id`, in every attempt.
- Order is not guaranteed. Compare `time`, and a note’s `content_revision`, before you replace newer text with older.
- Redirects are not followed.
- Certificates are checked strictly. A certificate from a private authority whose own certificate lacks the `keyUsage` extension is refused; public authorities are not affected.
- ThreadPage doesn’t publish fixed sending addresses; verify the signature instead.

## Retries

After a failure, ThreadPage tries again after 5 seconds, 5 minutes, 30 minutes, then 2, 5, 10, 14, 20 and 24 hours: ten attempts over about three days. A `Retry-After` in your answer is honoured for up to an hour.

| Your answer | What ThreadPage does |
|---|---|
| `2xx` within 15 seconds | Delivered. |
| No answer in 15 seconds, a network or TLS error, a redirect, `5xx`, `429` or another `4xx` | Tries again on the schedule above. |
| `401` or `403` | Pauses the webhook until it is resumed. Resuming sends that event again. |
| `410` | Stops the webhook (`expired`). |
| `413` | Skips that event. |

## Paused, expired and limits

- A paused webhook keeps its events for up to 10 days and sends them when it is resumed.
- While the connection can’t read, needs a new token, has no Pro or is disconnected, nothing is sent, and events from that time are not sent later.
- An account has up to 50 live webhooks. Together they deliver about 20,000 events a day; past that they pause until midnight UTC and show **Paused until tomorrow**, and their events wait.
- A connection has up to 10 webhooks and pull feeds.

## Deliveries and retry

**Deliveries** in the connection’s details shows the last 20 attempts. **Retry** sends the same event to the same URL again, with the same `webhook-id`. Through the API, `GET /subscriptions/{subscription_id}/deliveries` lists them and `POST …/deliveries/{delivery_id}/retry` sends one again.

| In ThreadPage | `error` in the API | Means |
|---|---|---|
| **Failed: no answer in 15 seconds** | `timeout` | No complete answer came in time. Tried again. |
| **Failed: certificate not trusted** | `tls` | Your certificate is not valid for its host name or not from an authority ThreadPage trusts. Tried again. |
| **Not sent: the note is no longer available** | `content_removed` | The note went to Trash or was deleted before its event went out, or the event waited longer than 10 days. Your endpoint is not at fault. |
| **Paused until tomorrow** | `state_reason` `rate` | The account’s deliveries for the day are used. |

## From n8n, Make or Zapier

Use a Webhook trigger and give **Note updates** its production URL, not the test URL. In n8n the workflow must be active for the production URL to answer. To verify the signature, the trigger must give you the raw body; n8n’s Webhook node has a **Raw Body** option. Then verify it in a Code or Crypto step with the secret.

## Next

- [Events and subscriptions](https://thread.page/help/connections/events.md): every event type and its JSON.
- [Pull feed](https://thread.page/help/connections/feed.md): the same events without a public URL.
