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.
On this page
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
- In ThreadPage, open Settings → Connections and set up Note updates for a connection that reads. Paste any public
httpsURL that shows the requests it gets, and copy the signing secret: it is shown once. - ThreadPage sends that URL a Test event at once. It is signed like every event, and its
data.testistrue. - 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.
#!/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"// 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));
}// 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.');# 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()# 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:
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 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, 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.
Verify the signature
ThreadPage signs with Standard Webhooks. 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:
import { Webhook } from 'standardwebhooks';
const webhook = new Webhook(process.env.THREADPAGE_WEBHOOK_SECRET);
const event = webhook.verify(rawBody, headers); // throws when the signature is wrongimport os
from standardwebhooks.webhooks import Webhook
webhook = Webhook(os.environ["THREADPAGE_WEBHOOK_SECRET"])
event = webhook.verify(raw_body, headers) # raises when the signature is wrongWithout a library:
// 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);
});
}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:
// 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.');# 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’sid, in every attempt. - Order is not guaranteed. Compare
time, and a note’scontent_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
keyUsageextension 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: every event type and its JSON.
- Pull feed: the same events without a public URL.