Koywe
On this page

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:

JSON
{
  "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:

  • errorCode — Always 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.

JavaScript
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.

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

Text
GET /api/v1/healthz
JSON
{ "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