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 500 server-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.