Errors and rate limits
The error shape
Every error has the same envelope. Branch on error.code, never on message: messages can change and some are in Indonesian.
400 Bad Request
{
"success": false,
"error": {
"code": "INVALID_BODY",
"message": "Validasi gagal",
"details": {
"formErrors": [],
"fieldErrors": {
"amount": [
"Too small: expected number to be >=1000"
]
}
}
}
}Validation errors (INVALID_BODY) add error.details.fieldErrors, keyed by field name. Some errors add a field of their own, for example requires_captcha on a failed sign-in.
What to do with each status
| Status | Meaning | Retry? |
|---|---|---|
400 | The request is wrong (INVALID_BODY, INVALID_QUERY, a bad value) | No. Fix the request |
401 | No valid session or key | No. Sign in again or fix the key |
402 | Not enough balance | No |
403 | Not allowed (FORBIDDEN, CSRF_FAILED, KEY_NOT_ALLOWED, SCOPE_REQUIRED) | No |
404 | Not found, or not visible to you | No |
409 | The state does not allow it, or someone changed it first | Read the resource again, then decide |
422 | The request is well formed but refused (signed links, idempotency conflicts) | No |
423 | Sign-in locked after failed attempts | Later |
429 | Rate limited | Yes, after Retry-After |
5xx | Server or gateway problem | Yes, with the same Idempotency-Key and backoff |
Real examples:
401 Unauthorized
{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "No session."
}
}403 Forbidden
{
"success": false,
"error": {
"code": "FORBIDDEN",
"message": "Your workspace role does not allow this action"
}
}409 Conflict
{
"success": false,
"error": {
"code": "NOT_DRAFT",
"message": "Only draft deals can be edited."
}
}Rate limits
| Budget | Limit |
|---|---|
| Signed-in calls (session or API key) | 300 requests a minute per user, across all routes |
| Calls without valid credentials | 100 requests a minute per IP address |
| Sign-in | Locks after repeated failures for the same IP and email |
| Some enterprise routes | Their own budget per user, from 5 to 60 a minute. See Enterprise overview |
| Tracking refresh | Once per escrow every 5 minutes |
Every answer carries the current budget. This is a call without credentials:
x-ratelimit-limit: 100
x-ratelimit-remaining: 99
x-ratelimit-reset: 60Over the limit you get 429 with a Retry-After header in seconds. This is the 101st call in a minute from one IP address without credentials, captured with Retry-After: 60:
429 Too Many Requests
{
"success": false,
"requestId": "req-hw",
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Try again in 60s."
}
}Some routes have their own short limit with their own message, for example refreshing tracking twice within 5 minutes:
429 Too Many Requests
{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "Tunggu sebentar sebelum memuat ulang status lagi."
}
}Handling them in code
async function call(url, init = {}, attempts = 4) {
for (let i = 0; i < attempts; i += 1) {
const res = await fetch(url, init);
if (res.status === 429) {
const wait = Number(res.headers.get('retry-after') ?? 1);
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
if (res.status >= 500) {
await new Promise((r) => setTimeout(r, 2 ** i * 500));
continue;
}
const body = await res.json();
if (!body.success) {
const err = new Error(body.error.message);
err.code = body.error.code;
err.status = res.status;
throw err;
}
return body.data;
}
throw new Error('Gave up after retries');
}Retry writes only with an Idempotency-Key, so a retry never acts twice. See Idempotency and retries.
Staying under the limits
- Poll slowly: every 5 seconds for a payment you are waiting on, every 15 to 60 seconds for the enterprise event feed.
- Cache data that rarely changes, such as payment methods and workspace settings.
- The budget belongs to the user, so every key and session of one user shares it.
- Do not load test against production.
Next steps
- Errors reference: every shared error code
- Idempotency and retries