ThreadPage
All docs

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.

View as Markdown

On this page
  1. Before you start
  2. Try it first
  3. Run a receiver
  4. Give it a public HTTPS address
  5. Add the webhook
  6. Verify the signature
  7. Respond
  8. Delivery behavior
  9. Retries
  10. Paused, expired and limits
  11. Deliveries and retry
  12. From n8n, Make or Zapier
  13. Next

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.

#!/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"

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

Run a receiver
FrameworkRaw body
Expressexpress.raw({type: 'application/json'})
Next.js Route Handlerawait req.text()
Flaskrequest.get_data()
FastAPIawait 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:
curl
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:

Verify the signature
HeaderValue
webhook-idThe event’s id, the same in every attempt.
webhook-timestampSeconds since 1970 when this attempt was signed.
webhook-signaturev1, 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 wrong

Without 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);
  });
}

Test vector

Check your code against these values before you connect it:

Test vector
secretwhsec_dGhyZWFkcGFnZS1kb2NzLXRlc3QtdmVjdG9yLWtleSE=
webhook-idevt_7m2kq9v4x8p1r6t3w5y0z2b4nc
webhook-timestamp1761904200
body{"specversion":"1.0","id":"evt_7m2kq9v4x8p1r6t3w5y0z2b4nc","type":"note.created"}
webhook-signaturev1,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.');

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.

Retries
Your answerWhat ThreadPage does
2xx within 15 secondsDelivered.
No answer in 15 seconds, a network or TLS error, a redirect, 5xx, 429 or another 4xxTries again on the schedule above.
401 or 403Pauses the webhook until it is resumed. Resuming sends that event again.
410Stops the webhook (expired).
413Skips 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.

Deliveries and retry
In ThreadPageerror in the APIMeans
Failed: no answer in 15 secondstimeoutNo complete answer came in time. Tried again.
Failed: certificate not trustedtlsYour certificate is not valid for its host name or not from an authority ThreadPage trusts. Tried again.
Not sent: the note is no longer availablecontent_removedThe 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 tomorrowstate_reason rateThe 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