# 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.

## Choose what to build

| I want to… | Use | Read |
|---|---|---|
| add notes from a script | the Integration API | [Notes](https://thread.page/help/connections/api/notes.md) |
| post a service’s events into a thread | an endpoint URL | [Endpoint URLs](https://thread.page/help/connections/endpoints.md) |
| react when notes change | a webhook or a pull feed | [Receive webhooks](https://thread.page/help/connections/webhooks.md), [Pull feed](https://thread.page/help/connections/feed.md) |
| answer notes addressed to my worker | Requests | [Requests](https://thread.page/help/connections/requests.md) |
| let an assistant work in my notes | the MCP server | [MCP server](https://thread.page/help/connections/mcp.md) |

## 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](https://thread.page/help/connections/access.md) 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](#the-whole-flow).

```bash
export THREADPAGE_TOKEN=tp_your_token
API=https://thread.page/api/integrations/v1
```

curl:

```bash
curl -sS -m 30 "$API/connection" \
  -H "Authorization: Bearer $THREADPAGE_TOKEN" | tee connection.json | jq .
```

JavaScript:

```javascript
const connection = await call('GET', '/connection');
console.log(connection.level, connection.permissions, connection.expires_at);
```

Python:

```python
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:

```json
{
  "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:

```bash
curl -sS -m 30 "$API/threads?limit=50" \
  -H "Authorization: Bearer $THREADPAGE_TOKEN" | tee threads.json | jq -r '.items[] | "\(.id)  \(.title)"'
```

JavaScript:

```javascript
const { items } = await call('GET', '/threads?limit=50');
for (const thread of items) console.log(thread.id, thread.title);
```

Python:

```python
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:

curl:

```bash
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 .
```

JavaScript:

```javascript
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);
```

Python:

```python
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"])
```

```json
{
  "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:

curl:

```bash
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 .
```

JavaScript:

```javascript
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
```

Python:

```python
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
```

```json
{
  "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:

```bash
curl -sS -m 30 "$API/operations/$OP" \
  -H "Authorization: Bearer $THREADPAGE_TOKEN" | jq .
```

JavaScript:

```javascript
console.log(await call('GET', `/operations/${operationId}`));
```

Python:

```python
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

curl:

```bash
#!/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 .
```

JavaScript:

```javascript
// 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}`));
```

Python:

```python
# 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

**Errors**

| 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/sample` shows the shape of real events. A **Test event** reaches your **Note updates** URL signed like any event, with `data.test` set to `true`. **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](https://thread.page/help/connections/api.md): authentication, access, safe writes, pagination and content.
- [Notes](https://thread.page/help/connections/api/notes.md): add, read, search and edit notes, images and files.
- [Events and subscriptions](https://thread.page/help/connections/events.md): hear when notes change.
- [Errors](https://thread.page/help/connections/errors.md) and [Rate limits](https://thread.page/help/connections/limits.md).
- [A lab notebook your script fills in](https://thread.page/use-cases/lab-notebook): a worked example, where an analysis script adds its plot and results to a thread.

## Machine-readable

- The contract: [OpenAPI description](https://thread.page/api/integrations/v1/openapi.json).
- 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](https://thread.page/llms.txt). Every page here has a Markdown version at the same address with `.md`.
