Panel API Rate limits and Idempotency

The Panel API is designed to be called from billing systems and automation that will retry on any network hiccup. Two independent mechanisms make those retries safe. Rate limits cap the request volume from a single key and a single source IP, so a runaway loop in your integration can never bring down a customer panel. Idempotency keys let you replay a POST after a timeout and be guaranteed you will get the original response, not a duplicate line, a double charge or a double credit adjustment.

Both mechanisms are wired into the request pipeline before the handler runs. You do not need to build them yourself. Your integration does need to honor the response headers and use the retry contract correctly, and that is what this page describes.

The official SDKs handle rate limits and safe retries automatically. You only need this page if you are calling the API by hand.

Rate limits

Every authenticated request passes through two independent budgets. Whichever one you exhaust first returns 429 rate_limited.

Per-key budget

Each API key carries its own rate_limit_per_min value, configurable from the panel UI at key issuance time. The default is 60 requests per minute. The UI accepts values between 1 and 600. Higher limits are available on request for high-volume integrations.

The counter is a rolling 60-second window. When it fills, further requests from that key return 429 until the window resets, regardless of source IP.

Per-IP budget

An additional panel-wide budget of 300 requests per minute per source IP applies before authentication. This is a defense-in-depth cap so that a caller flooding invalid tokens cannot exhaust downstream resources. When exceeded, the response is 429 rate_limited with the message Rate limit exceeded (300/min per IP).

The per-IP budget is separate from the per-key one. A key that has consumed 40 of its 60/min still counts against the 300/min IP budget, and vice versa.

The /health endpoint has its own budget

The public /health endpoint (no authentication) has a smaller anti-DDoS budget of 30 requests per minute per source IP. It is not consumed by, and does not consume, either of the authenticated budgets.

Response headers

Every authenticated response (2xx or 4xx) carries three headers.

Header Meaning
X-RateLimit-Limit Your key's per-minute cap.
X-RateLimit-Remaining Requests you can still make in the current window.
X-RateLimit-Reset Unix timestamp when the window resets and the counter goes back to Limit.

Example.

HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1786000260
Content-Type: application/json

Two response classes deliberately omit these headers.

  • /health. No key is resolved for public endpoints, so there is nothing to report.
  • Pre-authentication failures (401 invalid_key, malformed Authorization header). The request never reached the point where a key existed.

When you get a 429

The body is a standard error envelope.

{
  "error": "rate_limited",
  "message": "Rate limit exceeded (60/min).",
  "request_id": "req_a1b2c3d4e5"
}

The response headers.

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1786000260

Retry-After is always seconds (never an HTTP date) and always 60 since the window is a fixed 60 seconds. The per-IP variant says Rate limit exceeded (300/min per IP). in the message so you can tell the two apart in your logs.

The retry pattern

Honor Retry-After, add a small amount of jitter to avoid thundering-herd effects when many callers back off at once, and apply exponential backoff for repeated failures.

function panel_api_request(string $url, array $headers, ?string $body = null, int $attempt = 0): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $body !== null ? 'POST' : 'GET',
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_HEADER         => true,
    ]);
    $raw       = curl_exec($ch);
    $status    = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $hdrSize   = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $headerStr = substr($raw, 0, $hdrSize);
    $bodyStr   = substr($raw, $hdrSize);
    curl_close($ch);

    if ($status === 429 && $attempt < 5) {
        preg_match('/^retry-after:\s*(\d+)/im', $headerStr, $m);
        $wait = (int) ($m[1] ?? 60);
        $jitter = rand(0, 500) / 1000.0;                          // 0 to 0.5s
        sleep((int) ceil($wait * (2 ** $attempt) + $jitter));
        return panel_api_request($url, $headers, $body, $attempt + 1);
    }
    return ['status' => $status, 'body' => json_decode($bodyStr, true)];
}
import time, random, requests

def panel_api_request(method, url, headers, json_body=None, max_attempts=5):
    for attempt in range(max_attempts):
        r = requests.request(method, url, headers=headers, json=json_body, timeout=30)
        if r.status_code != 429:
            return r
        wait = int(r.headers.get("Retry-After", "60"))
        jitter = random.uniform(0, 0.5)
        time.sleep(wait * (2 ** attempt) + jitter)
    r.raise_for_status()

Idempotency

Every write operation (any POST that creates, updates, enables, disables, deletes, renews, resets a password or adjusts a billing balance) requires an Idempotency-Key header. Read operations (GET) do not.

POST /panel-api/v1/lines HTTP/1.1
Authorization: Bearer pk_live_<prefix>.<secret>
Idempotency-Key: 8f3c2b1e-4d5a-4a9f-9c2e-1b3d4e5f6a7b
Content-Type: application/json

If a write reaches the API without this header, you receive the response below.

{
  "error": "missing_idempotency_key",
  "message": "Idempotency-Key header is required for write operations.",
  "request_id": "req_a1b2c3d4e5"
}

An empty Idempotency-Key is treated as absent and returns the same error. A missing header on a GET is fine and passes through untouched.

How does it work?

The API stores the hash of method + path + body at the moment your key is claimed, along with the response it eventually produces. When it sees the same Idempotency-Key again, it inspects that record and reacts as follows.

