Errors and rate limits
Every error has the same shape, a stable code to branch on, and a Request-Id. Here is what to retry, what to fix and what to show the payer.
The error shape
HTTP/1.1 400 Bad Request
Request-Id: req_Qm3…
{
"error": {
"type": "invalid_request",
"code": "parameter_invalid",
"param": "amount",
"message": "amount must be a positive integer",
"request_id": "req_Qm3…"
}
}- Branch on
typeandcode. They are stable;messageis for humans and may change. paramnames the field at fault (e.g.payer.email,crypto.asset), or isnull.- Log
request_id(also in theRequest-Idheader of every response, success or not). Support can find any call by it. - Don't show
messageto payers. Map the cases you expect to your own wording.
Types and what to do
| HTTP | type | What to do |
|---|---|---|
| 400 | invalid_request | Fix the request. param names the field. Retrying unchanged gives the same answer. |
| 401 | authentication_error | Missing, malformed or revoked key. Don't retry; check your configuration. |
| 403 | permission_error | The key can't do this: a publishable key on a server call, a store-limited key on workspace data, or live mode not open yet. |
| 404 | not_found | No such object in this workspace and mode. A test id with a live key (or the other way round) also gives 404. |
| 409 | order_state | The object is in the wrong state (e.g. refunding an unpaid order), or an idempotency conflict. Fetch the object and decide. |
| 422 | method_unavailable | No allowed payment method can take this order. Change methods or amount, or offer another way to pay. |
| 429 | rate_limited | Too many requests. Wait Retry-After seconds, then retry. |
| 5xx | api_error | Our side. Retry with backoff and the same Idempotency-Key. |
The full list of codes is in the API reference. The ones you are most likely to meet when creating orders:
| code | Typical cause | Handling |
|---|---|---|
parameter_invalid, parameter_missing, parameter_unknown, amount_mismatch | A bug in your request. | Fix and redeploy; alert yourself. |
amount_too_small | The amount is outside the limits of every allowed method (e.g. 2 USD with only card). | Allow more methods or tell the payer the minimum. |
currency_unsupported | A currency we don't accept. | Convert to USD or EUR before creating the order. |
reference_taken | Another order in this mode already has this reference, often a retry without an Idempotency-Key. | Look the order up with GET /orders?reference=… and reuse it, or add a suffix. |
crypto_unsupported | The preset network or coin isn't offered (or not running in live mode). | Fall back to creating the order without crypto. |
no_method_enabled | None of the requested methods is switched on for this account or store. | Check payment method settings; in live mode they need to be granted. |
payer_blocked | The payer's email can't be accepted. | Show a neutral "payment not possible, contact us". |
account_inactive | A live key before your account is approved. | Use test keys until live mode opens. |
Rate limits
Each key may make 100 requests per second, with short bursts allowed. Every response carries:
| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed per window. |
RateLimit-Remaining | Requests left in the current window. |
RateLimit-Reset | Seconds until the window resets. |
Retry-After | On 429 only: seconds to wait before retrying. |
If you hit the limit regularly, you are probably polling. Use webhooks instead of polling order state, and list endpoints with limit=100 (filter GET /orders by reference, state or created[gte] to fetch just the orders you need).
Retrying safely
- GET requests can always be retried.
- POST requests: send an
Idempotency-Key(up to 255 characters, e.g. your order number) and retry with the same key and body. For 48 hours we return the first answer instead of doing the work twice; a replayed answer hasIdempotent-Replay: 1. - A
5xxanswer is not stored: retrying with the same key runs the request again. A4xxanswer is stored and replayed. 409 idempotency_mismatch: you reused a key with a different body. Use a new key for a new request.409 idempotency_in_progress: the first request with this key is still running. Wait a moment and retry.- Timeouts: if you don't know whether a POST went through, retry with the same key. You get the original order, not a second one.
async function velpay(path, { method = "GET", body, idempotencyKey } = {}) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`https://velpay.pro/api/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.VELPAY_SECRET_KEY}`,
...(body && { "Content-Type": "application/json" }),
...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
},
body: body && JSON.stringify(body),
});
if (res.ok) return res.json();
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt === 4) {
const { error } = await res.json().catch(() => ({ error: { message: res.statusText } }));
throw Object.assign(new Error(error.message), { status: res.status, ...error });
}
// 429: the server says how long. 5xx: exponential backoff with jitter.
const retryAfter = Number(res.headers.get("Retry-After"));
const wait = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250 + Math.random() * 250;
await new Promise((r) => setTimeout(r, wait));
}
}
// POSTs are only retried safely with an Idempotency-Key:
await velpay("/orders", { method: "POST", body: { amount: 4900, currency: "usd" }, idempotencyKey: "order-A-2041" });Maintenance
During rare maintenance windows POST /orders may answer 503 with code paused. Treat it like any other 5xx: retry with backoff, or offer the payer to try again in a few minutes. Reads keep working.