Documentation
Errors
Error shapes, retry guidance, and the full code table.
JuicePay returns conventional HTTP status codes and a consistent JSON error body. Error bodies are safe to log and safe to surface to your own support tooling.
Shape#
json
{
"error": {
"type": "invalid_request_error",
"code": "recipient_invalid_address",
"message": "The recipient address is not valid on chain 'solana'.",
"param": "legs[3].recipient",
"doc_url": "https://juicepay.test/docs/api/errors#recipient_invalid_address",
"request_id": "req_8kL3mQ2xNp"
}
}Always log request_id. It is the only identifier that lets support correlate your failure with our traces.
Types#
| Type | Status | Meaning |
|---|---|---|
invalid_request_error | 400 | The request is malformed or fails validation. |
authentication_error | 401 | The key is missing, malformed, or revoked. |
permission_denied | 403 | The key is valid but lacks a required scope. |
not_found | 404 | The resource does not exist, or belongs to another account. |
idempotency_key_reuse | 409 | Same key, different body. |
precondition_failed | 412 | The resource state does not permit this operation. |
rate_limit_error | 429 | Too many requests. |
provider_error | 502 | An upstream venue or chain node failed. |
api_error | 500 | Something on our side broke. |
Retry guidance#
The distinction that matters is whether retrying can ever help.
- Retry with backoff —
429,500,502, and any connection error.429respectsRetry-After. - Retry after fixing the request —
400,412. The body or the resource state must change first. - Never retry unchanged —
401,403,404. These indicate a configuration problem that will reproduce forever.
ts
async function withRetry<T>(operation: () => Promise<T>, attempts = 5): Promise<T> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return await operation();
} catch (error) {
if (!isJuicePayError(error)) throw error;
const retryable = ["rate_limit_error", "api_error", "provider_error"];
if (!retryable.includes(error.type)) throw error;
if (attempt === attempts - 1) throw error;
const ceiling = Math.min(2 ** attempt * 250, 8000);
const jitter = Math.random() * ceiling * 0.25;
await sleep(ceiling + jitter);
}
}
throw new Error("unreachable");
}Rate limits#
Limits are per key and per endpoint class:
| Class | Limit | Burst |
|---|---|---|
| Reads | 1,000/min | 2,000 |
| Writes | 300/min | 600 |
| Batch submissions | 20/min | 40 |
text
HTTP/1.1 200 OK
JuicePay-RateLimit-Limit: 300
JuicePay-RateLimit-Remaining: 271
JuicePay-RateLimit-Reset: 1755594900Common codes#
| Code | Fix |
|---|---|
recipient_invalid_address | Validate the destination against the target chain. |
recipient_screening_blocked | Stop. Escalate to compliance; do not retry. |
insufficient_balance | Fund the account, then replay the leg. |
amount_below_minimum | Check per-asset and per-chain minimums. |
asset_precision_exceeded | Send whole units for low-decimal tokens. |
quote_expired | Request a fresh quote before executing. |
chain_halted | Transient. Retries resume with the network. |
routing_unavailable | Relax prefer_chains or enable bridging. |
idempotency_key_reuse | A genuine bug — the same key was sent with a different body. |
settlement_confidence_invalid | included is not available for this rail. |
Validation errors#
Batch endpoints report every failing leg in one response rather than stopping at the first, so a dry run tells you about all of your problems at once.
json
{
"error": {
"type": "invalid_request_error",
"code": "batch_validation_failed",
"message": "3 of 10000 legs failed validation.",
"legs": [
{ "row": 3, "code": "recipient_invalid_address", "param": "legs[3].recipient" },
{ "row": 881, "code": "amount_below_minimum", "param": "legs[881].amount" },
{ "row": 4417, "code": "asset_precision_exceeded", "param": "legs[4417].amount" }
]
}
}