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.
On this page
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
429quota_exhausted, withoutRetry-After;details.resets_atsays when the count starts again. - Up to 1,000 notes or 20 MB wait for the owner’s browser at once. Then
409inbox_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
{
"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.reasonnames the limit, from the tables above.details.scopesays whose it is:connection, orownerfor all of the account’s connections together.details.retry_afterand theRetry-Afterheader give the seconds to wait.inbox_minuteandtestscome 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.
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());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())