Getting started
Errors & rate limits
Error response format, error codes, validation issues, rate limits and retry guidance.
On this page
CentraPoint uses conventional HTTP status codes. 2xx means success, 4xx means the request needs to change, and 5xx means something went wrong on our side.
Error format#
Every API error response has a JSON body with a stable, machine-readable code and a human-readable message:
{
"error": "unauthorized",
"message": "Invalid API key"
}- Branch on
error(and the HTTP status). It is one of the codes below. - Show or log
message, but do not parse it: wording can change. - Validation failures add an
issuesarray.
Error codes#
| HTTP | error | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_request | The body is not valid JSON (message: Body must be JSON), failed validation (issues included), or broke a business rule, e.g. Customer not found or Invoice is paid. The message is always safe to show. | Fix the request. Do not retry unchanged. |
| 401 | unauthorized | API key missing, malformed, unknown or revoked. | Check the Authorization header and key. |
| 403 | plan_restricted | Your plan does not include the API, or the feature (e.g. payment links). | Upgrade the plan in Settings → Billing. |
| 403 | plan_limit | A plan quota was reached, e.g. the customer limit when a new customer would be created. | Upgrade, or reuse existing customers. |
| 403 | account_restricted | The account is read-only (suspended, cancelled or trial ended). Write requests only. | Resolve billing in the dashboard. |
| 404 | not_found | The resource does not exist in your organisation. | Check the ID or reference. |
| 409 | idempotency_conflict | The Idempotency-Key was already used with a different request body. | Use a new key for a new operation; resend the identical body when retrying. |
| 409 | invalid_state | The action isn't allowed from the resource's current status, e.g. resuming a cancelled subscription. | Fetch the resource and check its status. |
| 409 | unsupported | The operation isn't supported for this resource, e.g. cancelling a subscription on a gateway that can't cancel through CentraPoint. | Do it in the gateway's portal. |
| 413 | invalid_request | Request body larger than 100 000 characters. | Send less data. |
| 429 | rate_limited | More than 120 requests in a minute with this API key (or 600 from one IP address). | Wait for Retry-After seconds, then retry. |
| 500 | internal_error | Unexpected server error. The message is generic; details are logged by CentraPoint. | Retry with backoff (see below). Contact support if it persists. |
| 502 | gateway_error | A payment gateway rejected or failed an operation CentraPoint forwarded (subscription cancel). Nothing changed. | Retry later. |
| 502–504 | – | Proxy or network error before the request reached the API. The body may not be JSON. | Retry with backoff. |
Validation errors#
When the request body fails validation, the response is 400 with error: "invalid_request", message: "Invalid body", and one entry in issues per problem. Each issue has a code, a path to the field and a message; some codes include extra detail such as minimum or format.
{
"error": "invalid_request",
"message": "Invalid body",
"issues": [
{
"origin": "number",
"code": "too_small",
"minimum": 0,
"inclusive": false,
"path": ["amount"],
"message": "Too small: expected number to be >0"
},
{
"origin": "string",
"code": "invalid_format",
"format": "email",
"path": ["customerEmail"],
"message": "Invalid email address"
}
]
}Common mistakes#
- Sending
amountas a string ("499.00"). It must be a JSON number (499.00). - Sending
expiresAtwithout seconds or timezone (2026-10-31T23:59). Include both, e.g.2026-10-31T23:59:59+02:00. It must also be in the future and at most 1 year ahead. - Using a
currencynone of your enabled gateways accepts. Enable a suitable gateway first, or use a supported currency. - Sending invalid JSON. You get
400with the message Body must be JSON (noissues). An empty body is treated as{}, so required fields are reported as missing.
Rate limits#
Each API key may make 120 requests per minute, counted in fixed 60-second windows that start on the minute. The count is shared across all CentraPoint servers, so it is the same whichever instance serves your request.
Separately, a coarse guard allows 600 requests per minute per client IP address before the API key is even checked. It only protects against floods (for example many requests with bad keys); normal integrations never reach it. The client IP is taken from the entry our own proxy appends to X-Forwarded-For, so it cannot be spoofed by setting that header yourself.
Rate-limit headers#
Every response to an authenticated request (success or error) includes the state of your key's current window:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per window (120). |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Unix time (seconds) when the current window ends and the count resets. |
Retry-After | 429 only: seconds to wait before retrying. |
401 unauthorized responses carry no rate-limit headers, because the key is not known yet.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790323260
Retry-After: 17
{"error":"rate_limited","message":"Rate limit of 120 requests per minute exceeded for this API key"}If the per-IP guard is hit instead, the message is Too many requests from this network and the headers describe the IP window (limit 600).
Retries and timeouts#
- 429: wait for the number of seconds in
Retry-After(or untilX-RateLimit-Reset), then retry. - 500 internal_error, 502–504 and network errors: retry reads (
GET) with exponential backoff and jitter, e.g. 1s, 2s, 4s, up to about 5 attempts. - POST requests: send an
Idempotency-Keyand reuse it on retries, so a timeout or500never creates a second link, invoice or payment. See Idempotency, metadata & references for how to handle this safely. - 4xx other than 429: do not retry without changing the request.
- Use a client timeout of around 30 seconds.
Example: GET with retries#
async function getWithRetry(url, attempts = 5) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.CENTRAPOINT_API_KEY}` },
signal: AbortSignal.timeout(30_000),
}).catch(() => null); // network error or timeout
if (res && res.status !== 429 && res.status < 500) return res; // success or non-retryable 4xx
let waitMs = Math.min(30_000, 1000 * 2 ** i) + Math.random() * 250; // backoff + jitter
if (res?.status === 429) {
const retryAfter = Number(res.headers.get("Retry-After"));
const reset = Number(res.headers.get("X-RateLimit-Reset"));
waitMs = retryAfter > 0 ? retryAfter * 1000 : reset > 0 ? reset * 1000 - Date.now() : waitMs;
}
await new Promise((r) => setTimeout(r, Math.max(0, waitMs)));
}
throw new Error("CentraPoint API unavailable after retries");
}