Skip to Content
Advanced TopicsError Handling

Error Handling

Every error returned by the Koywe Platform API carries a stable, unique errorCode like MC00015 or DC00010. The uppercase prefix identifies the domain (merchants, documents, policy, …); the numeric suffix is the specific error within that domain.

Looking for a specific code? The Error Code Catalog lists all 685 registered codes grouped by prefix.

Error response format

All API errors follow a consistent envelope:

{ "statusCode": 400, "timestamp": "2025-04-23T12:34:56.000Z", "path": "/api/v1/organizations/org_.../merchants/mer_.../orders", "errorCode": "BAA00008", "message": "The destination account currency does not match the order destination currency." }

Fields:

  • errorCodeAlways branch on this. The prefixed code is stable across releases.
  • statusCode — HTTP status; useful for generic retry logic.
  • message — Human-readable. May be reworded without notice; don’t parse it.
  • timestamp, path — Helpful for support tickets and log correlation.

Branch your integration on errorCode, not message. Messages are refined over time; codes are contracts.


The prefix convention

Codes follow the pattern <PREFIX><5+ digits> — for example MC00015, POL00007, WE000001.

PrefixDomainPrefixDomain
AUTHAuthentication & credentialsOROrders
BAABank accountsORGOrganizations
BTBalance transferPERPermissions
CTContactsPIVBAPay-in virtual bank accounts
DCDocuments (validation)PLPayment links
DLDealsPMPPayment-method providers
EWWEmbedded walletPOLPolicy (MFA & approvals)
IMTInter-merchant transferQEQuotes
LELedgerREReports
MCMerchantsWEWebhooks

See the full prefix → domain map in the catalog for all 51 prefixes.


HTTP status codes

StatusMeaningAction
200SuccessContinue
400Bad RequestFix request parameters
401UnauthorizedRefresh token / check credentials
403ForbiddenCheck permissions and organization/merchant context
404Not FoundVerify resource ID
408TimeoutRetry with backoff
409ConflictDuplicate / idempotency issue
410GoneResource (e.g. quote, payment link) has expired
422ValidationFix input validation
428Precondition RequiredSatisfy a policy (MFA verification or approval)
429Rate LimitWait and retry
500Server ErrorRetry with backoff
502 / 503 / 504Upstream issueRetry with backoff

Common errors you’ll hit first

A few codes are worth knowing by heart because they trip up most integrations. The Error Code Catalog has a longer list.

CodeStatusWhen it happensFix
AUTH00001401Token rejectedRe-auth with koywe auth login or refresh env vars
MC00015403merchantId not in the signed-in organizationCheck organizationId / merchantId in config. GETs succeed silently; only POSTs fail.
DC00010400Document number doesn’t match the country’s document type formatUse a valid test document (see Testing in sandbox)
BAA00008400Destination account currency ≠ order destination currencyPick a destination account whose currency matches the order
BAA00014409Payout exceeds virtual-account balanceFund the account or reduce the amount
QE00003410Quote expiredCreate a new quote
QE00004409Quote already used by another orderQuotes are single-use — create a new one
POL00002403No policy defined on the organizationkoywe policy create then add an ALLOW rule
POL00007428Approval required; order is ON_HOLDPass --wait in flow order, approve on the dashboard, or provide --mfa-token
PMP00009408Upstream payment provider timed outRetry with backoff

Recovery strategies

1. Retry transient failures with backoff

Retry on network-level errors and the upstream-unreachable range (408, 429, 5xx). Never retry on 4xx validation errors — they’ll keep failing with the same input.

