# Rate limits

ThreadPage limits how fast and how much a connection, and all of an account’s connections together, can call and write. Every plan has the same technical limits; only calls a minute and calls at once are lower on Free. Over a limit, nothing happens and the call answers `429`.

## Per connection and per account

| Limit | Per connection | All of the account’s connections | `details.reason` |
|---|---|---|---|
| Calls a minute | 120 | 300 (30 on Free) | `requests_minute` |
| Calls running at once |  | 5 (2 on Free) | `busy` |
| Writes a minute | 30 | 60 | `writes_minute` |
| Writes a UTC day | 1,000 | 2,000 | `writes_day` |
| Note content written a UTC day |  | 10 MiB | `content_day` |
| Markdown notes converted a minute | 60 |  | `markdown_minute` |
| Subscriptions created a minute | 20 |  | `subscriptions_minute` |
| Answers a UTC day, webhook bodies included |  | 2 GiB | `daily_response_volume` |
| Changes to notes it did not add or that a person edited, and moves to Trash, a clock hour | 200 |  | `changes_hour` |
| The same, a UTC day | 1,000 |  | `changes_day` |
| Moves to Trash a clock hour | 100 |  | `trash_hour` |

Adding notes doesn’t count toward the change limits. Each call counts as one change: moving a folder to Trash is one move, whatever it holds. A change limit’s `Retry-After` runs to the end of its clock hour or UTC day. A call that runs longer than 10 seconds answers `503` `timed_out`.

Everything the account writes, its own apps and the people it shares with included, also has a ceiling: 50 MiB a day, 300 writes a minute and 50,000 a day (`daily_write_volume` and `write_rate`, scope `owner`).

The owner gets an email when a connection’s activity is unusual, such as many changes to notes people wrote or many reads in 24 hours: at most one a day for each connection. The email never blocks a call; the limits above do.

## Free

- Connections add up to 3,000 notes a month (UTC) to the Inbox, all of them together. Then `429` `quota_exhausted`, without `Retry-After`; `details.resets_at` says when the count starts again.
- Up to 1,000 notes or 20 MB wait for the owner’s browser at once. Then `409` `inbox_full`.

Pro has no monthly limit on notes.

## Images and files

| Limit | Per connection | All of the account’s connections | `details.reason` |
|---|---|---|---|
| One image | 5 MB, 20 megapixels, 300 frames |  | `413` / `422`, not a `429` |
| Image uploads a UTC day | 100 MB | 250 MB | `image_bytes_day` |
| Image and file previews a minute | 20 |  | `image_previews_minute` |
| Image and file previews a UTC day | 500 | 1,000 | `image_previews_day` |
| Images decoded a minute (uploads and previews) |  | 30 | `image_decodes_minute` |

## Webhooks, feeds and endpoint URLs

| Limit | Value |
|---|---|
| Webhooks and pull feeds | 10 per connection |
| Live webhooks | 50 per account (`409` `webhook_limit`) |
| Deliveries a UTC day | about 20,000 per account; past that, webhooks show **Paused until tomorrow** and resume after midnight UTC |
| Endpoint URLs | 10 per connection |
| Events into endpoint URLs | 60 a minute per connection (`events_minute`) |
| Automations | 25 per account |
| Open requests | 100 per account; a request expires after 7 days |
| Owner’s tests (endpoint **Test…**, **Test event**, **Test request**, **Send test**) | 10 a minute, 100 a day (`tests`) |

## What a 429 says

```json
{
  "code": "rate_limited",
  "message": "This connection has reached its current limit.",
  "retryable": true,
  "diagnostic_id": "c47a1e09-5f2b-4d86-a3c0-1e9b7d4f2a65",
  "details": {"retry_after": 12, "reason": "writes_minute", "scope": "connection"}
}
```

- `details.reason` names the limit, from the tables above. `details.scope` says whose it is: `connection`, or `owner` for all of the account’s connections together.
- `details.retry_after` and the `Retry-After` header give the seconds to wait.
- `inbox_minute` and `tests` come only from the owner’s own actions in the app: the browsers that collect the Inbox, and the owner’s tests.

There are no `RateLimit-*` headers; wait `Retry-After` seconds.

`quota_exhausted` is the one `429` without `Retry-After`: Free’s notes for the month are used. Stop until `details.resets_at`.

## Back off correctly

Wait as long as `Retry-After` says, then send the identical request with the same `Operation-Id`. A daily limit can mean waiting until midnight UTC; if your job can’t wait that long, stop and tell the person instead of looping.

JavaScript:

```javascript
import { setTimeout as sleep } from 'node:timers/promises';

// send() makes the identical request each time, with the same Operation-Id.
export async function withBackoff(send, { longestWait = 60 } = {}) {
  for (;;) {
    const answer = await send();
    if (answer.status !== 429 && answer.status !== 503) return answer;
    const wait = Number(answer.headers.get('Retry-After'));
    if (!wait || wait > longestWait) return answer; // quota_exhausted, or a wait you can't afford
    await sleep(wait * 1_000);
  }
}

const operationId = crypto.randomUUID();
const answer = await withBackoff(() => fetch(`https://thread.page/api/integrations/v1/threads/${process.env.THREAD_ID}/notes`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.THREADPAGE_TOKEN}`,
    'Operation-Id': operationId,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ content: { text: 'Build 412 passed.' } }),
}));
console.log(answer.status, await answer.json());
```

Python:

```python
import os, time, uuid
import requests  # pip install requests

def with_backoff(send, longest_wait=60):
    """send() makes the identical request each time, with the same Operation-Id."""
    while True:
        answer = send()
        if answer.status_code not in (429, 503):
            return answer
        wait = int(answer.headers.get("Retry-After", 0))
        if not wait or wait > longest_wait:
            return answer  # quota_exhausted, or a wait you can't afford
        time.sleep(wait)

operation_id = str(uuid.uuid4())
answer = with_backoff(lambda: requests.post(
    f"https://thread.page/api/integrations/v1/threads/{os.environ['THREAD_ID']}/notes",
    headers={"Authorization": f"Bearer {os.environ['THREADPAGE_TOKEN']}", "Operation-Id": operation_id},
    json={"content": {"text": "Build 412 passed."}},
    timeout=30,
))
print(answer.status_code, answer.json())
```

## Next

- [Errors](https://thread.page/help/connections/errors.md)
- [Integration API basics: writing safely](https://thread.page/help/connections/api.md#writing-safely)
