ThreadPage
Connections help

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

  1. 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.
  2. Name the connection. Notes it adds show “Added via” with this name.
  3. 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.
  4. 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": "![](attachment:IMAGE_ID)",
  "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![Summary](attachment:IMAGE_ID)"}}'

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.

  1. In Settings → Connections, open a custom connection with Full workspace access or Append only and choose Add endpoint under Receive updates.
  2. Choose the thread and what the service sends: Text or Markdown, GitHub pull requests, Slack messages or a Custom template.
  3. 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

StatusMeaning
200Slack’s URL check, answered with its challenge
202A 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)
400The body is not one JSON object, repeats a key or nests too deeply
401The signature or its webhook-id is missing, or the signature is wrong or older than five minutes
404No such endpoint: the URL was replaced or deleted, or the connection was disconnected
405Not sent with POST
409Not 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
413The body is over 256 KB, or the note would be over 256 KB, or over 32 KB of Markdown
415Not sent as uncompressed JSON
422The event has no text or markdown, lacks a field the template needs, or makes an empty note
429A limit was reached: rate_limited, with details.reason and details.scope; see Retry-After
503The 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.

  1. In Settings → Connections, open a custom connection with Read only or Full workspace access and choose Add webhook under Send to a workflow.
  2. Paste a public https URL 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 with GET /notes/NOTE_ID.
  3. Copy the signing secret. It is shown only once; Replace secret… gives a new one.
EventSent when
note.createdA note was added
note.updatedA note’s content was edited
note.trashedA note was moved to Trash
note.restoredA note was restored from Trash
note.movedA note was moved to another thread
thread.createdA thread was created
thread.archivedA thread was archived
thread.trashedA thread was moved to Trash
thread.restoredA 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:

Node.js
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);
  });
}
Python
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.

  1. 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.
  2. In the Composer, type the handle in full, like @Research followed 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.
  3. The connection’s webhooks and pull feeds get request.created whatever events they list, and GET /requests lists 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.

AnswerMeaning
403 not_receivingReceiving requests is off for this connection
403 write_not_allowedThe connection no longer has Full workspace access
404 not_foundNot this connection’s request, expired, or in a thread outside its access
409 already_claimedAnother run claimed it with a different claim_id; details.claimed_at says when. Do not start the work
409 not_claimedClaim the request before reporting or answering
409 cancellation_requestedThe owner asked to stop; send status cancelled, or the result if the work is done
409 request_closedThe request has ended: answered, cancelled, failed or expired
409 operation_id_reusedThe same operation_id came with a different answer

Endpoints

RequestReturns or doesAccess
GET /connectionThis token’s connection and accessAny
GET /threadsThreads, filtered by state or folder_idAny
GET /threads/{id}One threadAny
GET /threads/{id}/notesNotes, newest first, with text previewsRead
POST /inbox/notesAdd a note to the InboxAny
POST /threads/{id}/notesAdd a note from text, Markdown or a rich documentAdd
POST /threads/{id}/imagesUpload an image for a new note in the threadAdd
GET /notes/{id}A note’s full text, Markdown and rich documentRead
GET /notes/{id}/images/{image_id}A PNG preview of an image the note holdsRead
GET /search?q=Search notesRead
GET /operations/{id}The receipt of an earlier writeAny
GET /subscriptionsThis connection’s webhooks and pull feedsRead
POST /subscriptionsAdd a pull feed, or a webhook where the owner allows itRead
GET /subscriptions/{id}/eventsRead a pull feedRead
GET /events/sampleUp to three recent events in their live form, for setting up a workflow; IDs only unless you add payload_mode=content; empty until something happensRead
GET /requestsOpen requests addressed to this connection, newest first; state picks another stateFull
GET /requests/{id}One request with its thread and noteFull
POST /requests/{id}/claimTake a request for one runFull
POST /requests/{id}/statusReport running, needs input, failed, outcome unknown or cancelledFull
POST /requests/{id}/resultAnswer with a note in the request’s threadFull

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

  1. 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.
  2. Check Send to a workflow and delete webhooks and pull feeds you do not recognise.
  3. An endpoint URL or its secret: choose Replace URL… on the endpoint. The old ones stop working at once.
  4. 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.
  5. When unsure: Disconnect… stops the connection’s token, endpoint URLs, webhooks and pull feeds at once.
Open Connections