Skip to content

Errors

Every error response has the shape { "success": false, "error_code": "...", "message": "..." }.

error_codeHTTP statusWhen
VALIDATION_ERROR400Malformed/missing input, bad JSON, malformed UUID path params
INVALID_API_KEY401Missing or unrecognized X-API-Key
SIGNATURE_INVALID401Inbound webhook: missing/unknown user or non-matching secret
QRIS_NOT_FOUND404qris_id doesn’t exist, is soft-deleted, or was registered in the other mode (live/sandbox)
TRANSACTION_NOT_FOUND404history_id doesn’t exist for this tenant
INVALID_QRIS_FORMAT400Uploaded/decoded QRIS fails EMV structural validation
PAYMENT_EXPIRED409simulate-payment on an expired transaction without allow_payment_after_expiry
ALREADY_FINALIZED409simulate-payment on a transaction that’s no longer unpaid (or lost a concurrent race)
RATE_LIMIT_EXCEEDED429Over the per-route rate limit
PAYLOAD_TOO_LARGE413Request body over the size cap
INSUFFICIENT_BALANCE402generate: the tenant’s wallet balance can’t cover this SKU’s price — top up and retry
BILLING_UNAVAILABLE503generate: the billing service couldn’t be reached or is misconfigured — no charge was made, retry
DAILY_LIMIT_EXCEEDED429generate (live only): today’s generate count has reached the tenant’s daily cap — see Add-ons
INTERNAL_ERROR500Unhandled server error

Examples:

{ "success": false, "error_code": "INVALID_API_KEY", "message": "API key is invalid or expired" }
{ "success": false, "error_code": "VALIDATION_ERROR", "message": "history id must be a valid UUID" }
{ "success": false, "error_code": "TRANSACTION_NOT_FOUND", "message": "Transaction history does not exist" }

Every authenticated route is rate-limited per tenant + mode + route (60/min for writes, 120/min for reads), tracked in-memory with a fixed window. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; exceeding the limit returns 429 RATE_LIMIT_EXCEEDED. The inbound webhook (POST /v1/webhook/payment-status) is intentionally not rate-limited — see Webhooks.

Request bodies are capped at 5 MB globally, and 64 KB on the unauthenticated inbound webhook endpoint; over the limit returns 413 PAYLOAD_TOO_LARGE.

GET /health (no auth) checks database reachability for uptime monitoring:

{ "status": "ok" }

or, with HTTP 503:

{ "status": "degraded", "message": "database unreachable" }

Continue to Known Limitations