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"
}
}
}| Field | Type | Description |
|---|---|---|
type | string (enum) | High-level category (see below) |
code | string | Machine-readable code, e.g. missing_field |
message | string | Human-readable description, safe to show to end-users when relevant |
field | string (optional) | When the error is about one specific input field |
detail | object (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
| Code | Cause | Fix |
|---|---|---|
recipient_unresolvable | Account number invalid at every provider in the cascade | Check account number + bank code |
name_mismatch | (FX rails only) Merchant-supplied name doesn’t match bank-of-record | Use the bank-of-record name; NUBAN auto-resolves on NGN |
beneficiary_cooldown | Same recipient paid in the last 5 minutes | Wait or pass allow_duplicate: true if intentional |
limit_violation | Per-tx / daily / per-beneficiary cap exceeded | Check MerchantLimits settings or split the payment |
fraud_rule_blocked | Internal fraud rule blocked | Contact support; check rule details in error.detail.hits |
beneficiary_blacklisted | Recipient on your merchant blacklist (403) | Remove from the blacklist or use a different recipient |
beneficiary_globally_blocked | Recipient on the platform-wide blacklist (403) | Cannot override; contact support |
sender_info_required | Your 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 currency | Issue 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 currency | Complete the currency’s onboarding with Technest |
POST /v1/batches
| Code | Cause | Fix |
|---|---|---|
invalid_body · invalid_amount · invalid_format · invalid_id_type · items_empty · items_too_many (400) | A structural problem in the body or a row | Validation 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 check | Every 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_key | API key not enabled for bulk | Use a key with payout_bulk_upload permission |
bulk_requires_ip_allowlist | Bulk needs a non-empty IP allowlist | Add 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.