HIGH TABLEPay

API reference

Everything your bot needs to take QRIS payments: create an invoice, show its QR, and hear when it is paid.

Overview

The base URL is your HIGH TABLE Pay address followed by /api/v1. Requests and responses are JSON. Amounts are whole Rupiah.

An invoice moves through these states:

  • pending: waiting for the buyer. It stays payable until payable_until, a few minutes after expires_at, so a buyer who pays at the last second is still accepted.
  • paid: the exact total_amount arrived in your GoPay Merchant.
  • expired or cancelled: closed without payment.

paid, expired and cancelled are final. An invoice never changes after that.

Before your first invoice, sign in to the dashboard, connect your GoPay Merchant, top up a little credit for the platform fee and create an API key.

Authentication

Create a key in Dashboard, API keys. It looks like ht_live_<id>_<secret> and is shown only once. The simplest way to use it is a bearer token:

Authorization: Bearer ht_live_Ab12Cd34Ef56_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

For production we recommend signed requests, where the secret never leaves your server. You can make a key accept only signed requests in the dashboard.

Signed requests

Send these four headers instead of Authorization:

HeaderValue
X-HT-KeyThe public part of your key: ht_live_<id>
X-HT-TimestampCurrent Unix time in seconds. Requests more than 5 minutes off are refused.
X-HT-Nonce16 to 64 letters, numbers, - or _. Every nonce works once.
X-HT-SignatureHex HMAC-SHA256 of the string below, keyed with your secret.

The signed string is five lines joined with \n: the timestamp, the nonce, the HTTP method in capitals, the path with its query string, and the hex SHA-256 of the raw body (of an empty string for GET).

1762760461
3f0b8c2e-6d1a-4e2b-9f71-0c9d2b7a5e44
POST
/api/v1/invoices
5e8a1d...c09f   <- sha256 of {"amount":25000,"reference":"ORDER-1042"}
const canonical = [ts, nonce, method, pathWithQuery, sha256Hex(rawBody)].join('\n');
const signature = crypto.createHmac('sha256', secret).update(canonical).digest('hex');

Sign the exact bytes you send. If you serialize the JSON again after signing, the signature no longer matches.

Idempotency

Add Idempotency-Key (8 to 100 characters) to any POST. If the network drops and you retry with the same key and the same body, you get the first response back with the header Idempotent-Replayed: true. Nothing is created twice.

  • Same key, different body: 422 idempotency_key_reused.
  • Same key while the first request is still running: 409 idempotency_in_progress.
  • Keys are remembered for 24 hours.

The reference field adds a second safety net: a pending invoice with the same reference and amount is returned instead of a new one.

Create an invoice

POST/api/v1/invoices

FieldTypeDescription
amountintegerRequired. Price in Rupiah, at least 1000.
referencestringYour order ID. Letters, numbers, . _ : -, up to 64. Unique in your account.
ttl_minutesinteger1 to 120. Default 15.
metadataobjectAnything you want back in webhooks, up to 2000 characters.
callback_urlstringOverrides your account webhook URL for this invoice. Public https addresses only.
{
  "success": true,
  "data": {
    "id": "INVMWBC8Q2F1A7E94B6D03C52",
    "reference": "ORDER-1042",
    "amount": 25000,
    "unique_code": 125,
    "total_amount": 25125,
    "status": "pending",
    "qris": "000201010211...",
    "qr_image_url": "https://.../api/v1/invoices/INV.../qr.png",
    "expires_at": "2026-11-10T08:15:00.000Z",
    "payable_until": "2026-11-10T08:18:00.000Z",
    "platform_fee": 125
  }
}

Show qris as a QR code, or use qr_image_url directly as an image. Tell the buyer to pay exactly total_amount; the QR already contains it. unique_code makes the total unique, and on accounts with a platform fee it is the fee: 0.5% of the price by default (a rupiah or two more while the same price is already open). Your buyer pays it, it arrives in your GoPay Merchant with the price, and the same amount comes off your credit when the invoice is paid, so you keep the full price.

The platform fee is reserved from your credit when the invoice is created and only taken when it is paid. With too little credit you get 402 insufficient_credit.

Get an invoice

GET/api/v1/invoices/{id or reference}

Paid invoices include payment with the paying bank or e-wallet (issuer), the QRIS reference number (rrn) and the transaction time.

Wait for payment

GET/api/v1/invoices/{id}/wait?timeout=25

Answers as soon as the invoice is paid, expired or cancelled, or after timeout seconds (at most 30) with "changed": false. Call it again in a loop: your bot hears about a payment within moments, without opening a port for webhooks.

POST/api/v1/invoices/{id}/check

Asks for a payment check right away, for example when a buyer presses "I paid". Checks still follow the per-account limits.

Cancel

POST/api/v1/invoices/{id}/cancel

Only pending invoices can be cancelled. The reserved platform fee returns to your credit. A payment that still arrives later is listed as an unmatched payment.

List invoices

GET/api/v1/invoices?status=paid&limit=50&before=…

Newest first. Pass next_before from the response as before to get the next page.

Webhooks

Set your webhook URL in Dashboard, Webhooks and copy the signing secret (whsec_…). We send invoice.paid, invoice.expired and invoice.cancelled.

HeaderMeaning
X-Gateway-EventEvent name
X-Gateway-Event-IdUnique per event. Retries keep the same ID, so process each ID once.
X-Gateway-TimestampUnix seconds when the request was signed
X-Gateway-SignatureHex HMAC-SHA256 of timestamp + "." + raw body with your webhook secret
  1. Recompute the signature from the raw body and compare it in constant time.
  2. Refuse timestamps older than 5 minutes.
  3. Answer with any 2xx within 10 seconds. Anything else is retried up to 7 times over about two hours.

Redirects are not followed, and webhook URLs that point into a private network are refused.

Unmatched payments

GET/api/v1/payments/unmatched

Money that arrived but matched no pending invoice: a wrong amount, a second payment for a paid invoice, or a payment after the invoice closed. The money is in your GoPay Merchant; decide whether to credit or refund the buyer.

Errors

Errors look like {"success": false, "code": "...", "message": "...", "request_id": "..."}. Quote the request_id when you ask for help.

StatusCodeWhat to do
400invalid_amount, invalid_reference, …Fix the request.
401unauthorized, invalid_signature, stale_request, replayed_requestCheck the key, your clock and the signed string. Use a new nonce for every request.
402insufficient_creditTop up credit in the dashboard.
404not_foundThe invoice does not exist in your account.
409provider_not_connected, duplicate_reference, not_cancellableRead the message: usually connect GoPay or use a new reference.
422idempotency_key_reusedUse a new Idempotency-Key for a different request.
429rate_limitedWait for Retry-After seconds.
503unique_code_exhausted, too_many_pendingRetry shortly.

Rate limits

Limits count per API key, per account and per address at the same time, so spreading requests over many IP addresses does not raise them. When you hit one you get 429 with Retry-After.

  • About 300 requests a minute per key, and 30 new invoices a minute per account.
  • Up to 200 open wait requests per account.
  • Repeated failed authentication from one address blocks it for 30 minutes.

Testing

Create a small real invoice (Rp 1.000) and pay it from any QRIS app. It lands in your own GoPay Merchant, so a test costs only the platform fee. You can also use Test payment on the Invoices page of your dashboard.