Developers
Rate limits and errors
How rate limits work in the Performance Blocks API, the standard error envelope, full error code table, and best practices for retries and resilience.
Plan availability: The Performance Blocks API is available on the Agentic plan. Team plan customers can upgrade in Settings → Billing.
This article covers everything you need to build a resilient client: how rate limits work and what to do when you hit them, the shape of every error response, the full code table you'll see in error.code, and the retry / idempotency patterns that prevent client bugs in production.
Rate limits
Rate limits are applied per API key, with separate quotas for read and write traffic.
Defaults
| Quota | Limit |
|---|---|
Reads (GET) |
600 requests per minute, per key. |
Writes (POST, PATCH, DELETE) |
100 requests per minute, per key. |
Webhooks test endpoint (POST /webhooks/{id}/test) |
10 per minute, per key. |
/employees/csv upsert |
6 per minute, per key. |
Limits reset every 60 seconds on a sliding window. The window does not align to wall-clock minutes.
These defaults work for most integrations. If you need higher limits for a specific key (e.g. a backfill job that legitimately needs more write throughput), contact support with the key ID and your expected sustained rate.
Inspecting your quota
Every response includes rate-limit headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Requests allowed in the current window for this quota class. |
X-RateLimit-Remaining |
Requests remaining before throttling. |
X-RateLimit-Reset |
Unix epoch seconds when the window fully resets. |
A reasonable client tracks X-RateLimit-Remaining and pre-emptively slows down before hitting zero, instead of relying on 429s.
When you hit the limit
A throttled request returns 429 rate_limited:
HTTP/1.1 429 Too Many Requests
Retry-After: 17
Content-Type: application/json
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded for write requests on this key",
"details": [
{ "quota": "writes", "limit": 100, "window_seconds": 60 }
]
}
}
Retry-After is the number of seconds to wait before retrying. Honor it — retrying sooner returns another 429 and burns more of your quota.
Handling 429 in code
A simple retry loop with backoff and jitter:
async function fetchWithRetry(
input: RequestInfo,
init: RequestInit,
maxAttempts = 5
): Promise<Response> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(input, init);
if (res.status !== 429 && res.status < 500) return res;
if (attempt === maxAttempts) return res;
const retryAfter = Number(res.headers.get('retry-after'));
const baseDelay =
Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(2 ** attempt * 250, 30_000);
const jitter = Math.random() * 250;
await new Promise((r) => setTimeout(r, baseDelay + jitter));
}
throw new Error('unreachable');
}
Three things this gets right:
- It honors
Retry-Afterwhen the server provides it. - It falls back to capped exponential backoff for
5xx(where noRetry-Afteris sent). - It adds jitter so concurrent clients don't synchronize their retries.
Pair this with an Idempotency-Key on every POST (see API overview) so retries don't create duplicates.
The error envelope
Every non-2xx response has the same JSON shape:
{
"error": {
"code": "validation_error",
"message": "Human-readable summary of what went wrong",
"details": [
{ "field": "observation", "code": "too_long", "message": "Maximum 4000 characters" }
],
"request_id": "req_01HXX..."
}
}
| Field | Notes |
|---|---|
code |
Machine-readable error code from the table below. Stable across versions. |
message |
Human-readable summary. Suitable for logs and developer-facing UIs; do not surface verbatim to end users. |
details |
Optional. Array of structured per-issue objects. Shape varies by code. |
request_id |
Server-assigned ID. Include when contacting support. |
Always branch on error.code, not message. Messages may change wording for clarity; codes are part of the contract.
Error code reference
| HTTP | code |
Meaning |
|---|---|---|
| 400 | validation_error |
Request body or query parameters failed validation. details[] lists per-field issues. |
| 400 | bad_request |
Generic malformed request (invalid JSON, missing required header). |
| 401 | authentication_required |
Missing, malformed, expired, or revoked API key. |
| 403 | permission_denied |
Key is valid but lacks the required scope, or targets a resource outside its organization. |
| 404 | not_found |
Resource ID does not exist or is not in your organization. |
| 409 | conflict |
Request collides with current resource state — duplicate email on create, status transition not allowed, idempotency key reuse with different payload. |
| 409 | idempotency_conflict |
Specific subtype of conflict: same Idempotency-Key was used with a different request body. |
| 422 | unprocessable |
Request is well-formed but semantically invalid — referenced resource not in your org, write attempted on a field managed by an HRIS connector, etc. |
| 429 | rate_limited |
Per-key quota exhausted. See Retry-After. |
| 500 | server_error |
Unexpected server-side failure. Safe to retry with backoff. |
| 502 | upstream_error |
A dependency Performance Blocks calls (e.g. an LLM provider for AI-assisted features) failed. Safe to retry. |
| 503 | unavailable |
Performance Blocks is temporarily unavailable (deploy, brief outage). Honor Retry-After. |
| 504 | timeout |
Request took too long to process. Safe to retry idempotent requests. |
Per-field validation details
For validation_error, the details array contains one entry per failed field:
{
"error": {
"code": "validation_error",
"message": "2 fields failed validation",
"details": [
{ "field": "type", "code": "invalid_enum", "message": "must be one of: strength, opportunity" },
{ "field": "observation", "code": "too_long", "message": "Maximum 4000 characters" }
]
}
}
Common per-field codes: required, too_short, too_long, invalid_format, invalid_enum, out_of_range, not_found_in_organization, field_managed_externally.
Permission detail
For permission_denied, details typically names the missing scope:
{
"error": {
"code": "permission_denied",
"message": "Required scope: observations:write",
"details": [{ "required_scope": "observations:write" }]
}
}
Update the key's scopes in Settings → API, or create a new key with the broader scope set.
Conflict detail
For conflict and idempotency_conflict, details includes the conflicting field or transition:
{
"error": {
"code": "conflict",
"message": "Cannot share an archived summary",
"details": [{ "field": "status", "current": "archived", "attempted_transition": "shared" }]
}
}
Example error responses
Missing required field
{
"error": {
"code": "validation_error",
"message": "1 field failed validation",
"details": [
{ "field": "employee_id", "code": "required", "message": "is required" }
],
"request_id": "req_01HXX..."
}
}
Wrong environment
Sending a pb_test_ key against a live-only resource:
{
"error": {
"code": "permission_denied",
"message": "Test keys cannot access this endpoint",
"details": [{ "key_environment": "test" }],
"request_id": "req_01HXX..."
}
}
Referenced resource not in your org
{
"error": {
"code": "unprocessable",
"message": "Referenced employee is not in your organization",
"details": [
{ "field": "employee_id", "code": "not_found_in_organization", "value": "emp_01OTHERORG..." }
]
}
}
Idempotency mismatch
You retried a POST with the same Idempotency-Key but a different body:
{
"error": {
"code": "idempotency_conflict",
"message": "This idempotency key was previously used with a different request body",
"details": [{ "idempotency_key": "6f4a4f64-7c2b-4f4e-a8e1-9a8e9d2c1e10" }]
}
}
Use a fresh key for the new request, or replay the original payload exactly.
Best practices
Always send an Idempotency-Key on writes
POST requests that create resources accept an Idempotency-Key header. Generate one client-side per logical operation, reuse it across retries of that operation, and the API will return the original response on retry instead of creating a duplicate. See API overview.
Retry with capped exponential backoff and jitter
A retry strategy that handles 429, 502, 503, 504, and connection failures:
- Honor
Retry-Afterwhen present. - Otherwise wait
min(2^attempt * 250ms, 30s)plus 0–250ms of jitter. - Cap at 5 attempts. Beyond that, surface the failure to your caller.
- Only retry idempotent operations (
GET,PATCH,DELETE, andPOSTrequests that include anIdempotency-Key).
Don't retry 400, 401, 403, 404, 409, 422
These errors will not change on retry. Fix the request, the key, or the data, and try again. Burning request budget on retries that can't succeed is the most common cause of nuisance throttling.
Surface error details to your users carefully
Pass error.message to your logging system but craft your end-user message yourself. The API's messages are written for developers and may include identifiers, field paths, or wording that doesn't match your product's voice.
For surfacing per-field validation issues in a form UI, map details[].field to your form field names and details[].code to localized strings. The codes are stable; the messages are not.
Log the request_id
Every error response includes a request_id. Log it alongside the original request. When you contact support, providing the request_id lets the team find the exact server-side log line for your failure.
Watch your rate-limit headers
Don't wait for 429 to slow down. If X-RateLimit-Remaining drops below 10% of X-RateLimit-Limit mid-burst, slow your client. For batch jobs, target a steady rate well below your quota — bursting to the limit and then waiting is less efficient than smooth pacing.
Use webhooks instead of polling
If you find yourself polling a list endpoint every minute for changes, switch to a Webhook subscription on the relevant event type. You'll get changes faster, with much less rate-limit pressure on both sides.
Use the search endpoint for cross-resource queries
If you need to find observations matching a phrase, don't list every observation and filter client-side. Use GET /search?q=...&types=observations instead — it's faster, costs less quota, and returns ranked results.
Related
- API overview — pagination, idempotency, request/response headers.
- Authentication and API keys — auth error handling, scope-related
403s. - Webhooks — receiver-side retry and idempotency.