Errors

Errors

The Swappr API uses a consistent error envelope. Every error response has the same shape, so you can write a single error handler that covers every endpoint.

Error envelope

{
  "error": {
    "type": "invalid_request_error",
    "code": "missing_field",
    "message": "amount_minor is required.",
    "field": "amount_minor",
    "detail": {
      "expected": "string",
      "received": "undefined"
    }
  }
}
FieldTypeDescription
typestring (enum)High-level category (see below)
codestringMachine-readable code, e.g. missing_field
messagestringHuman-readable description, safe to show to end-users when relevant
fieldstring (optional)When the error is about one specific input field
detailobject (optional)Additional structured context

Error types

invalid_request_error (400, 402, 409, 422, 429)

The request was malformed, missing required fields, or violated a business rule. Fix the request body before retrying.

Common codes: missing_field, invalid_field, invalid_json, invalid_url, invalid_event_type, wallet_not_found, customer_not_found, customer_not_verified, sender_account_not_provisioned, env_mismatch, unsupported_currency, invalid_recipient, recipient_unresolvable, validation_failed, limit_violation, fraud_rule_blocked, insufficient_funds, idempotency_conflict, merchant_reference_duplicate, routing_failed, name_mismatch, beneficiary_cooldown, sender_info_required.

authentication_error (401)

The API key is missing, malformed, revoked, or expired.

Codes: missing_api_key, invalid_api_key, key_expired (live key past its expiry date), grace_expired (rotated key past grace window).

permission_error (403)

The request is refused on authority rather than on its contents. Four flavours: the API key lacks the permission required for this endpoint; your merchant account is suspended or closed; the request IP isn’t in the key’s allowlist; or the account is not entitled to what the request asks for — a specific resource is barred (a frozen wallet, a blacklisted recipient) or your account is not enabled for any route that carries the requested currency. Entitlement refusals are permanent until Technest changes the configuration, so they should not be retried.

provider_paused and collections_paused are the 403s that are temporary. Your account is enabled for the route (or for collections); Technest has stopped it for the moment. It is not a configuration problem you can correct, and it is not worth a retry loop either — contact Technest, and the same request will work once the pause is lifted. collections_not_enabled is the permanent counterpart: collections are not enabled for the account at all.

Codes: permission_denied, merchant_suspended, merchant_closed, ip_not_allowed, bulk_disabled_for_key, bulk_requires_ip_allowlist, remittances_not_enabled, no_active_international_account, collections_not_enabled, collections_paused, wallet_frozen, beneficiary_blacklisted, beneficiary_globally_blocked, provider_not_entitled, provider_paused.

not_found_error (404)

Resource doesn’t exist or doesn’t belong to your merchant. We return 404 across env boundaries (e.g. looking up a sandbox payout with a live key) so we don’t leak the existence of resources you can’t access.

Codes: payout_not_found, batch_not_found, wallet_not_found, beneficiary_not_found, customer_not_found, webhook_endpoint_not_found.

rate_limit_error (429)

You exceeded the request budget. Wait until Retry-After seconds elapse + retry. See Rate limits.

Codes: rate_limit_exceeded.

provider_error (502, 503)

A downstream rail returned an error or was unreachable. The original request is not committed; retrying with the same idempotency key is safe.

On the collections create path, collections_disabled and collections_rail_disabled (both 503) mean collections are temporarily unavailable — platform-wide, or for the collection rail in your environment. They return the same merchant message; the code is the only discriminator. Both are temporary and platform-side — retry with backoff.

Codes: provider_unreachable, delivery_failed, rail_not_configured, collections_disabled, collections_rail_disabled, plus rail-specific codes surfaced from the underlying response.

api_error (500)

Unexpected internal error. Our on-call team is paged automatically. Retry with the same idempotency key — if the issue clears within 60 minutes you’ll get the cached successful response.

Handling errors safely

Always parse error.code, not error.message. The message is human-readable + may change over time. The code is stable and machine-readable.

