How many requests per minute the LinkHarbor API allows, how large a request may be, how to read the headers and how to handle a 429 properly.
To keep the API fast for everyone, LinkHarbor limits the number of requests per minute. The limits do not depend on your plan – there is no monthly quota of API calls. This article lists every limit and shows how your code can handle them cleanly.
How requests are counted
- Every request falls into exactly one bucket, for example user for authenticated requests or token for the token exchange
- Counting is per full minute (UTC); the counter starts at zero again with every new minute
- Depending on the bucket, the API counts per workspace, per person, per connection or per IP address
- Requests that already fail on the token, scope or schema (401, 403 insufficient_scope, 400, 422 validation_failed from the schema check) do not count; later errors such as 403 feature_not_available do
All limits
| Bucket (X-RateLimit-Bucket) | Limit | Counted per | Applies to |
|---|---|---|---|
| user | 600 per minute | Workspace for tokens from API keys (all keys and AI tools of the workspace together); person for dashboard sign-ins | All authenticated requests to /v1 that have no bucket of their own |
| token | 30 per minute | IP address | POST /v1/token |
| public | 120 per minute | IP address | Requests to the API without authentication, such as GET /v1/plans and GET /v1/pages/available |
| callback | 120 per minute | IP address | POST /v1/callbacks/sale |
| internal | 120 per minute | IP address | Ingest URL of the “Custom (API)” integration (POST /v1/ingest/{id}) |
| mcp | 120 per minute | AI connection (access token) | MCP server at /mcp |
| oauth | 300 per minute | IP address | OAuth sign-in and discovery for AI assistants |
| auth | 30 per minute | IP address | Login, registration, password reset (dashboard and app) |
| refresh | 120 per minute | IP address | Extending a dashboard session |
| discover | 60 per minute | Person, IP address when not signed in | The app's Discover section |
| subs_read | 120 per minute | Person, IP address when not signed in | Reading the pages you follow (app) |
| subs_write | 20 per minute | Person, IP address when not signed in | Changing the pages you follow (app) |
One budget per workspace
All tokens from API keys in the same workspace share the 600 requests per minute – no matter how many keys you create. The tools of connected AI assistants count here too, because they call the API internally. So more keys do not raise the limit. If a lot of load is spread across several programs, plan around the shared budget.
The headers
- X-RateLimit-Limit
- The bucket's limit per minute, for example 600.
- X-RateLimit-Remaining
- How many requests are left in the current minute.
- X-RateLimit-Bucket
- The bucket the request fell into, for example user.
- Retry-After
- Only with 429 rate_limited: seconds until the next minute starts. With api_key_locked, the waiting time is in details.retry_after_sec.
When the limit is reached
Once the limit is reached, the API responds with HTTP 429 and this body. The text in message is in the account language (see API error format), here in English. Rely on the code rate_limited:
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Bucket: user
{ "error": "rate_limited", "message": "Too many requests. Please try again in a moment.", "retry_after_sec": 37 } - Read Retry-After or retry_after_sec
- Wait at least that many seconds, plus a little randomness so that not all processes start again at the same moment
- Retry the request a few times at most
- If you keep getting 429, spread the work over more time
const BASE = 'https://dock.at-harbor.com';
let cached = null; // { token, expiresAt }
async function getToken() {
if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token;
const res = await fetch(BASE + '/v1/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key_id: process.env.LH_KEY_ID,
key_secret: process.env.LH_KEY_SECRET,
}),
});
if (!res.ok) throw new Error('Token exchange failed: HTTP ' + res.status);
const data = await res.json();
cached = { token: data.token, expiresAt: Date.now() + data.expires_in * 1000 };
return cached.token;
}
async function lh(method, path, body, attempt = 0) {
const headers = { Authorization: 'Bearer ' + (await getToken()) };
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(BASE + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 429 && attempt < 3) {
const wait = Number(res.headers.get('Retry-After') ?? 5);
await new Promise((r) => setTimeout(r, (wait + Math.random() * 2) * 1000));
return lh(method, path, body, attempt + 1);
}
if (res.status === 401 && attempt === 0) {
cached = null; // token expired – try once more with a new token
return lh(method, path, body, attempt + 1);
}
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error((data && data.error) + ' (HTTP ' + res.status + ')');
return data;
}
// Example: const { items } = await lh('GET', '/v1/pages'); Tips for large amounts of data
- Cache the token and only renew it shortly before it expires after 15 minutes
- Watch X-RateLimit-Remaining and slow down when it gets low
- Send requests one after another instead of over dozens of parallel connections
- Load lists once and filter them locally instead of querying each page individually
- Spread large syncs over several minutes
- Retry 5xx errors with increasing pauses, but not 4xx errors
Other limits
| What | Limit | Response |
|---|---|---|
| Failed logins | Lockout after 5, 10 and 20 failed attempts per e-mail address and IP: 60 seconds, 5 minutes, 30 minutes | 429 account_locked |
| Form submissions | 10 per hour and IP address, across all forms | 429 form_rate_limited |
Failed attempts at the token exchange temporarily lock the key and IP, see API keys and tokens. You only send push notifications in the dashboard; their limits are explained in Followers and push notifications.
Your plan's content limits – pages, links, blocks, short links and so on – are not rate limits but fixed caps. They are listed in Plans compared.
Request size
Every body is measured before the API reads it. How large it may be depends on the endpoint:
| Endpoints | Largest body |
|---|---|
| Login, registration and the other unauthenticated calls under /v1/auth, POST /v1/token, POST /v1/oauth/exchange, the OAuth endpoints for AI assistants under /oauth and the consent for them, POST /v1/ingest/{id}, the Support block checkout, the app's subscription calls (following, collections, push devices), switching workspaces, POST /v1/auth/logout-all, deleting an account, two-factor setup, accepting an invitation, activating the referral programme and the return step when connecting an integration | 64 KB |
| All other JSON endpoints, including submitting a form on a page | 1 MB |
| POST /v1/webhooks/{id}/verify-signature and the MCP server at /mcp | 2 MB |
| File uploads (POST /v1/assets and POST /v1/pages/{id}/uploads, multipart/form-data) | 11 MB for the whole request, the file itself at most 10 MB |
If the body is larger, the API responds with 413 request_too_large; details.max_bytes and details.max_kb give the limit. A file that is too large in an upload leads to 413 payload_too_large. Endpoints without a body, such as the DELETE calls, accept no body or only {}.
HTTP/1.1 413 Payload Too Large
{ "error": "request_too_large", "message": "…", "details": { "max_bytes": 65536, "max_kb": 64 }, "request_id": "…" } There are limits inside the body too: every text has a maximum length, every list a maximum number of entries, and unknown fields are rejected. The values are listed per endpoint in the API reference. A violation leads to 422 validation_failed, and details names the field, rule and limit. The path and query string of a request may be at most 16 KB long together.
You might also find this useful
Nothing found?
Tell us what you need, and our support team will help you personally.