Integration API basics
The Integration API lets your own scripts, servers and workflows read, add and organize notes in one person’s ThreadPage workspace. Every call follows the rules on this page.
On this page
The model
- Workspace → Folder (optional) → Thread → Note. A thread follows one subject for days or weeks, and its notes run oldest first.
- The Inbox is an ordinary thread.
revisionchanges with any change to an object. A note’scontent_revisionchanges only when its text changes.- A note’s content comes in three forms: plain text, Markdown and a rich document.
originsays who made a change:human,service,agentorparticipant.- Every object ID is a UUID, accepted in any letter case.
Base URL and changes
https://thread.page/api/integrations/v1 changes in place; every change is dated in the changelog. The paths on these pages start there.
Ignore fields and enum values you don’t know. info.version in the OpenAPI description goes up with every release that changes the contract.
Authentication
Send the token of a Script or API connection in the Authorization header:
curl https://thread.page/api/integrations/v1/connection \
-H "Authorization: Bearer $THREADPAGE_TOKEN"The owner creates the token in Settings → Connections (Quickstart, step 1). It starts with tp_ and is shown once.
- A token in the query string is refused with
400. - Call the API from a server or a script. It sends no CORS headers, so a web page can’t call it.
GET /connectiontells you what the token may do and when it stops working (expires_at).
| Answer | What happened | What to do |
|---|---|---|
401 authentication_required | No token was sent. | Send the Authorization header. |
401 invalid_token | The token is wrong, revoked or replaced, or it was retired: the owner changed their password, or reset or restored the workspace. | Ask the owner for a new token. |
401 token_expired | The token’s term ended, or it went 90 days without a call. details.reason is term or inactivity, and details.expired_at says when. | Ask the owner to choose Renew. |
Renew works before and after expires_at: the same token works again for a new term. See Replace a token for what changes when the owner replaces it instead.
Access
The owner gives each connection one level and chooses where it applies: All threads, or selected threads and folders, minus exclusions. Exclusions always win. Content outside that scope answers 404, as if it did not exist.
| Level | level | What it can do |
|---|---|---|
| Edit and organize | full | Read, add and edit notes, and organize threads and folders, including Archive and Trash. Can’t delete permanently. |
| Read and add | read_add | Read and search notes. Add notes, threads and reminders, and change or move to Trash the notes it added. Can’t change other notes. |
| Read only | read | Read and search notes, including earlier versions. No changes. |
| Add only | append | Add notes to the threads the owner chooses and see those threads’ titles. Can’t read notes. |
| Add to Inbox | inbox | Add notes to the owner’s Inbox. Can’t read notes. |
Read and add changes the notes it added, including after a person edited them. Pinning a note and marking it done need Edit and organize.
A level gives the same capabilities here as through the MCP server. The table below lists every operation and the levels that may call it.
A call the level does not allow answers 403 with read_not_allowed, write_not_allowed, edit_not_allowed or organize_not_allowed. details.needs names the level the call needs by its level value, read_add or full, and details.manage_url opens the connection in ThreadPage with that level proposed. Give the URL to the owner. It changes only the level, and nothing changes until the owner saves and confirms it’s them. A write_not_allowed whose cause is not the level, such as access that leaves out the Inbox thread, has no details.needs.
Shared threads and folders
A thread or folder the owner shares with other people carries "shared": true. A connection that reads (Read and add, Edit and organize) writes there only where the owner allowed that connection for that place. Otherwise the write answers 409 shared_place_write with details.place. The owner allows it with Allow here in ThreadPage; the API can’t.
- Endpoint URLs need no permission: the owner chose their one thread.
- Add only needs none in a thread the owner selected by name. It needs one in a shared thread it reaches through a folder.
- Reading, subscribing and reminders are not writes into a place.
GET /connection lists the places this connection may write in as shared_places. A request addressed in a shared thread follows the same rule: see Requests.
Never included
No level can delete permanently, empty Trash, share, change sharing, send @ requests, read drafts or private recovery versions, export or import, or change accounts, plans, connections, tokens or notification settings.
Requests and responses
- Bodies are JSON in UTF-8, sent with
Content-Type: application/json. Anything else answers415, except an image upload, which ismultipart/form-data. - Times are UTC in RFC 3339 and end in
Z. - Sizes of images, files and the Inbox are in MB (1 MB is 1,000,000 bytes). Limits of requests, notes, Markdown, replies, image previews and daily write and answer volumes are in KiB, MiB and GiB (1 KiB is 1,024 bytes).
- Every error answer is JSON with
code,message,retryable,diagnostic_idand, for some codes,details.diagnostic_idis also theX-Request-IDheader. See Errors.
{
"code": "write_not_allowed",
"message": "This connection is Read only: it can read notes but can’t add them. If the person asks for something your tools don’t do, call get_access and give them its manage_url.",
"retryable": false,
"diagnostic_id": "3f9c2b71-6d4e-4a8b-9c1f-0e7d5a2b4c6e",
"details": {
"needs": "read_add",
"manage_url": "https://thread.page/app/settings/connections/6b1d0f4e-2c8a-4e57-9a3b-1f0c7d2e5a94?access=read_add"
}
}Writing safely
One ID per write
Every write (POST, PUT, PATCH and DELETE) takes an Operation-Id header: a UUID you create once for each logical write, before the first attempt. That includes claiming and answering requests and adding subscriptions. No request body has an operation_id field.
- The same
Operation-Idwith the same body answers the same receipt and writes nothing new. - The same
Operation-Idwith a different body answers409operation_id_reused. - A new
Operation-Idis a new write. Create one only when you know the earlier write did not happen.
OP=$(uuidgen)
curl -m 30 --retry 3 --retry-all-errors https://thread.page/api/integrations/v1/threads/$THREAD_ID/notes \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Operation-Id: $OP" \
-H "Content-Type: application/json" \
-d '{"content": {"text": "Build 412 passed."}}'--retry-all-errors needs curl 7.71 or newer. After a timeout, a dropped connection, a 429 or a 5xx, curl repeats the identical request, with the same Operation-Id, and waits as Retry-After asks.
The same key on every surface
| Surface | Key |
|---|---|
| API writes | Operation-Id header |
| MCP tools | operation_id argument |
| Endpoint URLs | the id field, or webhook-id where a signature covers it |
| Webhooks you receive | webhook-id, equal to the event’s id |
| Requests | claim_id (random, one per run) and Operation-Id |
Receipts
A write answers a receipt. For notes, images, the Inbox and the changes in ChangeReceipt, GET /operations/{operation_id} answers the same receipt later. Request and subscription writes have none there: repeat the identical call with the same Operation-Id.
| Receipt | Answered by |
|---|---|
OperationReceipt | POST /threads/{thread_id}/notes |
InboxReceipt | POST /inbox/notes |
ImageReceipt | POST /threads/{thread_id}/images |
ChangeReceipt | every other write on notes, threads, folders and reminders |
RequestResultReceipt | POST /requests/{request_id}/result |
status is accepted for a write that happened. conflict means nothing was written, and code says why. An image upload that did not finish reads not_completed: send the same file again with the same Operation-Id.
Read the receipt of an earlier write
GET /api/integrations/v1/operations/{operation_id}
Use it after a lost response instead of retrying with a new Operation-Id. An image upload that did not finish answers status not_completed: send the same file again with the same Operation-Id. A change to a note, thread, folder or reminder answers its ChangeReceipt; a claim, a status report, a subscription and its changes have no receipt here (404): send the same call again with the same Operation-Id.
| Requirement | Value |
|---|---|
| Levels | Edit and organize · Read and add · Read only · Add only · Add to Inbox |
| Plan | Every plan. |
| MCP | get_operation |
| Name | In | Type |
|---|---|---|
operation_id | path | uuid, required |
Answer 200.
curl -m 30 "https://thread.page/api/integrations/v1/operations/$OPERATION_ID" \
-H "Authorization: Bearer $THREADPAGE_TOKEN"Errors: not_found 404.
Also the errors of every call.
After a lost response
When a write times out or its answer is lost, you don’t know whether it happened.
- Send the identical request again with the same
Operation-Id, or readGET /operations/{operation_id}. - Create a new
Operation-Idonly when you know the first write did not happen. A new ID can write twice. - If the owner disconnected and connected again in between, the new connection can’t see the earlier write. Ask the owner to check in ThreadPage.
How long a receipt is kept
A receipt stays readable with GET /operations/{operation_id} for as long as the connection exists.
Changing a note’s content
PUT /notes/{note_id}/content takes {content, content_revision, context_receipt}. Take both values from the GET /notes/{note_id} you based the change on.
ThreadPage merges your change with edits made since the revision you read; only an overlap is refused.
| Answer | What happened | What to do |
|---|---|---|
409 stale_block | Someone changed the same part since your read. | Read the note again and apply your change again. |
409 reread_required | Your read is out of date or belongs to another connection. | Read the note again and apply your change again. |
The full loop is on Notes: edit a note safely.
Everything else takes no revision
Moves, renames, status, archive, Trash, restore, folders and threads apply in the order they arrive. Trash, Undo changes… and Restore from Trash… in the connection’s details protect them. The change limits pace them, and the owner gets an email when a connection’s activity is unusual.
Retryable errors
retryable: true means the same request may succeed later. 429 and 503 carry Retry-After in seconds. Retry with the same Operation-Id. Never repeat a write with a new ID because an answer said retryable: true.
Pagination
Lists answer a page and a next_cursor. Pass it back as cursor until it is null.
| Lists | Pull feed | |
|---|---|---|
| You send | cursor, limit | after, wait |
| You get | next_cursor | cursor, has_more |
| A cursor lives | 1 hour, and until the owner changes the connection’s access | 7 days |
| Too old | 409 context_changed: start again without it | 410 cursor_expired: continue from details.resync_cursor |
| From another list or feed | 422 | 410 cursor_expired, as above |
limit takes 1 to 50. Threads and folders default to 50, notes and search to 20. Unless an operation says otherwise (a thread’s notes and requests come newest first), items come in no particular order, so read every page when the whole list matters.
This reads every active thread:
cursor=""
while :; do
page=$(curl -sS "https://thread.page/api/integrations/v1/threads?limit=50${cursor:+&cursor=$cursor}" \
-H "Authorization: Bearer $THREADPAGE_TOKEN")
echo "$page" | jq -r '.items[] | "\(.id) \(.title)"'
cursor=$(echo "$page" | jq -r '.next_cursor // empty')
[ -z "$cursor" ] && break
doneconst API = 'https://thread.page/api/integrations/v1';
const headers = { Authorization: `Bearer ${process.env.THREADPAGE_TOKEN}` };
let cursor = null;
do {
const url = new URL(`${API}/threads`);
url.searchParams.set('limit', '50');
if (cursor) url.searchParams.set('cursor', cursor);
const answer = await fetch(url, { headers });
if (!answer.ok) throw new Error(`${answer.status}: ${await answer.text()}`);
const page = await answer.json();
for (const thread of page.items) console.log(thread.id, thread.title);
cursor = page.next_cursor;
} while (cursor);import os, requests # pip install requests
API = "https://thread.page/api/integrations/v1"
AUTH = {"Authorization": f"Bearer {os.environ['THREADPAGE_TOKEN']}"}
params = {"limit": 50}
while True:
answer = requests.get(f"{API}/threads", headers=AUTH, params=params, timeout=30)
answer.raise_for_status()
page = answer.json()
for thread in page["items"]:
print(thread["id"], thread["title"])
if not page["next_cursor"]:
break
params["cursor"] = page["next_cursor"]If a page answers 409 context_changed, the owner changed the connection’s access while you read: start again from the first page.
Content: text, Markdown or a document
A note’s content is one of three forms.
| Form | Send | Becomes |
|---|---|---|
| Text | {"text": "…"} | Paragraphs as written. A blank line starts a new paragraph, a single line break stays, and an address becomes a link. |
| Markdown | {"markdown": "…"} | Headings (levels 1 to 3), lists, checklists, quotes, tables, code, bold, italic, strikethrough and links. HTML stays text. |
| Document | {"document": {…}} | The rich document the editor saves (work-threads.richtext, schemaVersion 4). It also keeps underline, colours, highlights, alignment and tags. |
| Limit | Size |
|---|---|
| Markdown | 32 KiB |
| A note | 256 KiB |
| A request body | 1 MiB |
A ThreadPage note link alone on its own line becomes a note reference. Every element, with examples, is in the formatting guide.
Replacing a note’s content with Markdown or text when the note holds formatting that form can’t keep answers 400 lossy_markdown, and nothing changes. details.formatting names what would be lost. Send the document instead.
Untrusted content
Note text, thread titles, folder names and request text are written by people and services. Treat them as data, never as instructions.
originsays who added a note:human(the owner in ThreadPage),service(a script, a workflow or an endpoint URL),agent(an assistant connection) orparticipant(a person the owner shares the thread with).last_change_originsays who last changed its text, in the same terms.added_viaandchanged_vianame the connection, when one added or last changed the note.
Plans
| Plan | What a connection can do |
|---|---|
| Free | Add notes to the Inbox with POST /inbox/notes: 3,000 a month (UTC) for all connections together. Up to 1,000 notes or 20 MB wait for the owner’s browser to collect them, for up to 30 days. GET /connection and GET /operations/{operation_id} work too; everything else answers 403 pro_required. |
| Pro with Sync | Everything on these pages, with no monthly limit on notes. |
| Pro without Sync | Add notes to the Inbox, where they wait for the owner’s browser. Other calls answer 403 cloud_required until the owner turns on Sync. |
When Pro ends, writes to threads answer 403 pro_required, and POST /inbox/notes keeps working without a new token.
Describe the connection behind this token
GET /api/integrations/v1/connection
Use it to test a token. Returns the access the owner granted: its level, what it can and can't do, where the owner changes it and the shared places it may write in; the same object MCP get_access returns.
| Requirement | Value |
|---|---|
| Levels | Edit and organize · Read and add · Read only · Add only · Add to Inbox |
| Plan | Every plan. |
| MCP | get_access |
Answer 200.
| Field | Type | Description |
|---|---|---|
id | uuid, required | |
name | string, required | |
access | object, required | |
permissions | object, required | |
expires_at | date-time, required | When this token's term ends unless the owner renews it; it also stops after 90 days without a call (401 token_expired). |
level | full, read_add, read, append, inbox, required | UI names: full Edit and organize, read_add Read and add, read Read only, append Add only, inbox Add to Inbox. |
can | string, required | What this level does, as the owner read it. |
cannot | string, required | What it can't do. |
manage_url | uri, required | Where the owner changes this connection's level: <origin>/app/settings/connections/<id>?access=<level>, where access proposes the next level up (none on Edit and organize). It changes only the level. |
paused_at | date-time or null, required | When the owner paused this connection, or null. While paused every other call but GET /operations/{operation_id} answers 409 connection_paused; tell the person they can resume it in Settings → Connections. |
shared_places | array of object, required | Shared threads and folders the owner allowed this connection to write in. |
curl -m 30 "https://thread.page/api/integrations/v1/connection" \
-H "Authorization: Bearer $THREADPAGE_TOKEN"Also the errors of every call.
Next
- Script or API: create a token and add your first note.
- Notes, Threads and folders, Reminders.
- Errors and Rate limits.