async function retryWithBackoff(fn, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { const status = error.response?.status; const isRetryable = status === 408 || status === 429 || (status >= 500 && status < 600) || error.code === 'ECONNABORTED'; const isLastAttempt = i === maxRetries - 1; if (!isRetryable || isLastAttempt) throw error; // Exponential backoff with jitter: ~1s, 2s, 4s const delay = Math.pow(2, i) * 1000 + Math.random() * 500; await new Promise(r => setTimeout(r, delay)); } } }

2. Handle policy-gated operations

POL00006, POL00007, and POL00008 (all 428 Precondition Required) mean the operation needs MFA verification, human approval, or both before it can execute. The order is placed in ON_HOLD — the creation call succeeds and returns the order — and transitions once the approval/MFA is satisfied.

async function createOrderAndWait(token, orgId, merchantId, payload) { const order = await createPayinOrder(token, orgId, merchantId, payload); if (order.status !== 'ON_HOLD') return order; // Poll until approval resolves — the CLI's `flow order --wait` does this // for you. Production integrations should listen to webhooks instead of // polling (see "Webhooks deep dive"). for (let i = 0; i < 60; i++) { await new Promise(r => setTimeout(r, 5000)); const refreshed = await getOrder(token, orgId, merchantId, order.id); if (refreshed.status !== 'ON_HOLD') return refreshed; } throw new Error(`Order ${order.id} still ON_HOLD after 5 minutes`); }

3. Graceful degradation for payment methods

If a payment method isn’t available for the country/currency combo (PMP00012, PMP00017) or the provider is momentarily down (PMP00003, PMP00008), fall back to a secondary method:

async function createPayinWithFallback(orderBase) { const attempts = [ { method: 'PSE', extra: { bankAccount: { name: 'BANCOLOMBIA' } } }, { method: 'NEQUI' }, ]; for (const pm of attempts) { try { return await createPayinOrder(token, orgId, merchantId, { ...orderBase, paymentMethods: [pm], }); } catch (error) { const code = error.response?.data?.errorCode; const retryable = ['PMP00012', 'PMP00017', 'PMP00003', 'PMP00008']; if (!retryable.includes(code)) throw error; } } throw new Error('No payment method available for this country/currency'); }

4. Translate codes to end-user messages

Map error codes to user-facing copy — never show raw codes to customers:

function translateErrorToUser(error) { const code = error.response?.data?.errorCode; const userMessages = { BAA00014: "We don't have enough balance to complete this payout right now.", DC00010: 'Please double-check the document number.', QE00003: 'That price quote has expired — please try again.', PMP00006: 'That amount is below the minimum for this payment method.', PMP00007: 'That amount exceeds the limit for this payment method.', POL00004: 'This transaction exceeds your configured limit. Please contact an admin.', POL00007: 'This operation is waiting for approval. You\'ll be notified shortly.', }; return userMessages[code] ?? 'Something went wrong. Please try again or contact support.'; }

5. Log everything with correlation context

When calling support, the single most useful thing you can send is the raw error envelope plus the path and timestamp:

function logError(error, context) { const apiError = error.response?.data ?? {}; console.error(JSON.stringify({ timestamp: new Date().toISOString(), ...context, statusCode: apiError.statusCode, errorCode: apiError.errorCode, message: apiError.message, path: apiError.path, apiTimestamp: apiError.timestamp, })); } try { await createPayoutOrder(token, orgId, merchantId, payoutData); } catch (error) { logError(error, { operation: 'create_payout', merchantId }); throw error; }

Production error handler

Everything above, wired into a single reusable class:

class KoyweErrorHandler { constructor({ maxRetries = 3, logger = console } = {}) { this.maxRetries = maxRetries; this.logger = logger; } async execute(fn, context = {}) { for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { return await fn(); } catch (error) { this.logError(error, { ...context, attempt }); if (!this.shouldRetry(error, attempt)) { throw this.formatError(error); } const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500; await new Promise(r => setTimeout(r, delay)); } } } shouldRetry(error, attempt) { if (attempt >= this.maxRetries - 1) return false; const status = error.response?.status; return ( status === 408 || status === 429 || (status >= 500 && status < 600) || error.code === 'ECONNABORTED' ); } logError(error, context) { const apiError = error.response?.data ?? {}; this.logger.error('Koywe API error:', { timestamp: new Date().toISOString(), ...context, errorCode: apiError.errorCode, statusCode: apiError.statusCode, message: apiError.message, path: apiError.path, }); } formatError(error) { const apiError = error.response?.data; if (!apiError) return error; const err = new Error(apiError.message); err.errorCode = apiError.errorCode; err.statusCode = apiError.statusCode; err.path = apiError.path; return err; } } const errorHandler = new KoyweErrorHandler({ maxRetries: 3 }); const order = await errorHandler.execute( () => createPayinOrder(token, orgId, merchantId, orderData), { operation: 'create_payin', merchantId }, );

Service health check

Before diagnosing request-level errors, confirm the API itself is reachable. Koywe exposes a lightweight unauthenticated health endpoint:

GET /api/v1/healthz
{ "status": "ok" }

Characteristics:

  • Unauthenticated — useful for status pages and CI smoke tests.
  • Cheap — safe to hit frequently from dashboards.
  • Returns 503 if an upstream dependency is degraded.

Next steps

Last updated on