Script or API
Script or API gives your own script, server or workflow a token and the access the owner chooses. Pick what to build below, then add your first note step by step.
Op deze pagina
Choose what to build
| I want to… | Use | Read |
|---|---|---|
| add notes from a script | the Integration API | Notes |
| post a service’s events into a thread | an endpoint URL | Endpoint URLs |
| react when notes change | a webhook or a pull feed | Receive webhooks, Pull feed |
| answer notes addressed to my worker | Requests | Requests |
| let an assistant work in my notes | the MCP server | MCP server |
Quickstart
You will check a token, find a thread, add a note and read its receipt. Each step shows curl, JavaScript and Python. curl examples are for bash; on Windows use WSL or Git Bash.
Step 0: Check your plan
With Free, a connection can only add notes to your Inbox: use POST /inbox/notes. Writing to a thread answers 403 pro_required. Apart from GET /connection and GET /operations/{operation_id}, everything else on these pages needs Pro with Sync turned on.
Step 1: Create a connection
In ThreadPage, open Settings → Connections, go to Automations and choose Connect an automation → Script or API. Enter a Name, choose what it can do and which threads and folders it can use, then choose Connect. Choose Copy token and keep it in a secret store: it is shown once.
For this quickstart, Add only with one selected thread is enough. On Free, the connection has Add to Inbox. Access levels explains each level.
Step 2: Check the token
Put the token in an environment variable, then ask the API which connection it belongs to. The JavaScript and Python steps use the call helper of the whole flow.
export THREADPAGE_TOKEN=tp_your_token
API=https://thread.page/api/integrations/v1curl -sS -m 30 "$API/connection" \
-H "Authorization: Bearer $THREADPAGE_TOKEN" | tee connection.json | jq .const connection = await call('GET', '/connection');
console.log(connection.level, connection.permissions, connection.expires_at);connection = call("GET", "/connection")
print(connection["level"], connection["permissions"], connection["expires_at"])The answer says what the token may do and when it stops working:
{
"id": "6b1d0f4e-2c8a-4e57-9a3b-1f0c7d2e5a94",
"name": "Release bot",
"level": "append",
"can": "Add notes to threads you choose and see those threads’ titles.",
"cannot": "Can’t read notes.",
"access": {
"version": 3,
"level": "append",
"scope": "selected",
"folder_ids": [],
"thread_ids": ["0e7c4b2a-91d3-4f6e-8a25-3c1b9d7f4e60"],
"excluded_folder_ids": [],
"excluded_thread_ids": []
},
"permissions": {
"read": false,
"create_notes": true,
"add_to_inbox": true,
"edit_notes": "none",
"organize": false
},
"manage_url": "https://thread.page/app/settings/connections/6b1d0f4e-2c8a-4e57-9a3b-1f0c7d2e5a94?access=read_add",
"shared_places": [],
"expires_at": "2027-01-29T09:30:00Z"
}If permissions.create_notes is false and permissions.add_to_inbox is true, this connection adds notes only to the Inbox: skip to step 4 and use POST /inbox/notes.
Step 3: Find a thread
List the threads the connection can see, or copy the ID from the thread’s address in your browser: the part after threads/. The examples use the only thread the connection sees, and otherwise ask you to set THREAD_ID.
curl -sS -m 30 "$API/threads?limit=50" \
-H "Authorization: Bearer $THREADPAGE_TOKEN" | tee threads.json | jq -r '.items[] | "\(.id) \(.title)"'const { items } = await call('GET', '/threads?limit=50');
for (const thread of items) console.log(thread.id, thread.title);threads = call("GET", "/threads?limit=50")["items"]
for thread in threads:
print(thread["id"], thread["title"])Step 4: Add a note
Create one Operation-Id before the request and send the same one on every retry. A retry with the same ID returns the same receipt instead of a second note.
With Pro and Sync, add the note to the thread:
OP=$(uuidgen)
curl -sS -m 30 --retry 3 --retry-all-errors "$API/threads/$THREAD_ID/notes" \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Operation-Id: $OP" \
-H "Content-Type: application/json" \
-d '{"content": {"markdown": "**Build 412** passed.\n\nDeployed to staging."}}' | jq .operationId = randomUUID(); // one ID for this note, the same on every retry
const receipt = await call('POST', `/threads/${threadId}/notes`, {
operationId,
body: { content: { markdown: '**Build 412** passed.\n\nDeployed to staging.' } },
});
console.log(receipt.status, receipt.url);operation_id = str(uuid.uuid4()) # one ID for this note, the same on every retry
receipt = call(
"POST",
f"/threads/{thread_id}/notes",
{"content": {"markdown": "**Build 412** passed.\n\nDeployed to staging."}},
operation_id,
)
print(receipt["status"], receipt["url"]){
"operation_id": "8a6e0804-2bd0-4672-b79d-d97027f9071a",
"status": "accepted",
"note_id": "4c2f8e19-7a6b-4d3e-b1c0-95e8f2a7d364",
"revision": 1,
"accepted_at": "2026-10-31T09:30:00Z",
"url": "https://thread.page/app/notes/4c2f8e19-7a6b-4d3e-b1c0-95e8f2a7d364"
}With Free, add it to the Inbox:
OP=$(uuidgen)
curl -sS -m 30 --retry 3 --retry-all-errors "$API/inbox/notes" \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Operation-Id: $OP" \
-H "Content-Type: application/json" \
-d '{"content": {"text": "Build 412 passed."}}' | jq .operationId = randomUUID();
const receipt = await call('POST', '/inbox/notes', {
operationId,
body: { content: { text: 'Build 412 passed.' } },
});
console.log(receipt.status, receipt.delivery, receipt.url); // url is null while the note waitsoperation_id = str(uuid.uuid4())
receipt = call("POST", "/inbox/notes", {"content": {"text": "Build 412 passed."}}, operation_id)
print(receipt["status"], receipt["delivery"], receipt["url"]) # url is None while the note waits{
"operation_id": "3f2c1d0e-9b8a-4c7d-8e6f-5a4b3c2d1e0f",
"status": "accepted",
"note_id": "a91e5c37-2b4d-4f80-9c6e-7d3a1b0f5e28",
"delivery": "inbox",
"accepted_at": "2026-10-31T09:30:00Z",
"url": null
}"delivery": "inbox" means the note waits for the browser that collects your Inbox, and it appears there when ThreadPage is open. With Pro and Sync the receipt says "account" and has the note’s url.
Step 5: Read the receipt
If the answer to step 4 got lost, read the receipt instead of writing again:
curl -sS -m 30 "$API/operations/$OP" \
-H "Authorization: Bearer $THREADPAGE_TOKEN" | jq .console.log(await call('GET', `/operations/${operationId}`));print(call("GET", f"/operations/{operation_id}"))It answers the receipt from step 4. A 404 unavailable means the write never reached ThreadPage: send it again with the same Operation-Id.
The whole flow
#!/usr/bin/env bash
# ThreadPage Quickstart: check the token, find a thread, add a note, read its receipt.
# Needs bash, curl 7.71 or newer, uuidgen and jq. Run: THREADPAGE_TOKEN=tp_… bash quickstart.sh
# Each step prints ThreadPage's answer, an error's JSON with its code included; it keeps
# connection.json and threads.json in the current folder.
# Set THREAD_ID when the connection sees more than one thread.
set -euo pipefail
API=https://thread.page/api/integrations/v1
curl -sS -m 30 "$API/connection" \
-H "Authorization: Bearer $THREADPAGE_TOKEN" | tee connection.json | jq .
if [ "$(jq -r '.permissions.create_notes' connection.json)" = "true" ]; then
curl -sS -m 30 "$API/threads?limit=50" \
-H "Authorization: Bearer $THREADPAGE_TOKEN" | tee threads.json | jq -r '.items[] | "\(.id) \(.title)"'
if [ -z "${THREAD_ID:-}" ]; then
if [ "$(jq '.items | length' threads.json)" != 1 ]; then
echo "Set THREAD_ID to one of the threads above." >&2
exit 1
fi
THREAD_ID=$(jq -r '.items[0].id' threads.json)
fi
OP=$(uuidgen)
curl -sS -m 30 --retry 3 --retry-all-errors "$API/threads/$THREAD_ID/notes" \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Operation-Id: $OP" \
-H "Content-Type: application/json" \
-d '{"content": {"markdown": "**Build 412** passed.\n\nDeployed to staging."}}' | jq .
else
OP=$(uuidgen)
curl -sS -m 30 --retry 3 --retry-all-errors "$API/inbox/notes" \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Operation-Id: $OP" \
-H "Content-Type: application/json" \
-d '{"content": {"text": "Build 412 passed."}}' | jq .
fi
curl -sS -m 30 "$API/operations/$OP" \
-H "Authorization: Bearer $THREADPAGE_TOKEN" | jq .// ThreadPage Quickstart: check the token, find a thread, add a note, read its receipt.
// Run with Node.js 24: THREADPAGE_TOKEN=tp_… node quickstart.mjs
// Set THREAD_ID when the connection sees more than one thread.
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
const API = 'https://thread.page/api/integrations/v1';
// One call. After a lost answer, or a 429 or 503 that says retryable, it sends the identical
// request again, with the same Operation-Id, up to three times.
async function call(method, path, { body, operationId } = {}) {
const headers = { Authorization: `Bearer ${process.env.THREADPAGE_TOKEN}` };
if (operationId) headers['Operation-Id'] = operationId;
if (body !== undefined) headers['Content-Type'] = 'application/json';
for (let attempt = 1; ; attempt++) {
let answer;
try {
answer = await fetch(API + path, {
method, headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
if (attempt === 3) throw error;
await sleep(attempt * 2_000);
continue;
}
if (answer.ok) return answer.status === 204 ? null : answer.json();
const data = await answer.json().catch(() => ({ message: answer.statusText }));
const wait = Number(answer.headers.get('Retry-After'));
if (data.retryable && wait && wait <= 60 && attempt < 3) {
await sleep(wait * 1_000);
continue;
}
throw Object.assign(new Error(`${answer.status} ${data.code}: ${data.message}`), { status: answer.status, error: data });
}
}
const connection = await call('GET', '/connection');
console.log(connection.level, connection.permissions, connection.expires_at);
let operationId;
if (connection.permissions.create_notes) {
const { items } = await call('GET', '/threads?limit=50');
for (const thread of items) console.log(thread.id, thread.title);
const threadId = process.env.THREAD_ID ?? (items.length === 1 ? items[0].id : null);
if (!threadId) throw new Error('Set THREAD_ID to one of the threads above.');
operationId = randomUUID(); // one ID for this note, the same on every retry
const receipt = await call('POST', `/threads/${threadId}/notes`, {
operationId,
body: { content: { markdown: '**Build 412** passed.\n\nDeployed to staging.' } },
});
console.log(receipt.status, receipt.url);
} else {
operationId = randomUUID();
const receipt = await call('POST', '/inbox/notes', {
operationId,
body: { content: { text: 'Build 412 passed.' } },
});
console.log(receipt.status, receipt.delivery, receipt.url); // url is null while the note waits
}
console.log(await call('GET', `/operations/${operationId}`));# ThreadPage Quickstart: check the token, find a thread, add a note, read its receipt.
# Run with Python 3.13: pip install requests, then THREADPAGE_TOKEN=tp_… python quickstart.py
# Set THREAD_ID when the connection sees more than one thread.
import os
import time
import uuid
import requests
API = "https://thread.page/api/integrations/v1"
class ThreadPageError(Exception):
def __init__(self, status, error):
super().__init__(f"{status} {error.get('code')}: {error.get('message')}")
self.status, self.error = status, error
def call(method, path, body=None, operation_id=None):
"""One call. After a lost answer, or a 429 or 503 that says retryable, it sends the identical
request again, with the same Operation-Id, up to three times."""
headers = {"Authorization": f"Bearer {os.environ['THREADPAGE_TOKEN']}"}
if operation_id:
headers["Operation-Id"] = operation_id
for attempt in range(1, 4):
try:
answer = requests.request(method, API + path, headers=headers, json=body, timeout=30)
except (requests.ConnectionError, requests.Timeout):
if attempt == 3:
raise
time.sleep(attempt * 2)
continue
if answer.ok:
return None if answer.status_code == 204 else answer.json()
try:
data = answer.json()
except ValueError:
data = {"code": None, "message": answer.text[:200]}
wait = int(answer.headers.get("Retry-After", 0))
if data.get("retryable") and 0 < wait <= 60 and attempt < 3:
time.sleep(wait)
continue
raise ThreadPageError(answer.status_code, data)
connection = call("GET", "/connection")
print(connection["level"], connection["permissions"], connection["expires_at"])
if connection["permissions"]["create_notes"]:
threads = call("GET", "/threads?limit=50")["items"]
for thread in threads:
print(thread["id"], thread["title"])
thread_id = os.environ.get("THREAD_ID") or (threads[0]["id"] if len(threads) == 1 else None)
if not thread_id:
raise SystemExit("Set THREAD_ID to one of the threads above.")
operation_id = str(uuid.uuid4()) # one ID for this note, the same on every retry
receipt = call(
"POST",
f"/threads/{thread_id}/notes",
{"content": {"markdown": "**Build 412** passed.\n\nDeployed to staging."}},
operation_id,
)
print(receipt["status"], receipt["url"])
else:
operation_id = str(uuid.uuid4())
receipt = call("POST", "/inbox/notes", {"content": {"text": "Build 412 passed."}}, operation_id)
print(receipt["status"], receipt["delivery"], receipt["url"]) # url is None while the note waits
print(call("GET", f"/operations/{operation_id}"))If it fails
| Code | HTTP | When | What to do | Retryable |
|---|---|---|---|---|
authentication_required | 401 | No token was sent. | Send Authorization: Bearer tp_…. | No |
invalid_token | 401 | The token is wrong, revoked or replaced, or it was retired: the owner changed their password, or reset or restored the workspace. /mcp also answers it for a token that only answers its connection's requests. | Ask the owner for a new token. | No |
token_expired | 401, 409 | The token's term ended, or it went 90 days without a call: details.reason is term or inactivity, details.expired_at when. An endpoint URL answers 409 with outcome blocked. | Ask the owner to renew it in Settings → Connections; the same token then works again. | No |
pro_required | 403 | It needs Pro: on Free a connection adds notes only to the Inbox. | Use POST /inbox/notes (MCP: create_note without thread_id), or tell the owner. | No |
cloud_required | 403 | It needs the owner's cloud copy: Pro without Sync. | Tell the owner to turn on Sync; adding to the Inbox works meanwhile. | No |
read_not_allowed | 403 | Reading, or subscribing to events, on Add only or Add to Inbox. | Tell the person and give them details.manage_url. | No |
write_not_allowed | 403, 409 | Adding on Read only, adding to a thread on Add to Inbox, a new thread or a reminder on Add only, or receiving requests below Read and add (details.needs and details.manage_url; the message names the level); an assistant whose sign-in holds no write scope; a connection whose access leaves out the Inbox thread. An endpoint URL answers 409 with outcome blocked. | Give the person details.manage_url where there is one; otherwise tell them. | No |
unavailable | 404 | Not found within this connection's scope. | Don't guess other IDs; ask the person whether the item is in scope. | No |
quota_exhausted | 429 | Free's 3,000 connection notes this month are used. | Stop until details.resets_at. There is no Retry-After. | No |
inbox_full | 409 | 1,000 notes or 20 MB wait for the owner's browser to collect them. | Ask the owner to open ThreadPage, then retry later with the same Operation-Id. | No |
Test without touching your notes
- Create a separate connection that reaches one test thread or folder instead of All threads.
GET /events/sampleshows the shape of real events. A Test event reaches your Note updates URL signed like any event, withdata.testset totrue. Test request on a script that receives requests, or Send test on a Send notes you @ to a URL connection, sends a request without a note.- Undo changes… in the connection’s details undoes its changes, and can move what it added to Trash. Restore from Trash… brings back what it moved to Trash.
- Choose Disconnect… when you are done.
What to read next
- Integration API basics: authentication, access, safe writes, pagination and content.
- Notes: add, read, search and edit notes, images and files.
- Events and subscriptions: hear when notes change.
- Errors and Rate limits.
- A lab notebook your script fills in: a worked example, where an analysis script adds its plot and results to a thread.
Machine-readable
- The contract: OpenAPI description.
- Import the OpenAPI description into Postman, Bruno or Insomnia.
- Generate a client from the OpenAPI description; there is no official SDK.
- For agents: llms.txt. Every page here has a Markdown version at the same address with
.md.