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"
}
Match on 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)

CodeHTTPMeaningHandling
unauthorized401Missing, malformed, unknown, or revoked API key.Don't retry. Surface to ops immediately — this is a deployment issue, not a transient failure.
insufficient_scope403Key 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)

CodeMeaningHandling
cannot_cancelRequest 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_conflictA 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_incompleteTried 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_mismatchTest 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)

CodeMeaningHandling
rate_limitedExceeded 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)

CodeMeaningHandling
platform_pausedPlatform 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_unavailableFX 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_failedUnexpected 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)

CodeMeaningHandling
invalid_jsonRequest body wasn't valid JSON.Fix the serializer; don't retry raw.
not_foundResource 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

HTTPRetry?How
2xxSuccess, no retry needed.
400NoFix the request. Log the body and the error code.
401, 403NoDeployment/config issue. Alert ops.
404NoResource not found. Don't retry; verify the id.
409Dependscannot_cancel: read the state from the response body, don't retry. wallet_address_conflict: don't retry.
429YesWait 60 s + jitter, retry.
5xxYesExponential 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;
}

What next?