Pull feed
A pull feed gives you the same events as a webhook, but your code asks for them. It needs no public URL, so it suits scripts on a laptop, servers behind a firewall and local development.
On this page
When to use it
- Your code has no public HTTPS address.
- You want events in order, and to choose when to read them.
- You are developing a webhook receiver and want real events first.
A token adds a pull feed whenever its connection can read, with Pro and Sync turned on; it needs no permission from the owner. Like a token’s webhook, it carries IDs and hears the owner’s changes only. See Events and subscriptions.
Create a feed
POST /subscriptions with "kind": "pull" and the types to hear, and an Operation-Id:
OP=$(uuidgen)
curl -m 30 --retry 3 --retry-all-errors https://thread.page/api/integrations/v1/subscriptions \
-H "Authorization: Bearer $THREADPAGE_TOKEN" \
-H "Operation-Id: $OP" \
-H "Content-Type: application/json" \
-d '{"kind": "pull", "event_types": ["note.created", "note.updated"]}'The answer’s subscription.id is the feed. Keep it with your code’s settings.
Read events
- Read without
after. The answer has the currentcursorand no events. - Read with
afterset to that cursor andwaitup to 25 seconds. The call returns as soon as events exist, or with none when the wait ends. - Handle the events, then store the new
cursor. Store it only after the events are handled, so a crash in between reads them again instead of losing them. - Read again at once while
has_moreistrue.
curl -m 40 "https://thread.page/api/integrations/v1/subscriptions/$FEED_ID/events?after=$CURSOR&wait=25" \
-H "Authorization: Bearer $THREADPAGE_TOKEN"One long poll waits per feed. A newer call that waits takes over, and the older one answers at once with no events and its cursor.
Read a pull feed
GET /api/integrations/v1/subscriptions/{subscription_id}/events
Without after, returns the current cursor and no events. With after, returns the events since that cursor, waiting up to wait seconds (0-25) when there are none yet. Store the returned cursor once the events are processed. One long poll waits per feed: a newer one that waits takes over, and the older answers at once with no events and its cursor. An expired cursor answers 410 with details.resync_cursor; events between the two cursors are not replayed.
| Requirement | Value |
|---|---|
| Levels | Edit and organize · Read and add · Read only |
| Plan | Pro with Sync. On Free, add to the Inbox instead (POST /inbox/notes). |
| Name | In | Type | Description |
|---|---|---|---|
after | query | string | The cursor from the previous page. |
subscription_id | path | uuid, required | |
wait | query | integer | 0-25 seconds; default 0. |
Answer 200.
| Field | Type | Description |
|---|---|---|
events | array of object, required | |
cursor | string, required | Send back as after= to continue; opaque and signed. |
has_more | boolean, required |
curl -m 30 "https://thread.page/api/integrations/v1/subscriptions/$SUBSCRIPTION_ID/events" \
-H "Authorization: Bearer $THREADPAGE_TOKEN"Errors: read_not_allowed 403, not_found 404, subscription_paused 409, paused_by_owner 409, cursor_expired 410.
Also the errors of every call, and pro_required or cloud_required without Pro and Sync.
Two kinds of cursor
| 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 |
Order and settling
A feed answers events in order. Added and edited notes appear 30 seconds after the last edit, and requests after the owner’s request delay. A note this feed lists that is still settling holds later events back until it is announced.
An event can still arrive twice, for example after a crash before you stored the cursor. Drop repeats by the event’s id.
Paused feeds
A paused feed answers 409 subscription_paused and keeps its events until it is resumed with POST /subscriptions/{subscription_id}/resume. A feed the owner paused can only be resumed by the owner: the call answers 409 paused_by_owner.
Old cursors
A cursor older than 7 days, or one from another feed, answers 410 cursor_expired with details.resync_cursor. Continue from that cursor, and read current notes through the API: events between the two cursors are not replayed.
Feed reader
A complete reader: it creates a feed if you have none, stores its cursor and the IDs it handled in a file, drops repeats, recovers from an old cursor and waits as Retry-After asks.
// A pull feed reader: it reads events as they come, handles each one once, and keeps its place
// in a file, so it continues where it stopped after a restart.
// Run with Node.js 24: THREADPAGE_TOKEN=tp_… node feed_reader.mjs
// Set THREADPAGE_FEED_ID to read a feed you already have; otherwise it creates one.
import { randomUUID } from 'node:crypto';
import { readFile, rename, writeFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';
const API = 'https://thread.page/api/integrations/v1';
const AUTH = { Authorization: `Bearer ${process.env.THREADPAGE_TOKEN}` };
const STATE = process.env.THREADPAGE_FEED_STATE ?? 'threadpage-feed.json';
async function load() {
try {
return JSON.parse(await readFile(STATE, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return { seen: [] };
throw error;
}
}
async function save(state) {
await writeFile(`${STATE}.tmp`, JSON.stringify(state));
await rename(`${STATE}.tmp`, STATE); // a crash never leaves half a file
}
// POST /subscriptions with one Operation-Id, kept in the state file until the feed exists.
async function createFeed(state) {
state.createOperationId ??= randomUUID();
await save(state);
let answer;
for (let attempt = 1; ; attempt++) {
try {
answer = await fetch(`${API}/subscriptions`, {
method: 'POST',
headers: { ...AUTH, 'Operation-Id': state.createOperationId, 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'pull', event_types: ['note.created', 'note.updated', 'note.trashed'] }),
signal: AbortSignal.timeout(30_000),
});
break;
} catch (error) {
if (attempt === 3) throw error;
await sleep(attempt * 2_000);
}
}
if (!answer.ok) throw new Error(`${answer.status}: ${await answer.text()}`);
state.feedId = (await answer.json()).subscription.id;
delete state.createOperationId;
await save(state);
}
function handle(event) {
const item = event.data.note ?? event.data.thread ?? {};
console.log(event.type, item.id);
}
async function readForever(state) {
for (;;) {
const url = new URL(`${API}/subscriptions/${state.feedId}/events`);
if (state.cursor) {
url.searchParams.set('after', state.cursor);
url.searchParams.set('wait', '25');
}
let answer;
try {
answer = await fetch(url, { headers: AUTH, signal: AbortSignal.timeout(40_000) });
} catch {
await sleep(5_000);
continue;
}
if (answer.status === 410) { // the cursor is older than 7 days
state.cursor = (await answer.json()).details.resync_cursor;
await save(state);
console.log('Events in between are not replayed: read current notes through the API.');
continue;
}
if (answer.status === 429 || answer.status === 503) {
await sleep(Number(answer.headers.get('Retry-After') ?? 5) * 1_000);
continue;
}
if (answer.status === 409) { // the feed is paused; its events wait
await sleep(60_000);
continue;
}
if (!answer.ok) throw new Error(`${answer.status}: ${await answer.text()}`);
const page = await answer.json();
for (const event of page.events) {
if (state.seen.includes(event.id)) continue; // the same event again
handle(event);
state.seen = [...state.seen, event.id].slice(-1000);
await save(state);
}
state.cursor = page.cursor; // only once the events are handled
await save(state);
}
}
let state = await load();
const feedId = process.env.THREADPAGE_FEED_ID;
if (feedId && state.feedId !== feedId) state = { seen: [], feedId }; // a cursor belongs to one feed
if (!state.feedId) await createFeed(state);
await readForever(state);# A pull feed reader: it reads events as they come, handles each one once, and keeps its place
# in a file, so it continues where it stopped after a restart.
# Run with Python 3.13: pip install requests, then THREADPAGE_TOKEN=tp_… python feed_reader.py
# Set THREADPAGE_FEED_ID to read a feed you already have; otherwise it creates one.
import json
import os
import pathlib
import time
import uuid
import requests
API = "https://thread.page/api/integrations/v1"
AUTH = {"Authorization": f"Bearer {os.environ['THREADPAGE_TOKEN']}"}
STATE = pathlib.Path(os.environ.get("THREADPAGE_FEED_STATE", "threadpage-feed.json"))
def load():
return json.loads(STATE.read_text()) if STATE.exists() else {"seen": []}
def save(state):
draft = STATE.with_suffix(".tmp")
draft.write_text(json.dumps(state))
draft.replace(STATE) # a crash never leaves half a file
def create_feed(state):
"""POST /subscriptions with one Operation-Id, kept in the state file until the feed exists."""
state.setdefault("create_operation_id", str(uuid.uuid4()))
save(state)
for attempt in range(1, 4):
try:
answer = requests.post(
f"{API}/subscriptions",
headers={**AUTH, "Operation-Id": state["create_operation_id"]},
json={"kind": "pull", "event_types": ["note.created", "note.updated", "note.trashed"]},
timeout=30,
)
break
except (requests.ConnectionError, requests.Timeout):
if attempt == 3:
raise
time.sleep(attempt * 2)
answer.raise_for_status()
state["feed_id"] = answer.json()["subscription"]["id"]
del state["create_operation_id"]
save(state)
def handle(event):
data = event["data"]
print(event["type"], (data.get("note") or data.get("thread") or {}).get("id"), flush=True)
def read_forever(state):
while True:
params = {"after": state["cursor"], "wait": 25} if state.get("cursor") else {}
try:
answer = requests.get(
f"{API}/subscriptions/{state['feed_id']}/events", headers=AUTH, params=params, timeout=40
)
except (requests.ConnectionError, requests.Timeout):
time.sleep(5)
continue
if answer.status_code == 410: # the cursor is older than 7 days
state["cursor"] = answer.json()["details"]["resync_cursor"]
save(state)
print("Events in between are not replayed: read current notes through the API.")
continue
if answer.status_code in (429, 503):
time.sleep(int(answer.headers.get("Retry-After", 5)))
continue
if answer.status_code == 409: # the feed is paused; its events wait
time.sleep(60)
continue
answer.raise_for_status()
page = answer.json()
for event in page["events"]:
if event["id"] in state["seen"]:
continue # the same event again
handle(event)
state["seen"] = [*state["seen"], event["id"]][-1000:]
save(state)
state["cursor"] = page["cursor"] # only once the events are handled
save(state)
if __name__ == "__main__":
state = load()
feed_id = os.environ.get("THREADPAGE_FEED_ID")
if feed_id and state.get("feed_id") != feed_id:
state = {"seen": [], "feed_id": feed_id} # a cursor belongs to one feed
if "feed_id" not in state:
create_feed(state)
read_forever(state)If it fails
| Code | HTTP | When | What to do | Retryable |
|---|---|---|---|---|
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 |
cursor_expired | 410 | A pull feed cursor older than 7 days, or from another feed. | Continue from details.resync_cursor and read current notes through the API. | No |
subscription_paused | 409 | The subscription is paused. | Resume it first. | No |
paused_by_owner | 409 | The owner paused it, so only the owner resumes it. | Ask the owner. | No |
rate_limited | 429 | A technical limit or a change limit; details.reason names it and details.scope whose it is. | Wait Retry-After seconds (MCP: details.retry_after) and send the identical request. | Yes |
operation_id_reused | 409 | The Operation-Id (MCP: operation_id) was already used with another body. | If you are repeating a lost write, read GET /operations/{id} first. Otherwise use a new ID. | No |
Next
- Events and subscriptions: every event type.
- Requests: a worker that reads requests from a feed.