Errors
How to handle errors from the Partner API v1. Response shape, categories, retry rules.
Overview
Every non-2xx response from the v1 API has the same JSON shape:
{
"ok": false,
"error": "<error_code>"
}The HTTP status tells you the category (4xx = client, 5xx = server); the error field tells you exactly what went wrong. Some errors include additional fields to help you recover:
{
"ok": false,
"error": "insufficient_scope",
"required_scope": "requests:write"
}
{
"ok": false,
"error": "currency_mismatch",
"expected_fiat_code": "USD"
}error, not on the HTTP status alone. Two different errors can share a status (many codes return 400), and the status can change over time (e.g. a code promoted from 400 to 422 in a future version) without breaking clients that switch on the string.Error categories
The errors a v1 partner integration actually encounters fall into six patterns. The full code table with every message is in Reference → Error codes — this page covers the handling logic.
Authentication & scope (401, 403)
| Code | HTTP | Meaning | Handling |
|---|---|---|---|
unauthorized | 401 | Missing, malformed, unknown, or revoked API key. | Don't retry. Surface to ops immediately — this is a deployment issue, not a transient failure. |
insufficient_scope | 403 | Key lacks the required scope. Response includes required_scope. | Add the scope in the partner portal or generate a new key. Retries with the same key will keep failing. |
Validation (400)
Invalid shape or content in your request body or query params. The error code names the offending field: invalid_merchant_id, invalid_fiat_amount, invalid_email, invalid_wallet_address, invalid_country, invalid_account_type, invalid_idempotency_key, invalid_memo, metadata_too_many_keys, metadata_key_too_long, metadata_value_too_long, etc.
Don't retry. A validation error means the request is structurally wrong — sending it again won't change the outcome. Log the payload, fix the caller, and redeploy.
Special case: currency_mismatch returns the expected currency in expected_fiat_code. Most integrations should drop the assertion (omit fiat_currency_code) or update their config to match.
State transitions (409)
| Code | Meaning | Handling |
|---|---|---|
cannot_cancel | Request has been opened, has proof attached, or is already voided. Returns the current request object so you can see the state. | Treat as non-fatal. Depending on the state, the request may already be paid (fulfill the order) or expired (surface to merchant). |
wallet_address_conflict | A wallet_address supplied on merchant create already belongs to a merchant on a different partner or in a different mode. | Don't retry. The wallet is globally unique. Either use a different wallet or omit the field and let the user provision one in onboarding. |
merchant_onboarding_incomplete | Tried to create a payment request for a merchant still in onboarding_status: "incomplete". | Redirect the merchant to their onboarding_url (from the merchant response) to finish setup. |
merchant_mode_mismatch | Test key used against a live merchant (or vice versa). | Don't retry. Swap keys between environments — probably the wrong key is loaded. |
Rate limit (429)
| Code | Meaning | Handling |
|---|---|---|
rate_limited | Exceeded the per-endpoint limit. See Rate limits. | Back off for 60 seconds (the window), then retry. Don't retry in a tight loop — every sub-60s retry is guaranteed to fail. |
Transient (503)
| Code | Meaning | Handling |
|---|---|---|
platform_paused | Platform kill switch is active. No requests or merchants can be created. | Retry after 60 seconds with exponential backoff. Surface a "payments temporarily unavailable" state to your user if the pause persists more than a few minutes. |
fx_unavailable | FX rate couldn't be resolved for the merchant's currency. | Retry after 30–60 seconds. If it persists, contact support — this is rare and usually resolves quickly. |
create_failed | Unexpected server error during create. Rare. | Retry with the same idempotency key — if the create actually succeeded on the server before the error, the retry returns the existing request. See Requests API → Idempotency. |
Structural (400, 404)
| Code | Meaning | Handling |
|---|---|---|
invalid_json | Request body wasn't valid JSON. | Fix the serializer; don't retry raw. |
not_found | Resource doesn't exist or belongs to a different partner. Cross-tenant existence is never disclosed — a typo and someone else's id look identical. | If you're reading your own resource, the id is wrong or the assignment was revoked. Check the merchant assignment before retrying. |
Retry rules summarized
| HTTP | Retry? | How |
|---|---|---|
| 2xx | — | Success, no retry needed. |
| 400 | No | Fix the request. Log the body and the error code. |
| 401, 403 | No | Deployment/config issue. Alert ops. |
| 404 | No | Resource not found. Don't retry; verify the id. |
| 409 | Depends | cannot_cancel: read the state from the response body, don't retry. wallet_address_conflict: don't retry. |
| 429 | Yes | Wait 60 s + jitter, retry. |
| 5xx | Yes | Exponential backoff (30s → 60s → 120s, cap at 5 minutes). For create endpoints, reuse the same idempotency key so replays are deterministic. |
Handling pattern
A minimal robust client:
async function createJoPayRequest(
body: CreateRequestBody,
idempotencyKey: string,
): Promise<PaymentRequest | null> {
const url = `https://${SLUG}.jopay.app/api/v1/requests/create`;
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, idempotency_key: idempotencyKey }),
});
if (res.status >= 200 && res.status < 300) {
return await res.json();
}
const payload = await res.json();
// Don't retry — fix caller and return
if (res.status === 400 || res.status === 401 || res.status === 403 || res.status === 404) {
throw new Error(`JoPay error: ${payload.error}`);
}
// 409: inspect state and act on it (e.g. merchant_onboarding_incomplete)
if (res.status === 409) {
throw new Error(`JoPay state conflict: ${payload.error}`);
}
// 429 or 5xx: retry with backoff
const waitMs = res.status === 429
? 60_000 + Math.random() * 10_000
: 30_000 * Math.pow(2, attempt) + Math.random() * 5_000;
await new Promise((r) => setTimeout(r, waitMs));
}
return null;
}