ThreadPage
All docs

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.

View as Markdown

On this page
  1. When to use it
  2. Create a feed
  3. Read events
  4. Two kinds of cursor
  5. Order and settling
  6. Paused feeds
  7. Old cursors
  8. Feed reader
  9. If it fails
  10. Next

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:

Shell
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

  1. Read without after. The answer has the current cursor and no events.
  2. Read with after set to that cursor and wait up to 25 seconds. The call returns as soon as events exist, or with none when the wait ends.
  3. 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.
  4. Read again at once while has_more is true.
Shell
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.

Requirements
RequirementValue
LevelsEdit and organize · Read and add · Read only
PlanPro with Sync. On Free, add to the Inbox instead (POST /inbox/notes).
Parameters
NameInTypeDescription
afterquerystringThe cursor from the previous page.
subscription_idpathuuid, required
waitqueryinteger0-25 seconds; default 0.

Answer 200.

Answer fields
FieldTypeDescription
eventsarray of object, required
cursorstring, requiredSend back as after= to continue; opaque and signed.
has_moreboolean, required
curl
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

Two kinds of cursor
ListsPull feed
You sendcursor, limitafter, wait
You getnext_cursorcursor, has_more
A cursor lives1 hour, and until the owner changes the connection’s access7 days
Too old409 context_changed: start again without it410 cursor_expired: continue from details.resync_cursor
From another list or feed422410 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);

If it fails

Errors
CodeHTTPWhenWhat to doRetryable
read_not_allowed403Reading, or subscribing to events, on Add only or Add to Inbox.Tell the person and give them details.manage_url.No
cursor_expired410A 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_paused409The subscription is paused.Resume it first.No
paused_by_owner409The owner paused it, so only the owner resumes it.Ask the owner.No
rate_limited429A 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_reused409The 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