ChaosPay API Documentation
Accept QRIS payments, track their status, check your balance and send payouts to your registered bank accounts with a JSON REST API. Every endpoint answers in the same envelope and every notification we send you is signed.
Base URL
All API requests are made over HTTPS to:
https://api.labubu4d.lol
Requests use Content-Type: application/json for POST bodies and query parameters for GET. Amounts are whole Indonesian Rupiah (IDR) integers. Times are in Asia/Jakarta (UTC+7).
Quick Start
- Get your credentials. Sign in to the merchant dashboard and open 2FA & API. You will find your Merchant Identifier and API Key. The Secret Key is shown only once, when it is issued or rotated, so store it safely then.
- Set your callback URL. In Callback Settings, enter the HTTPS URL that should receive payment notifications (and, if you use payouts, the withdrawal callback URL).
- Whitelist your server IPs (recommended) in the same place.
- Create a payment with
POST /payment, show the returned QR code to your customer and wait for the callback. - Test end to end in the sandbox before going live.
# client_signature = md5(username + amount + secret_key)
curl -X POST https://api.labubu4d.lol/payment \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_API_KEY" \
-d '{
"merchant": "YOUR_MERCHANT_ID",
"username": "customer001",
"amount": 50000,
"order_id": "INV-10023",
"client_signature": "3f2a9c0e7b1d4a8e9f6c5b2a1d0e9f8c"
}'
Sandbox
Each merchant can also issue a sandbox API key and secret from 2FA & API. Use them against the same base URL:
- Payments created with the sandbox key are never sent to a bank or provider, never touch your real balance, and come back with
"sandbox": true. - On the Sandbox page you can mark a sandbox payment as paid or failed. That sends a real callback to your payment callback URL, signed with your sandbox secret, so you can test your handler.
- The IP whitelist is not enforced for sandbox keys.
- Payouts and bank account listing are not available in the sandbox (
NOT_AVAILABLE_IN_SANDBOX). Balance returns zeros.
Rate Limiting
Limits are applied per API key, per minute. Over the limit, the API answers HTTP 429 with a Retry-After header (seconds).
| Endpoint | Limit |
|---|---|
POST /payment | 120 requests / minute |
GET /payment/status, GET /account/balance | 300 requests / minute |
POST /payout, GET /payout/status, GET /account/banks | 30 requests / minute (combined) |
These are the defaults. Every limit on this page (payment and payout amounts, daily limits, rate limits, callback attempts and timeout) can be set per account; yours are listed in the dashboard under My Account.
Prefer callbacks over polling. If you do poll a payment status, an interval of a few seconds is plenty.
Authentication
Every request identifies your account with the API key in a header and your merchant identifier in the request (JSON body for POST, query string for GET).
| Header | Required | Description |
|---|---|---|
X-API-KEY | Required | Your API key (or sandbox API key). Never send it in the URL. |
Content-Type | POST | application/json |
X-Timestamp | v2 | Unix time in seconds. Must be within 5 minutes of our clock. |
X-Nonce | v2 | A random string, 8โ64 characters of A-Z a-z 0-9 _ -, never reused. |
X-Signature | v2 | Lower-case hex HMAC-SHA256, see Request Signature v2. |
Your secret key is used to sign requests and to verify our callbacks. It is never sent in a request. If you think it has leaked, rotate your keys from 2FA & API: a new API key and secret key are issued together and the old pair stops working immediately.
Client Signature (Create Payment)
POST /payment requests that are not signed with v2 must include client_signature:
client_signature = md5(username + amount + secret_key)
The three values are concatenated as text with no separator, and the result is 32 lower-case hex characters. For username = "customer001", amount = 50000 and secret "sec", the input is customer00150000sec.
Request Signature v2
Signature v2 signs the whole request and protects it against replay. It is required for every payout endpoint (/payout, /payout/status, /account/banks) and accepted on every other endpoint. When a request carries a valid v2 signature, client_signature is not needed. Your account can also be set to require v2 everywhere.
METHOD + "\n" + PATH + "\n" + X-Timestamp + "\n" + X-Nonce + "\n" + sha256_hex(PAYLOAD)
X-Signature = hmac_sha256_hex(string_to_sign, secret_key)
| Part | Value |
|---|---|
METHOD | Upper case, e.g. POST |
PATH | The path only, without host or query, e.g. /payout |
PAYLOAD | POST: the raw request body, byte for byte. GET: the raw query string without the leading ? (empty string if none). |
<?php
// ChaosPay request signature v2
function chaospaySign(string $method, string $path, string $payload, string $secretKey): array
{
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(8));
$stringToSign = implode("\n", [strtoupper($method), $path, $timestamp, $nonce, hash('sha256', $payload)]);
return [
'X-Timestamp' => $timestamp,
'X-Nonce' => $nonce,
'X-Signature' => hash_hmac('sha256', $stringToSign, $secretKey),
];
}
// ChaosPay request signature v2 (Node.js 18+)
const crypto = require('crypto');
function chaospaySign(method, path, payload, secretKey) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(8).toString('hex');
const bodyHash = crypto.createHash('sha256').update(payload, 'utf8').digest('hex');
const stringToSign = [method.toUpperCase(), path, timestamp, nonce, bodyHash].join('\n');
return {
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': crypto.createHmac('sha256', secretKey).update(stringToSign, 'utf8').digest('hex'),
};
}
# ChaosPay request signature v2
import hashlib, hmac, secrets, time
def chaospay_sign(method: str, path: str, payload: str, secret_key: str) -> dict:
timestamp = str(int(time.time()))
nonce = secrets.token_hex(8)
body_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
string_to_sign = "\n".join([method.upper(), path, timestamp, nonce, body_hash])
return {
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": hmac.new(secret_key.encode(), string_to_sign.encode(), hashlib.sha256).hexdigest(),
}
- Signing one JSON string and sending another (for example, letting your HTTP client re-serialise the body). Send exactly the string you hashed.
- Building the query string twice. Build it once, sign it, and append that same string to the URL.
- Reusing a nonce: each nonce is accepted once (
NONCE_REUSED). A clock more than 5 minutes off givesREQUEST_EXPIRED.
IP Whitelist
In Callback Settings you can list the server IPs allowed to call the API with your live key, separated by |. When the list is empty, any IP is accepted. A request from another IP is refused with IP_NOT_WHITELISTED. We strongly recommend setting it before enabling payouts.
Response Format
Every response, success or failure, is a JSON envelope:
| Field | Type | Description |
|---|---|---|
code | integer | 200 on success, otherwise the error status |
message | string | OK, or a machine-readable error code |
data | object | array | null | The result; usually null on error |
{
"code": 200,
"message": "OK",
"data": { }
}
{
"code": 403,
"message": "IP_NOT_WHITELISTED",
"data": null
}
Branch on code and message, not on the HTTP status line alone. When an upstream provider fails, the envelope says 502 while the HTTP status is 503.
Error Codes
| HTTP | message | Meaning |
|---|---|---|
| 400 | INVALID_PARAMETER | A parameter is missing or has the wrong format. |
| 401 | INVALID_SIGNATURE | The v2 signature or its headers are malformed or do not match. |
| 401 | REQUEST_EXPIRED | X-Timestamp is more than 5 minutes from our clock. |
| 401 | NONCE_REUSED | This X-Nonce was already used. |
| 401 | SIGNATURE_V2_REQUIRED | This endpoint (or your account) requires a v2 signature. |
| 403 | API_KEY_REQUIRED | The X-API-KEY header is missing. |
| 403 | INVALID_API_KEY_OR_MERCHANT | The key and merchant identifier do not match an active account. |
| 403 | IP_NOT_WHITELISTED | The calling IP is not in your whitelist. |
| 403 | INVALID_CLIENT_SIGNATURE | client_signature does not match. |
| 403 | UNAUTHORIZED | The request could not be tied to your account. |
| 403 | API_PAYOUTS_DISABLED | Payouts by API are not enabled for your account. Contact us to enable them. |
| 403 | NOT_AVAILABLE_IN_SANDBOX | The endpoint does not work with sandbox keys. |
| 404 | TRANSACTION_NOT_FOUND | No payment with that transaction_id / order_id on your account. |
| 404 | PAYOUT_NOT_FOUND | No payout with that payout_id / withdraw_id on your account. |
| 404 | NOT_FOUND | Unknown endpoint. |
| 405 | METHOD_NOT_ALLOWED | Wrong HTTP method for this endpoint. |
| 409 | DUPLICATE_PAYOUT_ID | The payout_id was already used with a different bank account or amount. |
| 422 | INVALID_BANK | bank_id is not one of your registered bank accounts. |
| 422 | AMOUNT_BELOW_MINIMUM | The payout is below the minimum; data.minimum gives it. |
| 422 | AMOUNT_ABOVE_MAXIMUM | The payout is above your maximum per payout; data.maximum gives it. |
| 422 | DAILY_LIMIT_EXCEEDED | The daily payment volume, payout volume or payout count set for your account is reached. Create Payment returns data.limit and data.used. |
| 422 | INSUFFICIENT_BALANCE | Your available balance does not cover the payout amount plus its fee. |
| 429 | TOO_MANY_REQUESTS | Rate limit exceeded. Wait for Retry-After seconds. |
| 500 | SERVICE_ERROR | Unexpected error on our side. Check the payment status by order_id before retrying a payment; a payout is safe to retry with the same payout_id. |
| 503 (code 502) | GATEWAY_UNREACHABLE, INVALID_PROVIDER_RESPONSE, INVALID_SIGNATURE | The payment provider could not be reached, or its answer was malformed or failed our signature check. A retry opens a new payment. |
| 503 | PROVIDER_NOT_CONFIGURED | No payment channel is available for your account right now. |
When the payment provider rejects a request, its own code and message are passed through in the envelope, for example {"code": 400, "message": "Amount range is from IDR 10,000 to 10,000,000", "data": null}.
Create Payment
/paymentCreates a QRIS payment and returns the QR payload to show your customer. Payments expire at expire_date; the final result is delivered to your payment callback.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
merchant | string | Required | Your merchant identifier |
username | string | Required | Your customer's username or ID (max 191) |
amount | integer | Required | Amount in IDR. Default range 10,000 โ 10,000,000; your account may have its own. |
client_signature | string | Required* | md5(username + amount + secret_key). *Not needed when the request is signed with v2. |
order_id | string | Optional | Your own reference, max 64, A-Z a-z 0-9 . _ : -. Returned in the response and the callback, and usable to look the payment up. |
email | string | Optional | Payer email |
mobile | string | Optional | Payer phone, 6โ20 digits, optional leading + |
account_name | string | Optional | Payer name (max 100) |
account_no | string | Optional | Payer account number, 3โ32 digits |
bank_code | string | Optional | Payer bank code, 2โ32 characters |
Every request opens a new payment, even with an order_id you already used. After a timeout, look the payment up by order_id before sending the request again, or the customer gets a second QR. A status lookup by an order_id used more than once returns the newest payment.
Example Request
# client_signature = md5(username + amount + secret_key)
curl -X POST https://api.labubu4d.lol/payment \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_API_KEY" \
-d '{
"merchant": "YOUR_MERCHANT_ID",
"username": "customer001",
"amount": 50000,
"order_id": "INV-10023",
"client_signature": "3f2a9c0e7b1d4a8e9f6c5b2a1d0e9f8c"
}'
<?php
$apiKey = 'YOUR_API_KEY';
$secretKey = 'YOUR_SECRET_KEY';
$body = [
'merchant' => 'YOUR_MERCHANT_ID',
'username' => 'customer001',
'amount' => 50000,
'order_id' => 'INV-10023',
];
$body['client_signature'] = md5($body['username'] . $body['amount'] . $secretKey);
$ch = curl_init('https://api.labubu4d.lol/payment');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-KEY: ' . $apiKey],
]);
$response = json_decode(curl_exec($ch), true);
if ($response['code'] === 200) {
// Show $response['data']['qr'] as a QR code, or redirect to $response['data']['payment_url']
}
// Node.js 18+
const crypto = require('crypto');
(async () => {
const apiKey = 'YOUR_API_KEY';
const secretKey = 'YOUR_SECRET_KEY';
const body = { merchant: 'YOUR_MERCHANT_ID', username: 'customer001', amount: 50000, order_id: 'INV-10023' };
body.client_signature = crypto.createHash('md5').update(body.username + body.amount + secretKey).digest('hex');
const res = await fetch('https://api.labubu4d.lol/payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-KEY': apiKey },
body: JSON.stringify(body),
});
const { code, message, data } = await res.json();
// code === 200 โ show data.qr as a QR code, or redirect to data.payment_url
})();
import hashlib, requests
api_key, secret_key = "YOUR_API_KEY", "YOUR_SECRET_KEY"
body = {"merchant": "YOUR_MERCHANT_ID", "username": "customer001", "amount": 50000, "order_id": "INV-10023"}
body["client_signature"] = hashlib.md5(f'{body["username"]}{body["amount"]}{secret_key}'.encode()).hexdigest()
r = requests.post("https://api.labubu4d.lol/payment", json=body, headers={"X-API-KEY": api_key}, timeout=30)
resp = r.json()
# resp["code"] == 200 โ show resp["data"]["qr"] as a QR code, or redirect to resp["data"]["payment_url"]
Response
{
"code": 200,
"message": "OK",
"data": {
"transaction_id": "TRX2610211430151234",
"order_id": "INV-10023",
"amount": 50000,
"create_date": "2026-10-21 14:30:15",
"expire_date": "2026-10-21 14:40:15",
"status": 0,
"qr": "00020101021226...6304ABCD",
"payment_url": null,
"sandbox": false
}
}
| Field | Type | Description |
|---|---|---|
transaction_id | string | Our payment reference. Keep it with your order. |
order_id | string | null | Your reference, as sent |
amount | integer | Amount in IDR |
create_date | string | Y-m-d H:i:s, Asia/Jakarta |
expire_date | string | After this time the QR can no longer be paid |
status | integer | 0 on creation, see status codes |
qr | string | QRIS payload. Render it as a QR code for the customer to scan. |
payment_url | string | null | A hosted payment page, when the channel provides one (https only) |
sandbox | boolean | true when created with a sandbox key |
Payment Status
/payment/statusReturns the current state of one of your payments. Use it to reconcile, or if a callback did not reach you.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
merchant | string | Required | Your merchant identifier |
transaction_id | string | One of | Our reference from Create Payment |
order_id | string | One of | Your reference |
Example Request
curl -G https://api.labubu4d.lol/payment/status \
-H "X-API-KEY: YOUR_API_KEY" \
--data-urlencode "merchant=YOUR_MERCHANT_ID" \
--data-urlencode "order_id=INV-10023"
<?php
$query = http_build_query(['merchant' => 'YOUR_MERCHANT_ID', 'order_id' => 'INV-10023']);
$ch = curl_init('https://api.labubu4d.lol/payment/status?' . $query);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-API-KEY: YOUR_API_KEY'],
]);
$response = json_decode(curl_exec($ch), true);
if ($response['code'] === 200 && $response['data']['status'] === 1) {
// paid
}
// Node.js 18+
(async () => {
const query = new URLSearchParams({ merchant: 'YOUR_MERCHANT_ID', order_id: 'INV-10023' });
const res = await fetch(`https://api.labubu4d.lol/payment/status?${query}`, { headers: { 'X-API-KEY': 'YOUR_API_KEY' } });
const { code, data } = await res.json();
// code === 200 && data.status === 1 โ paid
})();
import requests
r = requests.get("https://api.labubu4d.lol/payment/status",
params={"merchant": "YOUR_MERCHANT_ID", "order_id": "INV-10023"},
headers={"X-API-KEY": "YOUR_API_KEY"}, timeout=30)
resp = r.json()
# resp["code"] == 200 and resp["data"]["status"] == 1 โ paid
Response
{
"code": 200,
"message": "OK",
"data": {
"merchant": "YOUR_MERCHANT_ID",
"transaction_id": "TRX2610211430151234",
"order_id": "INV-10023",
"idrpayment_id": "88231907",
"username": "customer001",
"amount": 50000,
"status": 1,
"local_status": "completed",
"remark": "",
"sandbox": false
}
}
| Field | Type | Description |
|---|---|---|
merchant | string | Your merchant identifier |
transaction_id | string | Our reference |
order_id | string | null | Your reference |
idrpayment_id | string | null | The payment provider's reference |
username | string | The customer username sent on creation |
amount | integer | Amount in IDR |
status | integer | 0, 1 or 2, see below |
local_status | string | pending, completed, failed or expired |
remark | string | Provider remark, if any |
sandbox | boolean | true for sandbox payments |
Payment Status Codes
| status | local_status | Meaning | Final |
|---|---|---|---|
0 | pending | Waiting for the customer to pay | No |
0 | expired | Not paid before expire_date | Yes |
1 | completed | Paid. Credit your customer. | Yes |
2 | failed | Failed or cancelled | Yes |
Only status = 1 means the money was received. Use local_status to tell a pending payment from an expired one.
Balance
/account/balanceReturns your current balance. Query parameter: merchant (required).
curl -G https://api.labubu4d.lol/account/balance \
-H "X-API-KEY: YOUR_API_KEY" \
--data-urlencode "merchant=YOUR_MERCHANT_ID"
{
"code": 200,
"message": "OK",
"data": {
"merchant": "YOUR_MERCHANT_ID",
"currency": "IDR",
"available": "12500000.00",
"pending_settlement": "1500000.00",
"settled_total": "84000000.00"
}
}
| Field | Type | Description |
|---|---|---|
currency | string | Always IDR |
available | string | Balance you can withdraw now (decimal string, 2 places) |
pending_settlement | string | Held for payouts that are not final yet |
settled_total | string | Total paid out to you so far |
Bank Accounts
/account/banksSignature v2Lists the bank accounts registered in your dashboard (Settlement Banks). Use the bank_id when creating a payout. Accounts can only be added from the dashboard. Query parameter: merchant (required). Sign it like any v2 GET, with PATH = /account/banks and PAYLOAD = merchant=YOUR_MERCHANT_ID.
{
"code": 200,
"message": "OK",
"data": [
{
"bank_id": 12,
"bank_name": "BCA",
"account_name": "PT CONTOH MERCHANT",
"account_number": "123****890",
"is_default": true
}
]
}
Create Payout
/payoutSignature v2Withdraws from your available balance to one of your registered bank accounts. The amount plus any withdraw fee set for your account is held immediately; the bank receives the full amount. The result is sent to your withdrawal callback URL. Payouts by API have to be enabled for your account by our team first.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
merchant | string | Required | Your merchant identifier |
payout_id | string | Required | Your unique reference, max 64, A-Z a-z 0-9 . _ : - |
bank_id | integer | Required | From Bank Accounts |
amount | integer | Required | Amount in IDR. Default minimum 10,000; your account may also have a maximum and daily limits. |
note | string | Optional | Free text, max 191 |
Idempotency. Repeating a payout_id with the same bank account and amount returns the existing payout; nothing is withdrawn twice. With different values it returns DUPLICATE_PAYOUT_ID.
Example Request
# POST requests signed with v2: the payload is the raw JSON body, byte for byte.
API_KEY='YOUR_API_KEY'; SECRET='YOUR_SECRET_KEY'
BODY='{"merchant":"YOUR_MERCHANT_ID","payout_id":"PO-2026-0001","bank_id":12,"amount":1500000,"note":"October settlement"}'
TS=$(date +%s); NONCE=$(openssl rand -hex 8)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIGNATURE=$(printf 'POST\n/payout\n%s\n%s\n%s' "$TS" "$NONCE" "$BODY_HASH" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST https://api.labubu4d.lol/payout \
-H "Content-Type: application/json" \
-H "X-API-KEY: $API_KEY" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" -H "X-Signature: $SIGNATURE" \
--data-raw "$BODY"
<?php
// Uses chaospaySign() from the Authentication section.
$payload = json_encode([
'merchant' => 'YOUR_MERCHANT_ID',
'payout_id' => 'PO-2026-0001',
'bank_id' => 12,
'amount' => 1500000,
'note' => 'October settlement',
]);
$headers = chaospaySign('POST', '/payout', $payload, 'YOUR_SECRET_KEY');
$ch = curl_init('https://api.labubu4d.lol/payout');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload, // send exactly the bytes you signed
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-KEY: YOUR_API_KEY',
'X-Timestamp: ' . $headers['X-Timestamp'],
'X-Nonce: ' . $headers['X-Nonce'],
'X-Signature: ' . $headers['X-Signature'],
],
]);
$response = json_decode(curl_exec($ch), true);
// Uses chaospaySign() from the Authentication section.
(async () => {
const payload = JSON.stringify({
merchant: 'YOUR_MERCHANT_ID', payout_id: 'PO-2026-0001', bank_id: 12, amount: 1500000, note: 'October settlement',
});
const res = await fetch('https://api.labubu4d.lol/payout', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-KEY': 'YOUR_API_KEY', ...chaospaySign('POST', '/payout', payload, 'YOUR_SECRET_KEY') },
body: payload, // send exactly the string you signed
});
const result = await res.json();
})();
# Uses chaospay_sign() from the Authentication section.
import json, requests
payload = json.dumps({"merchant": "YOUR_MERCHANT_ID", "payout_id": "PO-2026-0001", "bank_id": 12,
"amount": 1500000, "note": "October settlement"})
headers = {"Content-Type": "application/json", "X-API-KEY": "YOUR_API_KEY",
**chaospay_sign("POST", "/payout", payload, "YOUR_SECRET_KEY")}
r = requests.post("https://api.labubu4d.lol/payout", data=payload, headers=headers, timeout=30) # send the exact string you signed
result = r.json()
Response
{
"code": 200,
"message": "OK",
"data": {
"payout_id": "PO-2026-0001",
"withdraw_id": 41,
"amount": 1500000,
"fee": 5000,
"total_debit": 1505000,
"status": "pending",
"bank": {
"bank_id": 12,
"bank_name": "BCA",
"account_number": "123****890"
},
"note": "October settlement",
"requested_at": "2026-10-21T14:32:00+07:00",
"processed_at": null
}
}
{
"code": 422,
"message": "AMOUNT_BELOW_MINIMUM",
"data": { "minimum": 10000 }
}
| Field | Type | Description |
|---|---|---|
payout_id | string | Your reference |
withdraw_id | integer | Our reference |
amount | integer | Amount in IDR the bank receives |
fee | integer | Withdraw fee, charged on top of amount (0 if none) |
total_debit | integer | amount + fee, taken from your balance |
status | string | See payout status values |
bank | object | bank_id, bank_name, masked account_number |
note | string | null | Your note |
requested_at | string | ISO 8601 |
processed_at | string | null | ISO 8601, set when final |
Payout Status
/payout/statusSignature v2Query parameters: merchant (required) and one of payout_id or withdraw_id. The response data has the same shape as Create Payout.
# GET requests signed with v2: the payload is the raw query string, exactly as sent.
API_KEY='YOUR_API_KEY'; SECRET='YOUR_SECRET_KEY'
URI_PATH='/payout/status'
QUERY='merchant=YOUR_MERCHANT_ID&payout_id=PO-2026-0001'
TS=$(date +%s); NONCE=$(openssl rand -hex 8)
BODY_HASH=$(printf '%s' "$QUERY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIGNATURE=$(printf 'GET\n%s\n%s\n%s\n%s' "$URI_PATH" "$TS" "$NONCE" "$BODY_HASH" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl "https://api.labubu4d.lol$URI_PATH?$QUERY" \
-H "X-API-KEY: $API_KEY" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" -H "X-Signature: $SIGNATURE"
<?php
// Uses chaospaySign() from the Authentication section.
$query = http_build_query(['merchant' => 'YOUR_MERCHANT_ID', 'payout_id' => 'PO-2026-0001']);
$headers = chaospaySign('GET', '/payout/status', $query, 'YOUR_SECRET_KEY');
$ch = curl_init('https://api.labubu4d.lol/payout/status?' . $query); // the same query string that was signed
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-API-KEY: YOUR_API_KEY',
'X-Timestamp: ' . $headers['X-Timestamp'],
'X-Nonce: ' . $headers['X-Nonce'],
'X-Signature: ' . $headers['X-Signature'],
],
]);
$response = json_decode(curl_exec($ch), true);
// Uses chaospaySign() from the Authentication section.
(async () => {
const query = new URLSearchParams({ merchant: 'YOUR_MERCHANT_ID', payout_id: 'PO-2026-0001' }).toString();
const res = await fetch(`https://api.labubu4d.lol/payout/status?${query}`, { // the same query string that was signed
headers: { 'X-API-KEY': 'YOUR_API_KEY', ...chaospaySign('GET', '/payout/status', query, 'YOUR_SECRET_KEY') },
});
const result = await res.json();
})();
# Uses chaospay_sign() from the Authentication section.
import requests
from urllib.parse import urlencode
query = urlencode({"merchant": "YOUR_MERCHANT_ID", "payout_id": "PO-2026-0001"})
headers = {"X-API-KEY": "YOUR_API_KEY", **chaospay_sign("GET", "/payout/status", query, "YOUR_SECRET_KEY")}
# Put the signed string in the URL yourself; passing params= lets requests re-encode it.
r = requests.get(f"https://api.labubu4d.lol/payout/status?{query}", headers=headers, timeout=30)
result = r.json()
Payout Status Values
| status | Meaning | Final |
|---|---|---|
pending | Received; the amount is held from your balance | No |
processing | Being transferred to the bank | No |
success | Transferred | Yes |
failed | Rejected or failed; the held amount and fee are returned to your available balance | Yes |
Payment Callback
When a payment reaches a final state, we send a POST with a JSON body to your payment callback URL.
| Header | Value |
|---|---|
Content-Type | application/json |
Accept | application/json |
{
"idrpayment_id": "88231907",
"transaction_id": "TRX2610211430151234",
"order_id": "INV-10023",
"amount": 50000,
"status": 1,
"signature_v2": "5d1c7e0f6a2b...e93a",
"sandbox": false,
"signature": "a8f5f167f44f4964e6c998dee827110c"
}
| Field | Type | Description |
|---|---|---|
idrpayment_id | string | The payment provider's reference |
transaction_id | string | Our reference |
order_id | string | null | Your reference |
amount | integer | Amount in IDR |
status | integer | 1 paid, 2 failed, 0 expired (reported by the provider) |
signature_v2 | string | HMAC-SHA256, verify this one |
signature | string | Legacy: md5(transaction_id + amount + secret_key). Kept for older integrations; it does not cover status. |
sandbox | boolean | true for sandbox payments |
Verifying the signature
signature_v2 = hmac_sha256_hex(transaction_id|order_id|amount|status|idrpayment_id, secret_key)
Join the five values with |, using an empty string when order_id is null. Compare in constant time.
<?php
$secretKey = 'YOUR_SECRET_KEY';
$p = json_decode(file_get_contents('php://input'), true);
$expected = hash_hmac('sha256', implode('|', [
$p['transaction_id'], $p['order_id'] ?? '', $p['amount'], $p['status'], $p['idrpayment_id'],
]), $secretKey);
if (! hash_equals($expected, (string) ($p['signature_v2'] ?? ''))) {
http_response_code(401);
exit('invalid signature');
}
// Idempotent: the same notification may arrive more than once.
if ((int) $p['status'] === 1) {
// mark order $p['order_id'] / $p['transaction_id'] as paid (once)
}
http_response_code(200);
echo 'OK';
// Express example
const crypto = require('crypto');
app.post('/chaospay/callback', express.json(), (req, res) => {
const p = req.body;
const expected = crypto.createHmac('sha256', 'YOUR_SECRET_KEY')
.update([p.transaction_id, p.order_id ?? '', p.amount, p.status, p.idrpayment_id].join('|'))
.digest('hex');
const given = Buffer.from(String(p.signature_v2 || ''));
if (given.length !== expected.length || !crypto.timingSafeEqual(given, Buffer.from(expected))) {
return res.status(401).send('invalid signature');
}
if (Number(p.status) === 1) {
// mark the order as paid (once โ notifications can repeat)
}
res.status(200).send('OK');
});
# Flask example
import hashlib, hmac
from flask import Flask, request
app = Flask(__name__)
@app.post("/chaospay/callback")
def chaospay_callback():
p = request.get_json()
message = "|".join(str(v) for v in [p["transaction_id"], p.get("order_id") or "", p["amount"], p["status"], p["idrpayment_id"]])
expected = hmac.new(b"YOUR_SECRET_KEY", message.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, str(p.get("signature_v2", ""))):
return "invalid signature", 401
if int(p["status"]) == 1:
pass # mark the order as paid (once โ notifications can repeat)
return "OK", 200
Payout Callback
When a payout becomes success or failed, we send a POST with a JSON body to your withdrawal callback URL. This covers payouts requested by API and from the dashboard (payout_id is then null).
{
"payout_id": "PO-2026-0001",
"withdraw_id": 41,
"reference": "October settlement",
"amount": 1500000,
"fee": 5000,
"status": "success",
"note": "October settlement",
"processed_at": "2026-10-21T14:35:12+07:00",
"signature_v2": "9b0e44c1d2f3...7a10"
}
| Field | Type | Description |
|---|---|---|
payout_id | string | null | Your reference |
withdraw_id | integer | Our reference |
reference | string | The note, or the payout_id when no note was given |
amount | integer | Amount in IDR the bank receives |
fee | integer | Withdraw fee charged on top (refunded when failed). Not part of signature_v2. |
status | string | success or failed |
note | string | null | Processing note |
processed_at | string | null | ISO 8601 |
signature_v2 | string | HMAC-SHA256 |
signature_v2 = hmac_sha256_hex(withdraw_id|payout_id|amount|status, secret_key)
<?php
$secretKey = 'YOUR_SECRET_KEY';
$p = json_decode(file_get_contents('php://input'), true);
$expected = hash_hmac('sha256', implode('|', [
$p['withdraw_id'], $p['payout_id'] ?? '', $p['amount'], $p['status'],
]), $secretKey);
if (! hash_equals($expected, (string) ($p['signature_v2'] ?? ''))) {
http_response_code(401);
exit('invalid signature');
}
// $p['status'] is "success" or "failed". Apply it once per withdraw_id.
http_response_code(200);
echo 'OK';
// Express example
const crypto = require('crypto');
app.post('/chaospay/payout-callback', express.json(), (req, res) => {
const p = req.body;
const expected = crypto.createHmac('sha256', 'YOUR_SECRET_KEY')
.update([p.withdraw_id, p.payout_id ?? '', p.amount, p.status].join('|'))
.digest('hex');
const given = Buffer.from(String(p.signature_v2 || ''));
if (given.length !== expected.length || !crypto.timingSafeEqual(given, Buffer.from(expected))) {
return res.status(401).send('invalid signature');
}
// p.status is "success" or "failed". Apply it once per withdraw_id.
res.status(200).send('OK');
});
# Flask example
import hashlib, hmac
from flask import Flask, request
app = Flask(__name__)
@app.post("/chaospay/payout-callback")
def chaospay_payout_callback():
p = request.get_json()
message = "|".join(str(v) for v in [p["withdraw_id"], p.get("payout_id") or "", p["amount"], p["status"]])
expected = hmac.new(b"YOUR_SECRET_KEY", message.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, str(p.get("signature_v2", ""))):
return "invalid signature", 401
# p["status"] is "success" or "failed". Apply it once per withdraw_id.
return "OK", 200
Delivery & Retries
- Answer with any HTTP
2xxstatus to acknowledge. Anything else, or no answer within 5 seconds (default), counts as a failure. - A failed delivery is retried, by default for 3 attempts in total, waiting about 10, 60 and 300 seconds between attempts (300 seconds for any further attempt your account is set to).
- Callback URLs must use
httpsand resolve to a public address. Redirects are not followed. - The same notification can arrive more than once. Make your handler idempotent: key it on
transaction_id(orwithdraw_id) and apply each final state once. - Every delivery and its response is listed in the dashboard under Callback Log, where a failed one can be sent again.
- Always verify
signature_v2before trusting a callback, and check the amount against your order. - A payment that simply runs past
expire_datemay not produce a callback. For an order still open after its expiry, ask Payment Status.
Legacy Endpoints
Older integrations can keep using these aliases. They behave exactly like the current endpoints. New integrations should use the current paths.
| Legacy | Current |
|---|---|
POST /services/CreatePayment.php | POST /payment |
GET /services/GetStatus.php | GET /payment/status |
The API key is accepted only in the X-API-KEY header, never in the URL.
Health Check
/upReturns HTTP 200 when the API is up. No authentication.
Support
For credentials, enabling payouts by API, or questions about a specific transaction, contact your account manager and include the transaction_id or withdraw_id. Never send your secret key to anyone, including us.