Errors
Every error response has the shape { "success": false, "error_code": "...", "message": "..." }.
error_code | HTTP status | When |
|---|---|---|
VALIDATION_ERROR | 400 | Malformed/missing input, bad JSON, malformed UUID path params |
INVALID_API_KEY | 401 | Missing or unrecognized X-API-Key |
SIGNATURE_INVALID | 401 | Inbound webhook: missing/unknown user or non-matching secret |
QRIS_NOT_FOUND | 404 | qris_id doesn’t exist, is soft-deleted, or was registered in the other mode (live/sandbox) |
TRANSACTION_NOT_FOUND | 404 | history_id doesn’t exist for this tenant |
INVALID_QRIS_FORMAT | 400 | Uploaded/decoded QRIS fails EMV structural validation |
PAYMENT_EXPIRED | 409 | simulate-payment on an expired transaction without allow_payment_after_expiry |
ALREADY_FINALIZED | 409 | simulate-payment on a transaction that’s no longer unpaid (or lost a concurrent race) |
RATE_LIMIT_EXCEEDED | 429 | Over the per-route rate limit |
PAYLOAD_TOO_LARGE | 413 | Request body over the size cap |
INSUFFICIENT_BALANCE | 402 | generate: the tenant’s wallet balance can’t cover this SKU’s price — top up and retry |
BILLING_UNAVAILABLE | 503 | generate: the billing service couldn’t be reached or is misconfigured — no charge was made, retry |
DAILY_LIMIT_EXCEEDED | 429 | generate (live only): today’s generate count has reached the tenant’s daily cap — see Add-ons |
INTERNAL_ERROR | 500 | Unhandled 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" }Rate limiting
Section titled “Rate limiting”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.
Body limits
Section titled “Body limits”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.
Health check
Section titled “Health check”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 →