# Rate limits

_Category: Getting started_

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 `/v1`. |
| Writes | 120 | Every `POST`, `PUT`, `PATCH` and `DELETE`, except the bulk and image-upload endpoints in the next section. Also these reads: `GET /v1/articles/{id}`, `GET /v1/articles/{id}/staged`, `GET /v1/articles/{id}/versions`, and every `GET` under `/v1/change-sets`. |
| Analytics | 6 | `GET /v1/analytics/summary`, `GET /v1/analytics/content` and `GET /v1/analytics/searches`, together. |

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 |
| --- | --- |
| `GET /v1/export` | 30 |
| `POST /v1/images` | 60 |
| `POST /v1/images/uploads` | 60 |
| `POST /v1/images/bulk` | 20 |
| `POST /v1/articles/bulk` | 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 |
| --- | --- |
| `X-RateLimit-Limit` | The size of that limit: `300`, `120`, `6`, or a per-IP limit. |
| `X-RateLimit-Remaining` | How many requests it still allows this minute. |
| `Retry-After` | On a `429`: the seconds to wait before the limit lets you in again. |
| `X-RateLimit-Reset` | On a `429`: when the limit starts over, as a Unix timestamp. |

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/articles` returns full articles, 100 per request, and counts against the 300. Single-article reads count against the 120.
- **Write in bulk.** `POST /v1/articles/bulk` takes up to 50 articles in one request, and `POST /v1/images/bulk` up to 20 images.
- **Sync changes, not everything.** Use `updated_since` on `GET /v1/articles`, or let [webhooks](https://developers.helpcenter.io/content/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](https://self.helpcenter.io/content/contact-support) with what you are building. The per-IP and edge limits can't be raised.

## Related

- [Errors](https://developers.helpcenter.io/content/errors)
- [Pagination](https://developers.helpcenter.io/content/pagination)
- [Requests and responses](https://developers.helpcenter.io/content/requests-responses-and-conventions)
- [Import articles in bulk](https://developers.helpcenter.io/content/import-articles-in-bulk)