Situation What happens
Same key, identical body, original response was 2xx The stored response is replayed bit-for-bit. No handler runs. No side effect happens twice.
Same key, different body (or different path, or different method) 409 idempotency_conflict. Your integration is reusing the key incorrectly.
Same key, same body, original response was 4xx The store does not cache 4xx responses. The request is re-executed. This is deliberate. It lets you fix a bad body and retry with the same key without changing it.
Same key, same body, original request is still in flight 409 idempotency_in_flight. Wait and retry.

The retention window for stored responses is 24 hours from the moment the write completes (configurable per panel via PANEL_API_IDEMPOTENCY_RETENTION_HOURS, purged by a daily cron). After that, the same key can be reused for a different operation without conflict.

Method and path are part of the identity

Two different endpoints called with the same Idempotency-Key do not collide. For example, this pair works exactly as you would want.

POST /panel-api/v1/lines/9/enable   Idempotency-Key: rid_2026_00814
POST /panel-api/v1/lines/9/delete   Idempotency-Key: rid_2026_00814

Both requests succeed. The store treats them as independent claims because the path differs. This matters in particular for OneStream integrations that reuse a transaction id (rid) across the multiple steps of a business transaction.

Cross-key isolation

Two different API keys can use the same Idempotency-Key value independently. The store scopes every claim by (api_key_id, idempotency_key), so your key's rid_2026_00814 and another integrator's rid_2026_00814 do not see each other.

What makes a good key

Any opaque string up to 255 bytes. The API trims surrounding whitespace and truncates anything past 255. Values longer than that lose the extra bytes on the way in, so keep your keys within the limit rather than sending long ones and trusting the server to normalize them.

We recommend one of the following.

  • A stable business identifier you already have, like a WHMCS invoice number or a Stripe payment intent id. This has the great advantage that if your integration crashes and restarts, it can reproduce the exact same key from the same business event and safely retry. Example: renew-INV-2026-00814.
  • UUID v4 per business operation. uuid.uuid4().hex in Python, bin2hex(random_bytes(16)) in PHP. Use it when there is no natural business identifier at hand.

What to avoid.

  • A timestamp. Two retries produce two different keys and you lose idempotency.
  • A random value regenerated on every retry. Same problem.
  • The same static string for everything. Every request would collide with the previous one.

Concrete example. Safe subscription renewal

You are integrating a billing system that just accepted a payment for invoice INV-2026-00814. The customer wants to renew line 12345 with package 7 for another 30 days.

The initial request

curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: renew-INV-2026-00814" \
     -H "Content-Type: application/json" \
     -d '{"package_id":7,"months":1}' \
     https://<your-panel-domain>/panel-api/v1/lines/12345/renew
$ch = curl_init('https://<your-panel-domain>/panel-api/v1/lines/12345/renew');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . $token,
        'Idempotency-Key: renew-INV-2026-00814',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['package_id' => 7, 'months' => 1]),
    CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
import requests

r = requests.post(
    "https://<your-panel-domain>/panel-api/v1/lines/12345/renew",
    headers={
        "Authorization": f"Bearer {token}",
        "Idempotency-Key": "renew-INV-2026-00814",
        "Content-Type": "application/json",
    },
    json={"package_id": 7, "months": 1},
    timeout=30,
)

The renewal completes on the server side. The response body confirms the new exp_date and the deducted credits, if any.

The network dies before you see the response

Your process times out at 30 seconds. You do not know whether the line was renewed. Do not guess. Retry the exact same request with the exact same body and the exact same Idempotency-Key.

curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: renew-INV-2026-00814" \
     -H "Content-Type: application/json" \
     -d '{"package_id":7,"months":1}' \
     https://<your-panel-domain>/panel-api/v1/lines/12345/renew

Two possibilities.

  1. The original write reached the API. You receive the exact same 2xx response the first attempt produced, replayed from the store. The line was not renewed a second time. No extra credits were deducted.
  2. The original write did not reach the API (the network broke before it arrived). The retry is a fresh request. The renewal happens now, exactly once.

Either way your outcome is deterministic. The line ends up with exactly one 30-day renewal against invoice INV-2026-00814. If you naively retried without an Idempotency-Key, you would face the classic double-renewal bug the first time a caller times out.

If your retry hits an in-flight window

If your retry lands while the original is still executing (rare but possible), you get the response below.

{
  "error": "idempotency_in_flight",
  "message": "A request with this Idempotency-Key is still in progress.",
  "request_id": "req_x9y8z7"
}

Wait a couple of seconds and retry. Once the original completes, the next retry returns its final 2xx response.

If your retry uses the wrong body

If a bug in your code sends a different body on retry (say months: 12 instead of months: 1), you get the response below.

{
  "error": "idempotency_conflict",
  "message": "Idempotency-Key was reused with a different request body.",
  "request_id": "req_x9y8z7"
}

This is telling you "you already committed to a specific operation under this key. Either finish that one or use a new key for the new operation." Rotate the key if the second operation was the correct one.

See also

  • Errors. Full table of error slugs (rate_limited, missing_idempotency_key, idempotency_conflict, idempotency_in_flight) and how to handle each.
  • Lines. The resource with the most write operations, all of which use the idempotency contract described here.
  • Panel API Overview. Start here if you need the bigger picture on flavors, base URLs and what the API covers.