async function handleSwapprResponse(res: Response) {
  if (res.ok) return await res.json();
 
  const body = await res.json();
  const code = body?.error?.code;
  const type = body?.error?.type;
 
  switch (code) {
    case 'recipient_unresolvable':
      // Account number couldn't be resolved at any provider — show user a
      // friendly "check the account details" prompt.
      throw new UserFacingError('Could not verify recipient account.');
 
    case 'beneficiary_cooldown':
      // Same recipient was paid recently. Possibly a duplicate retry.
      // Your UI may want to ask "are you sure?" before retrying.
      throw new DuplicateRetryError(body.error.detail);
 
    case 'limit_violation':
      // Per-tx, daily, or per-beneficiary cap exceeded.
      throw new LimitExceededError(body.error.detail);
 
    case 'fraud_rule_blocked':
      // Internal fraud rule blocked the payout. Escalate to support.
      throw new FraudRuleError(body.error);
 
    case 'rate_limit_exceeded':
      // Wait Retry-After then try again.
      const retryAfter = parseInt(res.headers.get('retry-after') ?? '60', 10);
      await sleep(retryAfter * 1000);
      // Retry — caller should re-invoke with same idempotency key.
      throw new RetryableError(body.error.message, { retryAfter });
 
    default:
      if (type === 'provider_error' || type === 'api_error') {
        // Transient — caller can retry with same idempotency key.
        throw new RetryableError(body.error.message);
      }
      throw new ApiError(body.error);
  }
}

Request IDs

Every response — success or error — includes an X-Request-Id header: a unique id for that exact request.

When you contact support about a failed or unexpected call, include the X-Request-Id from the response. It lets us trace the precise request — method, path, status, latency, and the masked request/response bodies — in our logs, so we can diagnose without guessing. We retain API request logs for ~13 months; secrets such as your API key are never stored.

Read it from the response headers and store it alongside your own records:

const res = await fetch('https://api.swappr.me/api/v1/payouts', options);
const requestId = res.headers.get('x-request-id');
console.log('Swappr request id:', requestId);

Common errors by endpoint

POST /v1/payouts

CodeCauseFix
recipient_unresolvableAccount number invalid at every provider in the cascadeCheck account number + bank code
name_mismatch(FX rails only) Merchant-supplied name doesn’t match bank-of-recordUse the bank-of-record name; NUBAN auto-resolves on NGN
beneficiary_cooldownSame recipient paid in the last 5 minutesWait or pass allow_duplicate: true if intentional
limit_violationPer-tx / daily / per-beneficiary cap exceededCheck MerchantLimits settings or split the payment
fraud_rule_blockedInternal fraud rule blockedContact support; check rule details in error.detail.hits
beneficiary_blacklistedRecipient on your merchant blacklist (403)Remove from the blacklist or use a different recipient
beneficiary_globally_blockedRecipient on the platform-wide blacklist (403)Cannot override; contact support
sender_info_requiredYour account requires sender info (IMTO compliance)Add sender block to request
sender_account_not_provisioned(Individual flow, GBP/USD/EUR) The sending customer has no active virtual account in that currencyIssue one via POST /v1/customers/{id}/virtual_accounts, then retry
no_active_international_account(Business/treasury flow, GBP/USD/EUR) The merchant has no active international account in that currencyComplete the currency’s onboarding with Technest

POST /v1/batches

CodeCauseFix
invalid_body · invalid_amount · invalid_format · invalid_id_type · items_empty · items_too_many (400)A structural problem in the body or a rowValidation stops at the first bad row and names its index in message (e.g. items[3].amount_minor must be…). There is no per-row array — fix that row and resubmit
sender_info_required · unknown_bank_code · recipient_unresolvable · limit_violation · fraud_rule_blocked (422)The body parsed, so every row was checked and one or more failed a semantic checkEvery failing row is listed together, in an array top-level on error (never error.detail). The key is violations — except recipient_unresolvable, which uses unresolved. Branch on error.code first, or you will silently drop those rows
bulk_disabled_for_keyAPI key not enabled for bulkUse a key with payout_bulk_upload permission
bulk_requires_ip_allowlistBulk needs a non-empty IP allowlistAdd at least one IP to the key

The whole batch is rejected either way — nothing is created and no wallet moves unless every row passes. See the Batches reference for the complete code list, the per-row entry fields for each of the five, and how to read per-row outcomes after a batch is accepted.

What’s next