Custom connections and API
Let your own script, server, form or workflow read your notes, add new ones, hear when they change and answer notes you address to it. A custom connection has its own token, endpoints and webhooks, and only the access you choose.
Create a token
- Open Settings → Connections and choose Add connection → Automation or API. With a free account, a connection adds notes to your Inbox; everything else here needs Pro and Sync turned on.
- Name the connection. Notes it adds show “Added via” with this name.
- Choose access. Add to Inbox adds notes to your Inbox and nothing else; it is the access a free account has. Append only adds notes in the folders and threads you select and lists their thread titles without reading notes. Read only reads and searches. Full workspace access does both. Limit either to selected folders and threads, or exclude private ones.
- Copy the token. It is shown only once; ThreadPage stores only a hash of it.
Add a note to the Inbox
curl https://thread.page/api/integrations/v1/inbox/notes \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"operation_id": "3f2c1d0e-9b8a-4c7d-8e6f-5a4b3c2d1e0f",
"content": {"text": "Build 412 passed."}}'A token with Add to Inbox, Append only or Full workspace access can add a note to your Inbox; a Read only token cannot. With a free account the receipt says "delivery": "inbox": the note waits until the browser that collects your Inbox brings it into its Inbox thread, and url is null. With Pro and Sync it says "account" and the note is in your account’s Inbox thread at once, unless the token’s access excludes that thread or its folder.
A free account takes 3,000 such notes a calendar month (UTC). Past that the API answers 429 with quota_exhausted, retryable false, no Retry-After and details.resets_at. Up to 1,000 notes or 20 MiB can wait at once; past that it answers 409 with inbox_full. Notes no browser collects within 30 days are removed. A note with an image needs Pro.
Once Sync is turned on, notes your browser collected move to your account with new ids, so an earlier receipt may name an id the account no longer has. A note that reaches your account more than a year after it was collected no longer shows which connection added it.
Send a request
Send the token as a bearer token to https://thread.page/api/integrations/v1. Requests and responses are JSON. Start by asking the API which connection the token belongs to:
curl https://thread.page/api/integrations/v1/connection \
-H "Authorization: Bearer $THREADPAGE_TOKEN"Add a note
curl https://thread.page/api/integrations/v1/threads/THREAD_ID/notes \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"operation_id": "8a6e0804-2bd0-4672-b79d-d97027f9071a",
"content": {"text": "Build 412 passed.\n\nDeployed to staging."}}'Find THREAD_ID with GET /threads, or copy it from the thread’s address in your browser: /app/threads/THREAD_ID. A blank line starts a new paragraph. Send content.markdown instead of content.text to keep headings, lists, links, tables and formatting written in Markdown. A note’s link alone on a line of its own becomes a note reference.
Use a new UUID as operation_id for each note. Sending the same request again returns the same receipt instead of a second note. If a response is lost, read GET /operations/OPERATION_ID before you retry.
{
"operation_id": "8a6e0804-2bd0-4672-b79d-d97027f9071a",
"status": "accepted",
"note_id": "…",
"revision": 1,
"accepted_at": "2026-10-12T09:30:00Z",
"url": "https://thread.page/app/notes/…"
}Rich notes
To keep headings, lists, checklists, quotes, tables and formatting, send content.document instead of content.text. It is the same rich document the editor saves: headings go up to level 3, list items start with a paragraph, and every table row has the same number of cells.
{
"operation_id": "5b0f8b1e-6a3c-4d5e-9f10-2a3b4c5d6e7f",
"content": { "document": {
"format": "work-threads.richtext",
"schemaVersion": 4,
"doc": { "type": "doc", "content": [
{ "type": "heading", "attrs": { "level": 2 },
"content": [{ "type": "text", "text": "Release 412" }] },
{ "type": "bulletList", "content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [
{ "type": "text", "text": "Tests passed", "marks": [{ "type": "bold" }] }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [
{ "type": "text", "text": "Deployed to staging" }] }] }
] },
{ "type": "table", "content": [
{ "type": "tableRow", "content": [
{ "type": "tableHeader", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Check" }] }] },
{ "type": "tableHeader", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Result" }] }] }
] },
{ "type": "tableRow", "content": [
{ "type": "tableCell", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Smoke" }] }] },
{ "type": "tableCell", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "OK" }] }] }
] }
] }
] }
} }
}Images
Upload a PNG, JPEG or WebP image up to 5 MB to a thread, then add it to a note in that thread. Send it as multipart/form-data with one file, under any field name, and a new UUID in the Operation-Id header:
curl https://thread.page/api/integrations/v1/threads/THREAD_ID/images \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Operation-Id: $(uuidgen)" \
-F [email protected]{
"operation_id": "3d9e5f1a-7b2c-4d8e-9f0a-1b2c3d4e5f6a",
"status": "accepted",
"image_id": "IMAGE_ID",
"thread_id": "THREAD_ID",
"media_type": "image/png",
"width": 1179,
"height": 2556,
"markdown": "",
"accepted_at": "2026-10-17T12:00:00Z"
}Put the answer’s markdown on its own line in a note’s markdown, outside lists, quotes, tables and headings, and write a description between the brackets:
curl https://thread.page/api/integrations/v1/threads/THREAD_ID/notes \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"operation_id": "7e4c2a19-5d3b-4f6e-8a1c-2b3d4e5f6a7b",
"content": {"markdown": "Build 412\n\n"}}'Use an image in the thread you uploaded it to. Use a new Operation-Id for each image; send the same one again only to retry the same file.
In n8n, use HTTP Request with Body Content Type Form-Data, one parameter of type n8n Binary File, and an Operation-Id header. For a new UUID per image, put a Crypto node before it (Action Generate, Type UUID) and set the header to {{ $json.data }}.
Read a note’s images from images in GET /notes/NOTE_ID: each url returns a PNG preview up to 1600 px, without location or other metadata, read with the same token. Images are stored exactly as sent, including any location data in the file.
Receive updates
An endpoint is a URL that turns each JSON event a service sends into a note in a thread you choose: form answers, alerts, results from n8n, Make or Zapier, GitHub pull requests or Slack messages.
- In Settings → Connections, open a custom connection with Full workspace access or Append only and choose Add endpoint under Receive updates.
- Choose the thread and what the service sends: Text or Markdown, GitHub pull requests, Slack messages or a Custom template.
- Copy the URL and any secret into the service. Both are shown only once; Replace URL… gives new ones.
With Text or Markdown, send a text field (kept as written) or a markdown field (formatted). An id makes a retried event count once:
curl -X POST https://thread.page/in/YOUR_ENDPOINT \
-H "Content-Type: application/json" \
-d '{"id": "build-412", "markdown": "**Build 412** passed. [Logs](https://ci.example.com/412)"}'In n8n, Make or Zapier, use an HTTP or webhook step: method POST, body type JSON, and the same fields.
GitHub: in the repository’s Settings → Webhooks, paste the URL as Payload URL, choose Content type application/json, paste the secret and pick Pull requests. A pull request opened, reopened, ready for review, merged or closed becomes a note with its title, link, repository, author and time; other events are ignored. GitHub does not resend failed deliveries by itself; use Redeliver in the webhook’s Recent Deliveries. Slack: paste the URL as the Request URL in your app’s Event Subscriptions and give ThreadPage the app’s signing secret. Each new message becomes a note that reads as it does in Slack, with its links and formatting; edits, deletions and joins are ignored.
Custom templates
A template is Markdown with fields from the event as JSON Pointers. A field is required unless it ends with ?; |url makes a link, |timestamp a readable UTC time and |markdown keeps the field’s own formatting. Other values are inserted as plain text, and null inserts nothing. The dialog previews the note from a sample event.
New lead: **{{/name}}**
{{/company?}}
{{/profile|url}}
Received {{/created_at|timestamp}}Answers
| Status | Meaning |
|---|---|
200 | Slack’s URL check, answered with its challenge |
202 | A note was added (accepted), the same event was already added (duplicate), or the event is not one this endpoint adds, such as a GitHub ping or an edited Slack message (ignored) |
400 | The body is not one JSON object, repeats a key or nests too deeply |
401 | The signature or its webhook-id is missing, or the signature is wrong or older than five minutes |
404 | No such endpoint: the URL was replaced or deleted, or the connection was disconnected |
405 | Not sent with POST |
409 | Not added: the thread is in Trash, archived or outside the connection’s access, or the connection cannot add notes now. reconnect_required means its token no longer works: create a new token in Settings → Connections |
413 | The body is over 256 KB, or the note would be over 256 KB, or over 32 KB of Markdown |
415 | Not sent as uncompressed JSON |
422 | The event has no text or markdown, lacks a field the template needs, or makes an empty note |
429 | A limit was reached: rate_limited, with details.reason and details.scope; see Retry-After |
503 | The endpoint is paused, or adding the note took too long (timed_out); see Retry-After |
Every answer except Slack’s check is JSON with outcome and a diagnostic_id; an added note also has its note_id. Each endpoint’s Events list shows its last 20 outcomes, and its row says when the latest event failed. Events refused while paused or over a limit are not kept to add later; many services do not send them again.
The URL works like a password. An event cannot choose another thread, its HTML stays text and links work only for http and https. A connection’s endpoints take up to 60 events a minute together, and each note counts against the connection’s write limits. An event can be up to 256 KB; a markdown field or a template’s note can be up to 32 KB, and plain text up to the 256 KB of a note. Ids are remembered for 30 days. ThreadPage keeps no copy of an event, only its outcome.
Signed events
A Text or Markdown or Custom template endpoint can require a Standard Webhooks signature, made as ThreadPage signs its own webhooks: HMAC-SHA256 with the endpoint’s secret over webhook-id, webhook-timestamp and the raw body, sent in webhook-signature. Each event needs its own webhook-id, which also makes a retried event count once, and a timestamp within five minutes. GitHub signs with the secret ThreadPage made for it, and Slack with your app’s signing secret.
Webhooks
A webhook sends a signed POST to your URL when notes change, so a workflow in n8n, Make, Zapier or your own server can react without polling.
- In Settings → Connections, open a custom connection with Read only or Full workspace access and choose Add webhook under Send to a workflow.
- Paste a public
httpsURL and choose the events. Include note text sends the note as plain text and Markdown; turn it off to send only IDs and links, and read the note’s text, Markdown and rich document withGET /notes/NOTE_ID. - Copy the signing secret. It is shown only once; Replace secret… gives a new one.
| Event | Sent when |
|---|---|
note.created | A note was added |
note.updated | A note’s content was edited |
note.trashed | A note was moved to Trash |
note.restored | A note was restored from Trash |
note.moved | A note was moved to another thread |
thread.created | A thread was created |
thread.archived | A thread was archived |
thread.trashed | A thread was moved to Trash |
thread.restored | A thread was restored from Trash |
note.restored carries the note as it is now. After thread.restored, read the thread’s notes through the API; they are not announced again one by one. A note that went to Trash before it was announced comes as note.created when it returns. Moving a folder to Trash or restoring it sends an event for each thread in it. A webhook or pull feed keeps the events it was added with: to hear restore events too, add it again.
Events cover only the folders and threads the connection can read; narrowing its access narrows them too. Changes you make are sent. By default, notes added through connections are not, so a workflow that writes back does not trigger itself; a subscription created through the API can ask for them with origins. Pins, checkmarks and reordering are not edits.
A connection never receives events about its own writes, including notes its endpoint URLs add. A token adds webhooks through the API only when Allow this token to add webhooks is on in the connection’s details, and otherwise gets 403 with webhooks_not_allowed; pull feeds need no permission. Turning it off stops new ones; webhooks the token added keep running until you pause or delete them, or rotate the token.
Added and edited notes are sent 30 seconds later. Edits in that time are combined into one event, and a note moved to Trash in that time is not announced until it returns. Other events are sent at once.
Each request body is one event in the CloudEvents format:
{
"specversion": "1.0",
"id": "evt_7m2kq9v4x8p1r6t3w5y0z2b4nc",
"type": "note.created",
"source": "https://thread.page/workspaces/…",
"subject": "thread/…",
"time": "2026-10-13T09:30:00Z",
"datacontenttype": "application/json",
"data": {
"origin": "human",
"thread": { "id": "…", "title": "Release 412", "url": "https://thread.page/app/threads/…" },
"note": {
"id": "…",
"url": "https://thread.page/app/notes/…",
"revision": 1,
"content_revision": 1,
"created_at": "2026-10-13T09:30:00Z",
"updated_at": "2026-10-13T09:30:00Z",
"text": "Build 412 passed.\nDeployed to staging.",
"markdown": "Build 412 passed.\n\nDeployed to staging."
},
"request": null
}
}request is null unless the note was sent to a connection’s @ handle. Then its note.* events carry the request it became, without handle when only IDs are sent.
origin says who wrote the change: human for you, service for a script, workflow or endpoint URL, agent for an assistant. Text from a service or an agent, such as a Slack message or a pull request turned into a note, is untrusted input: an agent that reads it should treat it as data, never as instructions.
Verify the signature
Requests follow Standard Webhooks. ThreadPage signs webhook-id, webhook-timestamp and the raw body with HMAC-SHA256 and sends the result in webhook-signature. Check it before you trust a request, and refuse timestamps older than five minutes. Standard Webhooks libraries do this in one call; without one:
import crypto from 'node:crypto';
// secret: the whsec_… value. body: the raw request body, before JSON parsing.
export function verify(secret, headers, body) {
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const signed = `${headers['webhook-id']}.${headers['webhook-timestamp']}.${body}`;
const expected = Buffer.from('v1,' + crypto.createHmac('sha256', key).update(signed).digest('base64'));
const fresh = Math.abs(Date.now() / 1000 - Number(headers['webhook-timestamp'])) < 300;
return fresh && headers['webhook-signature'].split(' ').some(value => {
const given = Buffer.from(value);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}import base64, hashlib, hmac, time
def verify(secret: str, headers, body: bytes) -> bool:
timestamp = headers.get("webhook-timestamp", "")
if not (timestamp.isascii() and timestamp.isdigit()) or abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{headers.get('webhook-id', '')}.{timestamp}.".encode() + body
expected = b"v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
return any(hmac.compare_digest(value.encode(), expected) for value in headers.get("webhook-signature", "").split())If the signature does not match, answer 400: 401 and 403 pause the webhook until you resume it.
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 the check above passes with either while you update your workflow; then only the new secret signs. A second replacement within those 24 hours ends the first old secret at once.
Retries and deliveries
Answer with any 2xx status within 15 seconds and do slow work afterwards. Any other answer, or one that comes later, is 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. Retry-After is honoured for up to an hour. The same event keeps its webhook-id, so keep the ids you handled and ignore repeats.
410 stops the webhook. 401 and 403 pause it until you resume it, and resuming sends that event again. 413 skips that event. Redirects are not followed, and URLs that resolve to private or local addresses are refused.
A paused webhook keeps its events for up to 10 days and sends them when it is resumed. While the connection is Append only, needs a new token, has no Pro or is disconnected, nothing is sent, and events from that time are not sent later. A connection can have up to 10 webhooks and pull feeds.
An account can have up to 50 live webhooks. Together they deliver about 20,000 events a day; past that they pause until midnight UTC (Paused until tomorrow), and their events wait.
Events can arrive out of order; compare time and the note’s content_revision before you overwrite newer text with older.
Deliveries in the connection’s details shows the last 20 attempts. Retry sends the same event to the same URL again.
Failed: no answer in 15 seconds (timeout in the API) means no complete answer came in time. Failed: certificate not trusted (tls) means the endpoint’s certificate is not valid for its host name or was not issued by an authority ThreadPage trusts. Certificates are checked strictly: one from a private certificate authority whose own certificate lacks the keyUsage extension is refused; public authorities are not affected. Both are tried again like other failures.
Not sent: the note is no longer available means the note was moved to Trash or deleted before its event went out, or the event waited longer than the 10 days it is kept; your endpoint is not at fault.
Pull feed
A script without a public URL can read the same events from a pull feed. Create one with the token:
curl https://thread.page/api/integrations/v1/subscriptions \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind": "pull", "event_types": ["note.created", "note.updated"],
"payload_mode": "content"}'A webhook or pull feed the token adds sends IDs only unless you set "payload_mode": "content", as this one does.
The first read without after returns the current cursor. Then ask for events after it; wait keeps the request open for up to 25 seconds until something happens. Save the new cursor once you have handled the events, and ask again at once while has_more is true. Added and edited notes appear after the same 30 seconds and requests after the request delay, in order: a note this feed lists that is still waiting holds later events back. A paused feed answers 409 and keeps its events for when it is resumed.
curl "https://thread.page/api/integrations/v1/subscriptions/SUBSCRIPTION_ID/events?after=CURSOR&wait=25" \
-H "Authorization: Bearer $THREADPAGE_TOKEN"A cursor older than seven days answers 410 with details.resync_cursor. Continue from that cursor and read current notes through the API, because events in between are not replayed.
Answer addressed notes
A custom connection can receive requests. When a note you send names it with its @ handle, the note becomes a request: your script or workflow reads it, does the work and answers with a note in the same thread.
- In Settings → Connections, open a custom connection with Full workspace access and choose Receive requests under Receives @. Its handle comes from its name; Change handle renames it.
- In the Composer, type the handle in full, like
@Researchfollowed by a space, or type@(or press the@button) and choose the connection. The handle turns into a highlighted name: that is who receives the note, one per note. Pasted or quoted text and code never address a note. The note text you receive includes the handle. - The connection’s webhooks and pull feeds get
request.createdwhatever events they list, andGET /requestslists the request. Test request sends one without a note; once you answer it, the connection shows Ready to receive.
While you pause requests under Receives @, a request that has not reached the connection waits: GET /requests does not list it, and its request.created comes once you resume. Requests the connection already has go on.
To get only requests, create the pull feed or webhook with "event_types": []. It then gets this connection’s request.created and request.cancelled and no note or thread events, so a worker that answers requests sees no other content.
A request goes out once the workspace’s request delay has passed — 10 seconds unless you choose 5, 20 or 30 in Settings → Connections — so a request cancelled in that time is never seen. The event carries the note as it was sent; GET /requests/REQUEST_ID shows the note as it is now, with sent_content_revision, and no note once it was moved to Trash.
{
"specversion": "1.0",
"id": "evt_4k8m2q7v1x9p3r6t5w0y2z8bnd",
"type": "request.created",
"source": "https://thread.page/workspaces/…",
"subject": "thread/…",
"time": "2026-10-13T09:30:30Z",
"datacontenttype": "application/json",
"data": {
"origin": "human",
"thread": { "id": "…", "title": "Interviews", "url": "https://thread.page/app/threads/…" },
"note": {
"id": "…",
"url": "https://thread.page/app/notes/…",
"revision": 1,
"content_revision": 1,
"created_at": "2026-10-13T09:30:00Z",
"updated_at": "2026-10-13T09:30:00Z",
"text": "@Research summarise this week’s interviews",
"markdown": "@Research summarise this week’s interviews"
},
"request": {
"id": "REQUEST_ID",
"state": "queued",
"test": false,
"handle": "Research",
"expires_at": "2026-10-20T09:30:00Z",
"reply": {
"status_url": "https://thread.page/api/integrations/v1/requests/REQUEST_ID/status",
"result_url": "https://thread.page/api/integrations/v1/requests/REQUEST_ID/result"
},
"answers": null
}
}
}Claim the request with a claim_id you generate for the run; the same run may claim again, any other gets 409 already_claimed with details.claimed_at and does not start. Report running while you work, then send the answer as result. It becomes a note that starts with a reference to the addressed note and shows “From @Name”. Generate one operation_id per answer and reuse it when you retry.
# One run claims the request; keep its claim_id for the next calls.
curl https://thread.page/api/integrations/v1/requests/REQUEST_ID/claim \
-H "Authorization: Bearer $THREADPAGE_TOKEN" -H "Content-Type: application/json" \
-d '{"claim_id": "0f3c6a52-8d3e-4f1b-9a57-2c4d6e8f0a1b"}'
curl https://thread.page/api/integrations/v1/requests/REQUEST_ID/status \
-H "Authorization: Bearer $THREADPAGE_TOKEN" -H "Content-Type: application/json" \
-d '{"claim_id": "0f3c6a52-8d3e-4f1b-9a57-2c4d6e8f0a1b", "state": "running", "message": "Reading the interviews"}'
curl https://thread.page/api/integrations/v1/requests/REQUEST_ID/result \
-H "Authorization: Bearer $THREADPAGE_TOKEN" -H "Content-Type: application/json" \
-d '{"claim_id": "0f3c6a52-8d3e-4f1b-9a57-2c4d6e8f0a1b",
"operation_id": "6c1f9e2a-3b4d-4e5f-8a7b-9c0d1e2f3a4b",
"content": {"markdown": "**Three themes** came up this week …"}}'To ask the owner something, send status with needs_input and a question (text, markdown or document, like a note); it is added as a note that refers to the addressed note. The owner answers with Answer: that note arrives as a new request whose answers names the request and question it answers, and claiming it closes that request as answered. failed takes a short message the owner sees. Your own notes never become requests.
curl https://thread.page/api/integrations/v1/requests/REQUEST_ID/status \
-H "Authorization: Bearer $THREADPAGE_TOKEN" -H "Content-Type: application/json" \
-d '{"claim_id": "0f3c6a52-8d3e-4f1b-9a57-2c4d6e8f0a1b", "state": "needs_input",
"question": {"markdown": "Which period: **this week** or **this month**?"}}'If a run stops after it may have acted and cannot tell whether its action happened, send status outcome_unknown with a short message and do not repeat the work: the owner sees Result unknown and can send the request again.
GET /requests/REQUEST_ID shows one request; GET /requests lists the same items. claimed_at says when a run claimed it.
{
"id": "REQUEST_ID",
"state": "delivered",
"test": false,
"handle": "Research",
"created_at": "2026-10-13T09:30:00Z",
"expires_at": "2026-10-20T09:30:00Z",
"thread": { "id": "…", "title": "Interviews", "url": "https://thread.page/app/threads/…" },
"note": {
"id": "…",
"url": "https://thread.page/app/notes/…",
"content_revision": 1,
"sent_content_revision": 1,
"text": "@Research summarise this week’s interviews",
"markdown": "@Research summarise this week’s interviews"
},
"claim_id": null,
"claimed_at": null,
"reply": {
"status_url": "https://thread.page/api/integrations/v1/requests/REQUEST_ID/status",
"result_url": "https://thread.page/api/integrations/v1/requests/REQUEST_ID/result"
},
"answers": null
}Let a workflow act on GET /requests/REQUEST_ID, not on the webhook body: the request may have been cancelled or its note changed since. Derive claim_id from the request’s id, for example as a UUID v5, so a run that starts again for the same request claims it again. Every few minutes, list GET /requests and pick up open requests a missed delivery did not bring.
The owner can stop a request: Cancel or Stop under the note or in its menu, moving the note or its thread to Trash, or archiving the thread. You then get request.cancelled. A request no run claimed ends as cancelled at once. A claimed one reads cancellation_requested: stop and confirm with status cancelled, or send the result if the work is already done. Switching off receiving, narrowing the connection’s access or disconnecting it ends the requests it can no longer answer; ones you already had end as cancelled. A request expires after 7 days, and an account can have up to 100 open requests.
| Answer | Meaning |
|---|---|
403 not_receiving | Receiving requests is off for this connection |
403 write_not_allowed | The connection no longer has Full workspace access |
404 not_found | Not this connection’s request, expired, or in a thread outside its access |
409 already_claimed | Another run claimed it with a different claim_id; details.claimed_at says when. Do not start the work |
409 not_claimed | Claim the request before reporting or answering |
409 cancellation_requested | The owner asked to stop; send status cancelled, or the result if the work is done |
409 request_closed | The request has ended: answered, cancelled, failed or expired |
409 operation_id_reused | The same operation_id came with a different answer |
Endpoints
| Request | Returns or does | Access |
|---|---|---|
GET /connection | This token’s connection and access | Any |
GET /threads | Threads, filtered by state or folder_id | Any |
GET /threads/{id} | One thread | Any |
GET /threads/{id}/notes | Notes, newest first, with text previews | Read |
POST /inbox/notes | Add a note to the Inbox | Any |
POST /threads/{id}/notes | Add a note from text, Markdown or a rich document | Add |
POST /threads/{id}/images | Upload an image for a new note in the thread | Add |
GET /notes/{id} | A note’s full text, Markdown and rich document | Read |
GET /notes/{id}/images/{image_id} | A PNG preview of an image the note holds | Read |
GET /search?q= | Search notes | Read |
GET /operations/{id} | The receipt of an earlier write | Any |
GET /subscriptions | This connection’s webhooks and pull feeds | Read |
POST /subscriptions | Add a pull feed, or a webhook where the owner allows it | Read |
GET /subscriptions/{id}/events | Read a pull feed | Read |
GET /events/sample | Up to three recent events in their live form, for setting up a workflow; IDs only unless you add payload_mode=content; empty until something happens | Read |
GET /requests | Open requests addressed to this connection, newest first; state picks another state | Full |
GET /requests/{id} | One request with its thread and note | Full |
POST /requests/{id}/claim | Take a request for one run | Full |
POST /requests/{id}/status | Report running, needs input, failed, outcome unknown or cancelled | Full |
POST /requests/{id}/result | Answer with a note in the request’s thread | Full |
Read works with Full workspace access or Read only. Add works with Full workspace access or Append only. Full needs Full workspace access and receiving requests switched on. Lists return next_cursor; send it as cursor for the next page.
The complete contract is an OpenAPI description at /api/integrations/v1/openapi.json. Use it to generate a client or give it to an agent.
Limits and errors
Each connection can make 120 requests and 30 writes a minute, and 1,000 writes a day. Limits also apply across all connections of an account, which can add up to 10 MB of note content a day. A note can be up to 256 KB, or 32 KB as Markdown. Over a limit, the API answers 429 with Retry-After.
All connections of an account together can make 300 requests a minute, 60 writes a minute and 2,000 a day, with up to 5 calls running at once, and an account can have up to 25 custom connections. Everything the account writes, its own apps included, can add up to 50 MB a day in up to 300 writes a minute and 50,000 a day.
Every 429 is rate_limited: details.reason names the limit and details.scope says whose it is, connection or owner (all connections of the account). The reasons are busy (more than 5 calls of the account at once), requests_minute, writes_minute, writes_day, content_day, daily_write_volume and write_rate (everything the account writes, its own apps too), daily_response_volume (2 GB of answers a day for all connections, webhook bodies included), markdown_minute, subscriptions_minute, events_minute and the image limits below. A call that runs over 10 seconds answers 503 with timed_out; send it again after Retry-After.
Errors are JSON with code, message and retryable. 400 means the request is malformed or the token was sent in the query string instead of the Authorization header. 401 means the token is missing, replaced, revoked or expired. 403 means the connection’s access does not allow the request; with pro_required, that it needs Pro (a free account’s connections add notes to the Inbox); with cloud_required, that it needs Sync turned on; with webhooks_not_allowed, that the owner has not allowed the token to add webhooks. Content outside its access answers 404, as if it did not exist. A body not sent as JSON answers 415, except an image upload, which is multipart/form-data. 403 with reconnect_required means the token no longer works: its owner creates a new token in Settings → Connections.
An image can be up to 5 MB, 20 megapixels and 300 frames. Each connection can upload 100 MB of new images and read 500 image previews a day, and 20 previews a minute; all connections of an account together, 250 MB and 1,000 previews a day, and 30 image uploads and previews a minute. Over an image limit the API answers 429 with rate_limited and Retry-After: details.reason is image_bytes_day, image_previews_day, image_previews_minute or image_decodes_minute, and details.scope is connection or owner (all connections of the account).
409 with image_unavailable means a note names an image this connection can’t use there: upload it to that thread first. 422 with invalid_attachment means the file is not a PNG, JPEG or WebP image within these limits. 413 with content_too_large means an image over 5 MB, and with attachment_quota_exceeded that the workspace’s image storage is full. 503 with image_busy means another image is being processed: send the same request again after Retry-After.
Keep the token safe
Keep the token in a secret store or environment variable, never in notes or source code. Anyone with it gets the connection’s access.
In Settings → Connections, Rotate token… replaces it at once and Disconnect… revokes it. Tokens expire after a year. If Pro or a trial ends, the token keeps adding notes to your Inbox, and what else it could do comes back with Pro.
If something leaks
- A token: Rotate token… replaces it, and webhooks it added pause until you resume them; check their URLs first. Recent activity in the connection’s details shows what the token read and changed.
- Check Send to a workflow and delete webhooks and pull feeds you do not recognise.
- An endpoint URL or its secret: choose Replace URL… on the endpoint. The old ones stop working at once.
- A webhook’s signing secret (
whsec_…): choose Replace secret… and put the new secret in your workflow right away: requests forged with the leaked secret then fail your check, and ThreadPage’s own requests already carry the new signature. - When unsure: Disconnect… stops the connection’s token, endpoint URLs, webhooks and pull feeds at once.