Errors
Every non-2xx response from the Hexolus Payment Gateway shares one envelope:
{
"message": "human-readable description",
"code": "machine-readable enum"
}
The envelope may also carry these optional fields:
| Field | When present |
|---|---|
field_errors |
On 422 validation errors emitted by form-style endpoints; map of field โ [reason]. |
action_hint |
On a small set of operator-facing errors; human remediation copy for the admin UI. |
This page documents:
- The error envelope
- Error code reference
- HTTP status mapping
- Idempotency conflicts
- Debugging 422 validation errors
The error envelope
Always parse code (machine) first; the message field is meant for human
display and may evolve. The Content-Type is always application/json for
errors emitted by Hexolus's own code; an upstream load balancer or CDN
returning a 502 may yield HTML, so check Content-Type defensively.
{
"message": "notional_minor must be > 0",
"code": "validation"
}
Error code reference
code |
HTTP status | Retryable? | Meaning |
|---|---|---|---|
auth |
401 | no | Missing, malformed, unknown, or expired bearer token. See Authentication. |
forbidden |
403 | no | Authenticated but the action is not permitted for your client. Rare on the public API. |
not_found |
404 | no | The resource ID does not exist OR belongs to another client. (See tenancy note.) |
conflict |
409 | depends | A uniqueness constraint was violated. Idempotency-key collisions are NOT this; see below. |
validation |
422 | no | The request body or query failed validation. Look at message and field_errors. |
rate_limit |
429 | yes | You exceeded a rate cap. Honour Retry-After if present. |
network |
502 | yes | A transient network failure reaching Xendit. Retry with backoff. |
server_error |
502 | yes | Xendit returned a 5xx. Retry with backoff. |
internal_error |
500 | maybe | Unexpected Hexolus-side failure. Retry once with the same Idempotency-Key; if it persists, contact support. |
Codes not listed above are present in Hexolus's internal taxonomy
(ip_not_allowed, domain_taken, transfer_locked, auth_code_invalid,
insufficient_balance, ineligible_registrant) but are emitted only by
internal domain / billing flows and will not appear on the payment gateway
public API. If you ever do see one, treat it as an internal_error.
HTTP status mapping
The mapping is deterministic and exhaustive:
| HTTP status | Possible code values |
|---|---|
401 |
auth |
403 |
forbidden |
404 |
not_found |
409 |
conflict |
422 |
validation (most common), auth_code_invalid |
429 |
rate_limit |
500 |
internal_error |
502 |
network, server_error |
If you write a generic error handler, switching on code is more
forward-compatible than switching on HTTP status.
Tenancy and 404
Hexolus deliberately does not distinguish "this resource does not exist"
from "this resource exists but belongs to another tenant". Both surface as
404 not_found. This prevents enumeration attacks where a caller could
otherwise learn which IDs are in use across the platform by counting 403s
vs 404s.
This applies to:
If you are confident the ID was minted by your own client and you receive a 404, the most likely cause is a typo or a misrouted environment (production ID against staging or vice versa).
Idempotency conflicts
The Idempotency-Key header on POST /v1/payments
is not a uniqueness constraint that returns 409 conflict. Instead:
- A second POST with the same key and an otherwise-identical body is
treated as a retry and returns the original payment (HTTP
200or201). - A second POST with the same key but different body fields still returns the original payment โ Hexolus does not compare bodies. This means a buggy client that reuses the key across distinct orders will return the wrong record without erroring.
The practical implication: choose Idempotency-Key values that are
provably unique per logical request (e.g. order-{your_uuid},
order-{date}-{seq}). If your key generator might collide, you will
silently receive someone else's payment instead of a clean 409.
If you genuinely need to detect a collision client-side, store the
(idempotency_key, payment_id) pair locally and reject any reuse before
sending the request.
Debugging 422 validation errors
A 422 validation response always includes a message that names the
failed rule:
{
"message": "channel_code required for method virtual_account",
"code": "validation"
}
For form-style endpoints (admin and a subset of client API surfaces), an
additional field_errors block points at the offending field(s):
{
"message": "request body is invalid",
"code": "validation",
"field_errors": {
"amount": ["must be > 0"],
"channel_code": ["unsupported value: \"BCASYARIAH\""]
}
}
Common 422 causes on the payments API:
message |
Fix |
|---|---|
notional_minor must be > 0 |
Send a positive integer in amount. |
unsupported method: <x> |
Use virtual_account, ewallet, or qris. |
channel_code required for method <x> |
VA and e-wallet require channel_code. QRIS does not. |
invalid JSON body: ... |
The body did not parse as JSON, or contained an unknown field (strict mode). |
metadata is not JSON-serialisable: ... |
The supplied metadata had a non-JSON value (e.g. NaN). |
payment cannot be cancelled in status=<x> |
Cancel is only valid while status == "pending". |
For anything still unclear, copy the full response (including the
X-Hexolus-Delivery-Id header if from a webhook) and contact support โ
Hexolus retains request logs for 14 days.