Payments
The payments API is the core of the Hexolus gateway. You create a payment
intent for a fixed IDR amount and a fixed payment method; Hexolus dispatches
the request upstream to Xendit and returns a payment_destination that you
present to the customer (a VA number, a QR string, or an e-wallet checkout
URL). When the customer pays, Hexolus delivers a
payment.succeeded webhook and credits your
balance.
All endpoints require bearer authentication and are
scoped to the authenticated client. Cross-tenant ID access returns 404.
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/payments |
Create a payment. |
| GET | /v1/payments |
List payments. |
| GET | /v1/payments/{id} |
Fetch one payment. |
| POST | /v1/payments/{id}/cancel |
Cancel a pending payment. |
Create a payment
Request
POST /v1/payments
Authorization: Bearer hxk_*
Content-Type: application/json
Idempotency-Key: <your-unique-key> (optional but recommended)
| Field | Type | Required | Description |
|---|---|---|---|
method |
string enum | yes | virtual_account, ewallet, or qris. |
channel_code |
string enum | yes for VA + e-wallet | See supported channels. Omitted for QRIS. |
amount |
integer | yes | Gross amount the customer pays, in IDR minor units. Must be > 0. |
currency |
string | yes | IDR (only currency supported at MVP). |
external_reference |
string | optional | Your own order/invoice ID. Echoed back unchanged. Not validated for uniqueness. |
customer |
object | optional (req. for OVO) | { name, email, phone }. OVO requires phone in E.164 format (+62…). |
description |
string | optional | Free-form note. Surfaces in admin UI and stored on the row. |
metadata |
object | optional | Arbitrary JSON-serialisable key/value bag. Round-tripped on subsequent GETs. |
expires_in_seconds |
integer | optional | Custom expiry from now. Omit (or pass 0) to use the Xendit per-method default. |
The request body uses strict JSON: unknown fields (e.g. a typo'd
"ammount") are rejected with 422 validation rather than silently dropped.
Idempotency
Pass any unique string in the Idempotency-Key header. If you retry an
otherwise-identical POST with the same key, Hexolus returns the original
payment record without dispatching a second Xendit call. The key must be
unique within your client account; reuse across distinct orders will
return the wrong payment record. Recommended pattern:
Idempotency-Key: order-{your_order_id}-{utc_date}
The key is optional. Without it, every retry creates a new payment intent — which means a duplicate transfer if the customer ends up with two VA numbers. Always send it.
Supported channels
method |
channel_code values |
Xendit tariff | Hexolus markup |
|---|---|---|---|
virtual_account |
BCA, BNI, BRI, MANDIRI, PERMATA |
Rp 4.000 flat per paid transaction | 0.1% of amount |
ewallet |
OVO |
2.0% of amount |
0.1% of amount |
ewallet |
DANA |
1.5% of amount + Rp 500 |
0.1% of amount |
ewallet |
LINKAJA |
1.5% of amount |
0.1% of amount |
ewallet |
SHOPEEPAY |
2.0% of amount |
0.1% of amount |
ewallet |
GOPAY |
2.0% of amount |
0.1% of amount |
qris |
(none — leave unset) | 0.7% of amount |
0.1% of amount |
Unknown e-wallet channels fall back to
2.0% + Rp 500server-side. Stick to the documented enum to avoid surprise fees.
Tariffs are reproduced from Xendit's pricing page (Indonesia, IDR) as of
2026-05-30 and may change quarterly. The values returned in the response's
xendit_fee_minor field are authoritative.
Response — 201 Created
The response body is the payment DTO, the same shape returned by GET /v1/payments/{id}:
{
"id": "8e5e9b94-3f9c-4b6c-8e7a-9c9f3d3a7e21",
"client_id": "client_01HZX...",
"external_reference": "order-0001",
"method": "virtual_account",
"channel_code": "BCA",
"notional_minor": 50000,
"xendit_fee_minor": 4000,
"hexolus_markup_minor": 50,
"client_net_minor": 45950,
"currency": "IDR",
"status": "pending",
"payment_destination": "107669999416224",
"customer": { "name": "Budi Santoso", "email": "budi@example.com" },
"description": "Hosting renewal — premium-12mo",
"metadata": { "order_id": "ord_9981", "plan": "premium-12mo" },
"expires_at": "2026-06-02T00:00:00Z",
"created_at": "2026-05-30T14:32:11Z"
}
Fee fields
| Field | Meaning |
|---|---|
notional_minor |
What the customer pays. Equals the request's amount. |
xendit_fee_minor |
Xendit's listed tariff, computed by Hexolus at create time. |
hexolus_markup_minor |
Hexolus's 0.1% markup on the notional. Always notional * 10 / 10000. |
client_net_minor |
What lands in your balance once paid: notional − xendit_fee − hexolus_markup. |
The xendit_fee_minor value at create time is Hexolus's best estimate from
the published tariff. The webhook handler updates it to the actual fee
Xendit deducted when the payment.succeeded event arrives (the values are
usually identical, but Xendit occasionally adjusts for promotional pricing).
payment_destination semantics
The meaning of payment_destination depends on method:
method |
payment_destination content |
|---|---|
virtual_account |
Bank VA number (e.g. "107669999416224"). BCA is 15 digits, BNI is 16 digits, etc. |
qris |
EMVCo QR string (~300 chars in production; placeholder in staging). Render as QR image. |
ewallet DANA / LINKAJA |
Checkout URL — redirect the customer's browser to this URL. |
ewallet OVO |
Empty string — push-flow. The customer receives an OVO push notification on the phone you supplied. |
Examples
BCA Virtual Account, Rp 50.000
curl -sS -X POST https://api-staging.hexolus.com/v1/payments \
-H "Authorization: Bearer $HEXOLUS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-2026-05-30-0001" \
-d '{
"method": "virtual_account",
"channel_code": "BCA",
"amount": 50000,
"currency": "IDR",
"external_reference": "order-0001",
"customer": { "name": "Budi Santoso" },
"description": "Hosting renewal"
}'
{
"id": "8e5e9b94-3f9c-4b6c-8e7a-9c9f3d3a7e21",
"client_id": "client_01HZX...",
"external_reference": "order-0001",
"method": "virtual_account",
"channel_code": "BCA",
"notional_minor": 50000,
"xendit_fee_minor": 4000,
"hexolus_markup_minor": 50,
"client_net_minor": 45950,
"currency": "IDR",
"status": "pending",
"payment_destination": "107669999416224",
"customer": { "name": "Budi Santoso" },
"description": "Hosting renewal",
"created_at": "2026-05-30T14:32:11Z"
}
QRIS, Rp 100.000
curl -sS -X POST https://api-staging.hexolus.com/v1/payments \
-H "Authorization: Bearer $HEXOLUS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-2026-05-30-0002" \
-d '{
"method": "qris",
"amount": 100000,
"currency": "IDR",
"external_reference": "order-0002",
"expires_in_seconds": 900
}'
{
"id": "0c2c2d62-9e3a-4cf1-9c89-77c4a1e6c4b2",
"client_id": "client_01HZX...",
"external_reference": "order-0002",
"method": "qris",
"notional_minor": 100000,
"xendit_fee_minor": 700,
"hexolus_markup_minor": 100,
"client_net_minor": 99200,
"currency": "IDR",
"status": "pending",
"payment_destination": "00020101021226660014ID.LINKAJA.WWW01189360091400123456789...",
"expires_at": "2026-05-30T14:47:11Z",
"created_at": "2026-05-30T14:32:11Z"
}
E-Wallet DANA, Rp 75.000
curl -sS -X POST https://api-staging.hexolus.com/v1/payments \
-H "Authorization: Bearer $HEXOLUS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-2026-05-30-0003" \
-d '{
"method": "ewallet",
"channel_code": "DANA",
"amount": 75000,
"currency": "IDR",
"external_reference": "order-0003",
"customer": { "name": "Siti Rahayu", "email": "siti@example.com" }
}'
{
"id": "5d8a9f01-2b13-4f3b-a72e-c19c6d8a44a8",
"client_id": "client_01HZX...",
"external_reference": "order-0003",
"method": "ewallet",
"channel_code": "DANA",
"notional_minor": 75000,
"xendit_fee_minor": 1625,
"hexolus_markup_minor": 75,
"client_net_minor": 73300,
"currency": "IDR",
"status": "pending",
"payment_destination": "https://checkout-redirect.xendit.co/...",
"customer": { "name": "Siti Rahayu", "email": "siti@example.com" },
"created_at": "2026-05-30T14:32:11Z"
}
The xendit_fee_minor for the DANA example is 75000 * 0.015 + 500 = 1625.
Common errors
| HTTP | code |
message (sample) |
Notes |
|---|---|---|---|
| 401 | auth |
invalid credentials |
Bad / missing / expired bearer. See Authentication. |
| 422 | validation |
notional_minor must be > 0 |
amount <= 0. |
| 422 | validation |
unsupported method: foo |
method is not one of virtual_account / ewallet / qris. |
| 422 | validation |
channel_code required for method virtual_account |
VA + e-wallet require channel_code. |
| 422 | validation |
invalid JSON body: json: unknown field "ammount" |
Typo in field name (strict JSON decoding). |
| 422 | validation |
metadata is not JSON-serialisable: ... |
The supplied metadata failed to marshal. |
| 500 | internal_error |
(varies) | Upstream Xendit failure or DB error. Retry with the same Idempotency-Key. |
List payments
Request
GET /v1/payments?status=pending&page=1&per_page=25
Authorization: Bearer hxk_*
| Query param | Default | Notes |
|---|---|---|
status |
all | Filter by pending, succeeded, failed, expired, cancelled. |
page |
1 |
1-indexed. Values < 1 are clamped to 1. |
per_page |
25 |
Clamped to [1, 100]. |
Response — 200 OK
{
"data": [
{
"id": "8e5e9b94-3f9c-4b6c-8e7a-9c9f3d3a7e21",
"client_id": "client_01HZX...",
"method": "virtual_account",
"channel_code": "BCA",
"notional_minor": 50000,
"xendit_fee_minor": 4000,
"hexolus_markup_minor": 50,
"client_net_minor": 45950,
"currency": "IDR",
"status": "pending",
"payment_destination": "107669999416224",
"created_at": "2026-05-30T14:32:11Z"
}
],
"pagination": {
"page": 1,
"per_page": 25,
"total": 1,
"total_pages": 1
}
}
data is always an array (never null), even when empty. total_pages is
computed as ceil(total / per_page); at total == 0 it is 0, not 1.
Common errors
| HTTP | code |
When |
|---|---|---|
| 401 | auth |
Missing / invalid bearer. |
| 500 | internal_error |
Database read failed. |
Fetch a payment
Request
GET /v1/payments/{id}
Authorization: Bearer hxk_*
Response — 200 OK
Returns the payment DTO for {id} scoped to the
authenticated client. The body shape is identical to the POST /v1/payments
response, plus paid_at once the payment has succeeded.
Common errors
| HTTP | code |
When |
|---|---|---|
| 401 | auth |
Missing / invalid bearer. |
| 404 | not_found |
{id} doesn't exist OR belongs to a different client. Indistinguishable on purpose. |
Cancel a payment
Cancellation is only valid while the payment is pending. Once Xendit has
confirmed payment (status succeeded), or after the payment has expired /
failed, the cancel call returns 422 validation.
Request
POST /v1/payments/{id}/cancel
Authorization: Bearer hxk_*
Response — 200 OK
Returns the updated payment DTO with
"status": "cancelled".
Common errors
| HTTP | code |
message (sample) |
Notes |
|---|---|---|---|
| 401 | auth |
invalid credentials |
Bad bearer. |
| 404 | not_found |
payment not found |
Wrong ID or cross-tenant. |
| 422 | validation |
payment cannot be cancelled in status=succeeded |
Payment already left the pending state. |
Status lifecycle
pending ──► succeeded (webhook payment.succeeded)
├─► failed (webhook payment.failed)
├─► expired (Xendit-side TTL)
└─► cancelled (POST /v1/payments/{id}/cancel)
succeeded, failed, expired, and cancelled are terminal — no further
transitions occur. All four are surfaced as distinct
webhook events.