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:
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.
| Prefix | Domain | Prefix | Domain |
|---|---|---|---|
AUTH | Authentication & credentials | OR | Orders |
BAA | Bank accounts | ORG | Organizations |
BT | Balance transfer | PER | Permissions |
CT | Contacts | PIVBA | Pay-in virtual bank accounts |
DC | Documents (validation) | PL | Payment links |
DL | Deals | PMP | Payment-method providers |
EWW | Embedded wallet | POL | Policy (MFA & approvals) |
IMT | Inter-merchant transfer | QE | Quotes |
LE | Ledger | RE | Reports |
MC | Merchants | WE | Webhooks |
See the full prefix → domain map in the catalog for all 51 prefixes.
HTTP status codes
| Status | Meaning | Action |
|---|---|---|
200 | Success | Continue |
400 | Bad Request | Fix request parameters |
401 | Unauthorized | Refresh token / check credentials |
403 | Forbidden | Check permissions and organization/merchant context |
404 | Not Found | Verify resource ID |
408 | Timeout | Retry with backoff |
409 | Conflict | Duplicate / idempotency issue |
410 | Gone | Resource (e.g. quote, payment link) has expired |
422 | Validation | Fix input validation |
428 | Precondition Required | Satisfy a policy (MFA verification or approval) |
429 | Rate Limit | Wait and retry |
500 | Server Error | Retry with backoff |
502 / 503 / 504 | Upstream issue | Retry 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.
| Code | Status | When it happens | Fix |
|---|---|---|---|
AUTH00001 | 401 | Token rejected | Re-auth with koywe auth login or refresh env vars |
MC00015 | 403 | merchantId not in the signed-in organization | Check organizationId / merchantId in config. GETs succeed silently; only POSTs fail. |
DC00010 | 400 | Document number doesn’t match the country’s document type format | Use a valid test document (see Testing in sandbox) |
BAA00008 | 400 | Destination account currency ≠ order destination currency | Pick a destination account whose currency matches the order |
BAA00014 | 409 | Payout exceeds virtual-account balance | Fund the account or reduce the amount |
QE00003 | 410 | Quote expired | Create a new quote |
QE00004 | 409 | Quote already used by another order | Quotes are single-use — create a new one |
POL00002 | 403 | No policy defined on the organization | koywe policy create then add an ALLOW rule |
POL00007 | 428 | Approval required; order is ON_HOLD | Pass --wait in flow order, approve on the dashboard, or provide --mfa-token |
PMP00009 | 408 | Upstream payment provider timed out | Retry 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
503if an upstream dependency is degraded.