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

## 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](https://thread.page/help/connections/events.md#payload-modes).

## Create a feed

`POST /subscriptions` with `"kind": "pull"` and the types to hear, and an `Operation-Id`:

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

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

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

**Parameters**

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

**Answer fields**

| Field | Type | Description |
|---|---|---|
| `events` | array of object, required |  |
| `cursor` | string, required | Send back as after= to continue; opaque and signed. |
| `has_more` | boolean, required |  |

```bash
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](https://thread.page/help/connections/errors.md#errors-every-call-can-get), 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.

JavaScript:

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

Python:

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

**Errors**

| 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](https://thread.page/help/connections/events.md): every event type.
- [Requests](https://thread.page/help/connections/requests.md): a worker that reads requests from a feed.
