Rate Limits
The BenchKey API enforces rate limits to protect platform stability and ensure fair usage across tenants. Limits apply per API key.
Limits
Section titled “Limits”The API uses a token-bucket model per API key: a burst capacity of 100 requests that refills at 10 requests per second. This allows short bursts up to 100 in-flight requests while sustaining no more than 10 req/s over time. Both live (bk_live_) and test (bk_test_) keys have independent limits.
Rate-limit headers
Section titled “Rate-limit headers”Every API response includes headers that describe your current usage:
| Header | Description |
|---|---|
RateLimit-Limit | Bucket capacity (maximum burst size) |
RateLimit-Remaining | Tokens remaining in the bucket right now |
RateLimit-Reset | Seconds until the bucket is full again |
Retry-After | Seconds to wait before retrying (only present on 429 responses) |
Example response headers
HTTP/1.1 200 OKRateLimit-Limit: 100RateLimit-Remaining: 87RateLimit-Reset: 2When you are rate-limited
Section titled “When you are rate-limited”If you exceed the limit the API returns 429 Too Many Requests:
{ "error": { "type": "rate_limit_error", "code": "rate_limited", "message": "API rate limit exceeded for this key.", "request_id": "req_01J8X4zzzzzz" }}The Retry-After header tells you exactly how many seconds to wait.
Exponential backoff
Section titled “Exponential backoff”For any 429 or transient 5xx response, use exponential backoff with jitter. Do not hammer the API in a tight retry loop.
curl (shell script)
Section titled “curl (shell script)”BENCHKEY_API_KEY="bk_live_acme_xxxxxxxxxxxx"MAX_ATTEMPTS=5ATTEMPT=0BACKOFF=1
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do RESPONSE=$(curl -s -w "\n%{http_code}" \ https://app.benchkey.com/api/v1/customers \ -H "Authorization: Bearer $BENCHKEY_API_KEY" \ -H "BenchKey-Version: 2026-06-13")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1) BODY=$(echo "$RESPONSE" | head -n-1)
if [ "$HTTP_CODE" -eq 200 ]; then echo "$BODY" break elif [ "$HTTP_CODE" -eq 429 ] || [ "$HTTP_CODE" -ge 500 ]; then RETRY_AFTER=$(echo "$BODY" | jq -r '.error.message' | grep -oE '[0-9]+' | head -1) WAIT=${RETRY_AFTER:-$BACKOFF} echo "Rate limited or server error. Waiting ${WAIT}s..." >&2 sleep "$WAIT" BACKOFF=$((BACKOFF * 2)) ATTEMPT=$((ATTEMPT + 1)) else echo "Fatal error $HTTP_CODE: $BODY" >&2 break fidoneNode.js
Section titled “Node.js”async function fetchWithRetry(url, options, maxAttempts = 5) { let backoff = 1000; // ms
for (let attempt = 0; attempt < maxAttempts; attempt++) { const res = await fetch(url, options);
if (res.ok) return res.json();
if (res.status === 429 || res.status >= 500) { const retryAfter = res.headers.get("Retry-After"); const wait = retryAfter ? parseInt(retryAfter, 10) * 1000 : backoff; const jitter = Math.random() * 200; await new Promise((r) => setTimeout(r, wait + jitter)); backoff = Math.min(backoff * 2, 30_000); continue; }
const { error } = await res.json(); throw new Error(`[${error.code}] ${error.message}`); }
throw new Error("Max retry attempts reached");}
// Usageconst data = await fetchWithRetry( "https://app.benchkey.com/api/v1/customers", { headers: { Authorization: `Bearer ${process.env.BENCHKEY_API_KEY}`, "BenchKey-Version": "2026-06-13", }, });console.log(data);Best practices
Section titled “Best practices”- Batch reads — use
limit=100(the maximum) to reduce the number of list requests. - Cache aggressively — avoid re-fetching resources that haven’t changed. Store the
idand re-read only when needed. - Parallelize carefully — fan-out requests in parallel, but cap concurrent in-flight requests to avoid burst spikes (aim for ≤ 10 concurrent).
- Check headers proactively — if
RateLimit-Remainingdrops near zero, slow down before hitting429. - Use idempotency keys on writes — if a write request times out and you retry, an Idempotency-Key prevents duplicate side effects.
Bulk imports and backfills
Section titled “Bulk imports and backfills”There are no bulk endpoints — deliberately. A batch write is a loop of single, idempotent requests, which gives you per-record success/failure (no partial-batch ambiguity), free crash-restart safety, and honest backpressure:
- Send each record with an
Idempotency-Keyderived from your source ID (e.g.import-cust-8841). A crashed run can restart from the top — already-completed records replay their stored response instead of duplicating. - Pace by the headers. Watch
RateLimit-Remainingand ease off as it approaches zero; the default bucket sustains ~10 requests/second indefinitely, so a 5,000-record backfill completes in under 10 minutes. - On
429, waitRetry-Afterseconds and resend the same request with the same key.
For a full migration from another system (RepairDesk, RepairShopr, CSV), use the in-app importer instead — it handles dedupe, staging review, and per-entity selection in ways a REST loop can’t.
Self-service usage introspection
Section titled “Self-service usage introspection”GET /api/v1/usageReturns a rollup of your tenant’s API request volume, error rate, and latency over a configurable time window. Useful for dashboards, alerting, and diagnosing whether you are approaching rate limits.
Auth required: any valid API key. No specific scope is needed — this endpoint is accessible even if your account is on a plan that gates other resources.
Query parameters
Section titled “Query parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
window_days | integer | 30 | Rolling lookback window in days |
since | integer (epoch ms) | — | Explicit window start (overrides window_days) |
until | integer (epoch ms) | — | Explicit window end (overrides window_days) |
by_key | boolean | false | Add a per-API-key breakdown to the response |
limit | integer | 50 | Max keys to include when by_key=true |
# Last 7 days, whole-tenant rollupcurl "https://app.benchkey.com/api/v1/usage?window_days=7" \ -H "Authorization: Bearer bk_live_<tenantId>_<secret>"
# Explicit window with per-key breakdowncurl "https://app.benchkey.com/api/v1/usage?since=1748822400000&until=1749081600000&by_key=true&limit=20" \ -H "Authorization: Bearer bk_live_<tenantId>_<secret>"Node.js
Section titled “Node.js”const res = await fetch("https://app.benchkey.com/api/v1/usage?window_days=7", { headers: { Authorization: `Bearer ${process.env.BENCHKEY_API_KEY}` },});const usage = await res.json();console.log(`${usage.total_requests} requests, error rate ${usage.error_rate}`);Response
Section titled “Response”{ "object": "api_usage", "tenant_id": "acme", "window": { "since": "2026-06-07T00:00:00.000Z", "until": "2026-06-14T00:00:00.000Z" }, "total_requests": 4821, "by_status_class": { "2xx": 4710, "4xx": 108, "5xx": 3, "other": 0 }, "error_count": 111, "error_rate": 0.023, "duration_ms": { "total": 721500, "avg": 149, "max": 2340 }, "distinct_keys": 2}With by_key=true the response also includes a by_key array — one entry per API key, with the same fields scoped to requests made by that key.
| Field | Type | Description |
|---|---|---|
window.since / window.until | string (ISO 8601) | Resolved window boundaries |
total_requests | integer | All requests in the window |
by_status_class | object | Counts broken out by HTTP status class |
error_count | integer | 4xx + 5xx total |
error_rate | number | error_count / total_requests (0–1) |
duration_ms.avg | number | Mean response time in milliseconds |
duration_ms.max | number | Slowest response in the window |
distinct_keys | integer | Number of distinct API keys that made requests |
Fresh tenant / no traffic: If no request log data exists yet, the endpoint returns all-zero values rather than an error — safe to poll from dashboards immediately after provisioning.