The API limits how many requests you can make in a minute: per credential for every request, per IP address for a few heavy endpoints, and at the edge of HelpCenter.io's network. This page lists every limit, shows how to read your remaining budget from the response headers, and gives you a helper that waits exactly as long as the API asks.
Limits per credential
Every API key has its own budget. So does every OAuth app within a HelpCenter.io account: all the users and help centers of that account share it.
Limit | Requests a minute | Counts |
|---|---|---|
All requests | 300 | Every request to |
Writes | 120 | Every |
Analytics | 6 |
|
A request counts against every limit that applies to it, and the tightest one decides. A PATCH spends from both the 300 and the 120. Reading one article with GET /v1/articles/{id} counts as a write, so fetching articles one by one runs into the 120 long before the 300: list them with GET /v1/articles, which returns full articles, 100 at a time.
Requests without a valid key or token share a budget of 300 a minute per IP address. The limit is checked before the credential, so a burst of requests with a wrong key starts getting 429 instead of 401.
Limits per IP address
Five endpoints that move a lot of data have a limit per IP address as well:
Endpoint | Requests a minute |
|---|---|
| 30 |
| 60 |
| 60 |
| 20 |
| 20 |
These five share one counter per IP address, and each compares that count with its own limit. After 20 image uploads in a minute, the next POST /v1/articles/bulk from the same address gets 429 until the minute is over. The counter covers everyone behind that address, such as an office network or a shared build server, whichever key they use. Uploading a file to an upload URL from POST /v1/images/uploads has a separate limit of 60 a minute per IP address, and doesn't count against the per-credential limits.
These limits apply on top of the per-credential ones, and the bulk and image-upload endpoints don't count against the 120 writes.
The edge limit
Before a request reaches the API, HelpCenter.io's web servers allow each IP address about 2 requests a second, with bursts of up to 100, and 20 open connections at a time. Sustained, that is about 120 requests a minute from one address, fewer than the 300 a credential allows, so spread large jobs over time. A request refused here gets a 429 with an HTML page and no Retry-After header.
Read your remaining budget
Responses from the API carry two headers about the limit you are closest to on that endpoint:
Header | Meaning |
|---|---|
| The size of that limit: |
| How many requests it still allows this minute. |
| On a |
| On a |
For example, GET /v1/articles reports the 300 limit, and GET /v1/articles/{id} reports the 120, because it counts as a write:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 119
Each limit counts in a window of one minute that starts with your first request, then starts over. Requests that never reach an endpoint, such as a wrong path or method, carry no rate-limit headers. Browser code can't read these headers.
When you hit a limit
The API answers 429 Too Many Requests. Here the 121st single-article read of the minute:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 31
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790756655
{"message": "Too Many Attempts."}
That JSON body needs Accept: application/json; without it, the same 429 is an HTML page. The analytics limit answers with its own code and explanation, whatever you send:
{
"status": "error",
"code": "analytics_rate_limited",
"message": "Analytics endpoints are limited to 6 requests per minute per credential. These figures are computed daily — re-reading the same range more often returns the same numbers, so wait rather than retrying in a loop."
}
Either way, wait for Retry-After seconds, then send the request again. Retrying sooner only gets another 429.
Retry with Retry-After
This helper sends a request, and when the answer is 429, waits for Retry-After seconds and tries again, up to five times. When the header is missing, as with the edge limit, it backs off for 1, 2, 4, 8 and then 16 seconds. It adds up to a second of random delay, so parallel workers don't retry at the same moment. Other statuses come back to you as they are.
// A request helper for the HelpCenter.io API that retries 429 responses,
// waiting as long as the Retry-After header asks.
// Node.js 18 or later, no dependencies. Run: node helpcenter-request.js
const API = 'https://api.helpcenter.io/v1';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function helpcenterRequest(method, path, body, { maxRetries = 5 } = {}) {
const headers = { apikey: process.env.HELPCENTER_API_KEY, Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
for (let attempt = 0; ; attempt++) {
const res = await fetch(API + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status !== 429 || attempt >= maxRetries) return res;
// Retry-After is in seconds. Without it, back off: 1, 2, 4, 8, 16 seconds.
// Up to a second of jitter keeps parallel workers from retrying in step.
const retryAfter = Number(res.headers.get('retry-after'));
const seconds = retryAfter > 0 ? retryAfter : 2 ** attempt;
await sleep((seconds + Math.random()) * 1000);
}
}
module.exports = { helpcenterRequest };
if (require.main === module) {
(async () => {
const res = await helpcenterRequest('GET', '/articles?limit=1');
const limit = res.headers.get('x-ratelimit-limit');
const remaining = res.headers.get('x-ratelimit-remaining');
console.log(`${res.status}: ${remaining} of ${limit} requests left this minute.`);
if (!res.ok) process.exit(1);
})().catch((err) => {
console.error(err.message);
process.exit(1);
});
}
# A request helper for the HelpCenter.io API that retries 429 responses,
# waiting as long as the Retry-After header asks.
# Python 3.8 or later, standard library only. Run: python3 helpcenter_request.py
import json
import os
import random
import time
import urllib.error
import urllib.request
API = "https://api.helpcenter.io/v1"
def helpcenter_request(method, path, body=None, max_retries=5):
"""Return (status, headers, body): body is parsed JSON, or text if it isn't JSON."""
headers = {"apikey": os.environ["HELPCENTER_API_KEY"], "Accept": "application/json"}
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
for attempt in range(max_retries + 1):
request = urllib.request.Request(
API + path, data=data, method=method, headers=headers
)
try:
with urllib.request.urlopen(request) as response:
return response.status, response.headers, _parse(response.read())
except urllib.error.HTTPError as err:
if err.code != 429 or attempt == max_retries:
return err.code, err.headers, _parse(err.read())
# Retry-After is in seconds. Without it, back off: 1, 2, 4, 8, 16 seconds.
# Up to a second of jitter keeps parallel workers from retrying in step.
retry_after = err.headers.get("Retry-After", "")
seconds = int(retry_after) if retry_after.isdigit() else 0
time.sleep((seconds if seconds > 0 else 2 ** attempt) + random.random())
def _parse(raw):
text = raw.decode("utf-8", "replace")
try:
return json.loads(text)
except ValueError:
return text
if __name__ == "__main__":
status, headers, body = helpcenter_request("GET", "/articles?limit=1")
limit = headers.get("X-RateLimit-Limit")
remaining = headers.get("X-RateLimit-Remaining")
print(f"{status}: {remaining} of {limit} requests left this minute.")
raise SystemExit(0 if status < 400 else 1)
<?php
// A request helper for the HelpCenter.io API that retries 429 responses,
// waiting as long as the Retry-After header asks.
// PHP 8 or later with the curl extension. Run: php helpcenter-request.php
/**
* Returns [status, headers with lowercase names, body].
*/
function helpcenter_request(
string $method,
string $path,
?array $body = null,
int $maxRetries = 5
): array {
$headers = ['apikey: ' . getenv('HELPCENTER_API_KEY'), 'Accept: application/json'];
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
}
for ($attempt = 0; ; $attempt++) {
$responseHeaders = [];
$ch = curl_init('https://api.helpcenter.io/v1' . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$responseHeaders) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status !== 429 || $attempt >= $maxRetries) {
return [$status, $responseHeaders, json_decode($raw, true) ?? $raw];
}
// Retry-After is in seconds. Without it, back off: 1, 2, 4, 8, 16 seconds.
// Up to a second of jitter keeps parallel workers from retrying in step.
$retryAfter = (int) ($responseHeaders['retry-after'] ?? 0);
$seconds = $retryAfter > 0 ? $retryAfter : 2 ** $attempt;
usleep((int) (($seconds + mt_rand(0, 1000) / 1000) * 1000000));
}
}
if (realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
[$status, $headers] = helpcenter_request('GET', '/articles?limit=1');
$limit = $headers['x-ratelimit-limit'] ?? '?';
$remaining = $headers['x-ratelimit-remaining'] ?? '?';
echo "$status: $remaining of $limit requests left this minute.\n";
exit($status < 400 ? 0 : 1);
}
Run it with HELPCENTER_API_KEY set, and it lists one article and reports your budget:
200: 299 of 300 requests left this minute.
Use the helper for every call your integration makes. It doesn't retry 503 Service Unavailable: when GET /v1/analytics/* answers 503 with analytics_unavailable, its Retry-After is 300 seconds, so try again later rather than in the same job.
Stay under the limits
Read lists, not items.
GET /v1/articlesreturns full articles, 100 per request, and counts against the 300. Single-article reads count against the 120.Write in bulk.
POST /v1/articles/bulktakes up to 50 articles in one request, andPOST /v1/images/bulkup to 20 images.Sync changes, not everything. Use
updated_sinceonGET /v1/articles, or let webhooks tell you what changed instead of polling.Store analytics. Most analytics figures change once a day, so store what you read instead of asking again.
Run heavy jobs one at a time. Exports, bulk imports and image uploads from one address share the per-IP counter.
Give each integration its own key. Every key has its own budget, so one busy integration doesn't slow the others down.
The limits are the same on every plan. HelpCenter.io can raise the three per-credential limits for a help center that needs more: contact support with what you are building. The per-IP and edge limits can't be raised.