Dokumentasi API ChaosPay
Terima pembayaran QRIS, pantau statusnya, cek saldo, dan kirim payout ke rekening bank yang sudah terdaftar melalui REST API berbasis JSON. Semua endpoint menjawab dengan format yang sama, dan setiap notifikasi yang kami kirim ke Anda selalu ditandatangani.
Base URL
Semua request API dikirim melalui HTTPS ke:
https://api.labubu4d.lol
Body request POST memakai Content-Type: application/json, sedangkan GET memakai query parameter. Nominal selalu berupa bilangan bulat Rupiah (IDR). Waktu memakai zona Asia/Jakarta (UTC+7).
Contoh kode di halaman ini memakai komentar berbahasa Inggris, sama dengan versi English.
Mulai Cepat
- Ambil kredensial Anda. Login ke dashboard merchant lalu buka 2FA & API. Di sana ada Merchant Identifier dan API Key. Secret Key hanya ditampilkan satu kali, yaitu saat diterbitkan atau dirotasi, jadi simpan baik-baik saat itu juga.
- Atur URL callback. Di Callback Settings, isi URL HTTPS yang akan menerima notifikasi pembayaran (dan URL callback penarikan jika Anda memakai payout).
- Daftarkan IP server Anda (disarankan) di tempat yang sama.
- Buat pembayaran dengan
POST /payment, tampilkan QR yang dikembalikan ke pelanggan, lalu tunggu callback. - Uji alurnya dari awal sampai akhir di sandbox sebelum go-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
Setiap merchant juga bisa menerbitkan API key dan secret khusus sandbox dari 2FA & API. Gunakan dengan base URL yang sama:
- Pembayaran yang dibuat dengan key sandbox tidak pernah dikirim ke bank atau provider, tidak menyentuh saldo asli Anda, dan dikembalikan dengan
"sandbox": true. - Di halaman Sandbox pada dashboard, Anda bisa menandai pembayaran sandbox sebagai paid atau failed. Setelah itu kami mengirim callback sungguhan ke URL callback pembayaran Anda, ditandatangani dengan secret sandbox, sehingga handler Anda bisa diuji.
- IP whitelist tidak diberlakukan untuk key sandbox.
- Payout dan daftar rekening bank tidak tersedia di sandbox (
NOT_AVAILABLE_IN_SANDBOX). Saldo selalu bernilai nol.
Batas Request
Batas dihitung per API key, per menit. Jika melewati batas, API menjawab HTTP 429 dengan header Retry-After (dalam detik).
| Endpoint | Batas |
|---|---|
POST /payment | 120 request / menit |
GET /payment/status, GET /account/balance | 300 request / menit |
POST /payout, GET /payout/status, GET /account/banks | 30 request / menit (gabungan) |
Nilai di atas adalah default. Setiap batasan di halaman ini (nominal pembayaran dan payout, batas harian, batas request, serta jumlah percobaan dan timeout callback) bisa diatur per akun; batasan akun Anda tercantum di dashboard pada My Account.
Utamakan callback daripada polling. Jika memang perlu polling status pembayaran, jeda beberapa detik sudah cukup.
Autentikasi
Setiap request mengidentifikasi akun Anda lewat API key di header dan merchant identifier di dalam request (body JSON untuk POST, query string untuk GET).
| Header | Wajib | Keterangan |
|---|---|---|
X-API-KEY | Wajib | API key Anda (atau API key sandbox). Jangan pernah dikirim di URL. |
Content-Type | POST | application/json |
X-Timestamp | v2 | Unix time dalam detik. Selisihnya dengan jam server kami maksimal 5 menit. |
X-Nonce | v2 | String acak 8โ64 karakter dari A-Z a-z 0-9 _ -, tidak boleh dipakai ulang. |
X-Signature | v2 | HMAC-SHA256 dalam hex huruf kecil, lihat Request Signature v2. |
Secret key dipakai untuk menandatangani request dan memverifikasi callback dari kami. Secret key tidak pernah dikirim di dalam request. Jika Anda menduga secret key bocor, rotasi key dari 2FA & API: API key dan secret key baru diterbitkan bersamaan, dan pasangan lama langsung tidak berlaku.
Client Signature (Buat Pembayaran)
Request POST /payment yang tidak ditandatangani dengan v2 wajib menyertakan client_signature:
client_signature = md5(username + amount + secret_key)
Ketiga nilai digabung sebagai teks tanpa pemisah, dan hasilnya 32 karakter hex huruf kecil. Untuk username = "customer001", amount = 50000, dan secret "sec", input-nya adalah customer00150000sec.
Request Signature v2
Signature v2 menandatangani seluruh request dan melindunginya dari replay. Signature ini wajib untuk semua endpoint payout (/payout, /payout/status, /account/banks) dan diterima di semua endpoint lain. Jika request membawa signature v2 yang valid, client_signature tidak diperlukan. Akun Anda juga bisa diatur agar mewajibkan v2 di semua endpoint.
METHOD + "\n" + PATH + "\n" + X-Timestamp + "\n" + X-Nonce + "\n" + sha256_hex(PAYLOAD)
X-Signature = hmac_sha256_hex(string_to_sign, secret_key)
| Bagian | Nilai |
|---|---|
METHOD | Huruf besar, misalnya POST |
PATH | Hanya path, tanpa host dan query, misalnya /payout |
PAYLOAD | POST: body request mentah, persis byte demi byte. GET: query string mentah tanpa ? di depan (string kosong jika tidak ada). |
<?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(),
}
- Menandatangani satu string JSON tetapi mengirim string lain (misalnya karena HTTP client men-serialize ulang body). Kirim persis string yang Anda hash.
- Membangun query string dua kali. Bangun sekali, tandatangani, lalu tempelkan string yang sama ke URL.
- Memakai ulang nonce: setiap nonce hanya diterima sekali (
NONCE_REUSED). Jam yang meleset lebih dari 5 menit menghasilkanREQUEST_EXPIRED.
IP Whitelist
Di Callback Settings Anda bisa mendaftarkan IP server yang boleh memanggil API dengan key live, dipisahkan dengan |. Jika daftarnya kosong, semua IP diterima. Request dari IP lain ditolak dengan IP_NOT_WHITELISTED. Kami sangat menyarankan mengisinya sebelum mengaktifkan payout.
Format Respons
Setiap respons, baik sukses maupun gagal, berupa envelope JSON:
| Field | Tipe | Keterangan |
|---|---|---|
code | integer | 200 jika sukses, selain itu status error |
message | string | OK, atau kode error yang bisa dibaca mesin |
data | object | array | null | Hasilnya; biasanya null jika error |
{
"code": 200,
"message": "OK",
"data": { }
}
{
"code": 403,
"message": "IP_NOT_WHITELISTED",
"data": null
}
Gunakan code dan message untuk percabangan logika, jangan hanya status HTTP. Saat provider upstream gagal, envelope berisi 502 sedangkan status HTTP-nya 503.
Kode Error
| HTTP | message | Arti |
|---|---|---|
| 400 | INVALID_PARAMETER | Ada parameter yang kosong atau formatnya salah. |
| 401 | INVALID_SIGNATURE | Signature v2 atau header-nya tidak valid atau tidak cocok. |
| 401 | REQUEST_EXPIRED | X-Timestamp berselisih lebih dari 5 menit dari jam server kami. |
| 401 | NONCE_REUSED | X-Nonce ini sudah pernah dipakai. |
| 401 | SIGNATURE_V2_REQUIRED | Endpoint ini (atau akun Anda) mewajibkan signature v2. |
| 403 | API_KEY_REQUIRED | Header X-API-KEY tidak ada. |
| 403 | INVALID_API_KEY_OR_MERCHANT | API key dan merchant identifier tidak cocok dengan akun yang aktif. |
| 403 | IP_NOT_WHITELISTED | IP pemanggil tidak ada di whitelist Anda. |
| 403 | INVALID_CLIENT_SIGNATURE | client_signature tidak cocok. |
| 403 | UNAUTHORIZED | Request tidak dapat dikaitkan dengan akun Anda. |
| 403 | API_PAYOUTS_DISABLED | Payout lewat API belum diaktifkan untuk akun Anda. Hubungi kami untuk mengaktifkannya. |
| 403 | NOT_AVAILABLE_IN_SANDBOX | Endpoint ini tidak bisa dipakai dengan key sandbox. |
| 404 | TRANSACTION_NOT_FOUND | Tidak ada pembayaran dengan transaction_id / order_id tersebut di akun Anda. |
| 404 | PAYOUT_NOT_FOUND | Tidak ada payout dengan payout_id / withdraw_id tersebut di akun Anda. |
| 404 | NOT_FOUND | Endpoint tidak dikenal. |
| 405 | METHOD_NOT_ALLOWED | Method HTTP salah untuk endpoint ini. |
| 409 | DUPLICATE_PAYOUT_ID | payout_id sudah dipakai dengan rekening atau nominal berbeda. |
| 422 | INVALID_BANK | bank_id bukan salah satu rekening terdaftar Anda. |
| 422 | AMOUNT_BELOW_MINIMUM | Nominal payout di bawah minimum; nilainya ada di data.minimum. |
| 422 | AMOUNT_ABOVE_MAXIMUM | Nominal payout melebihi maksimum per payout; nilainya ada di data.maximum. |
| 422 | DAILY_LIMIT_EXCEEDED | Batas harian akun Anda (volume pembayaran, volume payout, atau jumlah payout) sudah tercapai. Buat Pembayaran mengembalikan data.limit dan data.used. |
| 422 | INSUFFICIENT_BALANCE | Saldo tersedia tidak cukup untuk nominal payout ditambah fee-nya. |
| 429 | TOO_MANY_REQUESTS | Melebihi batas request. Tunggu selama Retry-After detik. |
| 500 | SERVICE_ERROR | Error tak terduga di sisi kami. Cek status pembayaran berdasarkan order_id sebelum mencoba ulang pembayaran; payout aman dicoba ulang dengan payout_id yang sama. |
| 503 (code 502) | GATEWAY_UNREACHABLE, INVALID_PROVIDER_RESPONSE, INVALID_SIGNATURE | Provider pembayaran tidak bisa dihubungi, atau jawabannya rusak atau gagal pemeriksaan signature kami. Mencoba ulang akan membuat pembayaran baru. |
| 503 | PROVIDER_NOT_CONFIGURED | Saat ini tidak ada channel pembayaran yang tersedia untuk akun Anda. |
Jika provider pembayaran menolak request, kode dan pesan dari provider diteruskan apa adanya di envelope, misalnya {"code": 400, "message": "Amount range is from IDR 10,000 to 10,000,000", "data": null}.
Buat Pembayaran
/paymentMembuat pembayaran QRIS dan mengembalikan payload QR untuk ditampilkan ke pelanggan. Pembayaran kedaluwarsa pada expire_date; hasil akhirnya dikirim ke callback pembayaran Anda.
Body Request
| Parameter | Tipe | Wajib | Keterangan |
|---|---|---|---|
merchant | string | Wajib | Merchant identifier Anda |
username | string | Wajib | Username atau ID pelanggan Anda (maks. 191) |
amount | integer | Wajib | Nominal dalam IDR. Rentang default 10.000 โ 10.000.000; akun Anda bisa memiliki rentang sendiri. |
client_signature | string | Wajib* | md5(username + amount + secret_key). *Tidak diperlukan jika request ditandatangani dengan v2. |
order_id | string | Opsional | Referensi Anda sendiri, maks. 64, A-Z a-z 0-9 . _ : -. Dikembalikan di respons dan callback, dan bisa dipakai untuk mencari pembayaran. |
email | string | Opsional | Email pembayar |
mobile | string | Opsional | Nomor HP pembayar, 6โ20 digit, boleh diawali + |
account_name | string | Opsional | Nama pembayar (maks. 100) |
account_no | string | Opsional | Nomor rekening pembayar, 3โ32 digit |
bank_code | string | Opsional | Kode bank pembayar, 2โ32 karakter |
Setiap request membuat pembayaran baru, termasuk dengan order_id yang sudah pernah dipakai. Setelah timeout, cek dulu pembayarannya berdasarkan order_id sebelum mengirim ulang, agar pelanggan tidak mendapat QR kedua. Pencarian status dengan order_id yang dipakai lebih dari sekali mengembalikan pembayaran terbaru.
Contoh 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"]
Respons
{
"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 | Tipe | Keterangan |
|---|---|---|
transaction_id | string | Referensi pembayaran dari kami. Simpan bersama order Anda. |
order_id | string | null | Referensi Anda, sesuai yang dikirim |
amount | integer | Nominal dalam IDR |
create_date | string | Y-m-d H:i:s, Asia/Jakarta |
expire_date | string | Setelah waktu ini QR tidak bisa dibayar lagi |
status | integer | 0 saat dibuat, lihat kode status |
qr | string | Payload QRIS. Tampilkan sebagai kode QR untuk dipindai pelanggan. |
payment_url | string | null | Halaman pembayaran hosted, jika channel menyediakannya (hanya https) |
sandbox | boolean | true jika dibuat dengan key sandbox |
Status Pembayaran
/payment/statusMengembalikan kondisi terkini salah satu pembayaran Anda. Gunakan untuk rekonsiliasi, atau jika callback tidak sampai ke Anda.
Query Parameter
| Parameter | Tipe | Wajib | Keterangan |
|---|---|---|---|
merchant | string | Wajib | Merchant identifier Anda |
transaction_id | string | Salah satu | Referensi dari Buat Pembayaran |
order_id | string | Salah satu | Referensi Anda |
Contoh 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
Respons
{
"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 | Tipe | Keterangan |
|---|---|---|
merchant | string | Merchant identifier Anda |
transaction_id | string | Referensi dari kami |
order_id | string | null | Referensi Anda |
idrpayment_id | string | null | Referensi dari provider pembayaran |
username | string | Username pelanggan yang dikirim saat pembuatan |
amount | integer | Nominal dalam IDR |
status | integer | 0, 1, atau 2, lihat di bawah |
local_status | string | pending, completed, failed, atau expired |
remark | string | Catatan dari provider, jika ada |
sandbox | boolean | true untuk pembayaran sandbox |
Kode Status Pembayaran
| status | local_status | Arti | Final |
|---|---|---|---|
0 | pending | Menunggu pelanggan membayar | Tidak |
0 | expired | Tidak dibayar sebelum expire_date | Ya |
1 | completed | Sudah dibayar. Kreditkan ke pelanggan Anda. | Ya |
2 | failed | Gagal atau dibatalkan | Ya |
Hanya status = 1 yang berarti uang sudah diterima. Gunakan local_status untuk membedakan pembayaran yang masih pending dari yang sudah kedaluwarsa.
Saldo
/account/balanceMengembalikan saldo Anda saat ini. Query parameter: merchant (wajib).
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 | Tipe | Keterangan |
|---|---|---|
currency | string | Selalu IDR |
available | string | Saldo yang bisa ditarik sekarang (string desimal, 2 angka di belakang koma) |
pending_settlement | string | Saldo yang ditahan untuk payout yang belum final |
settled_total | string | Total yang sudah dibayarkan ke Anda |
Rekening Bank
/account/banksSignature v2Menampilkan rekening bank yang terdaftar di dashboard Anda (Settlement Banks). Gunakan bank_id saat membuat payout. Rekening hanya bisa ditambahkan dari dashboard. Query parameter: merchant (wajib). Tandatangani seperti GET v2 lainnya, dengan PATH = /account/banks dan 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
}
]
}
Buat Payout
/payoutSignature v2Menarik saldo tersedia ke salah satu rekening bank terdaftar Anda. Nominal ditambah fee withdraw akun Anda (jika ada) langsung ditahan; bank menerima nominal penuh. Hasilnya dikirim ke URL callback penarikan Anda. Payout lewat API harus diaktifkan dulu untuk akun Anda oleh tim kami.
Body Request
| Parameter | Tipe | Wajib | Keterangan |
|---|---|---|---|
merchant | string | Wajib | Merchant identifier Anda |
payout_id | string | Wajib | Referensi unik Anda, maks. 64, A-Z a-z 0-9 . _ : - |
bank_id | integer | Wajib | Dari Rekening Bank |
amount | integer | Wajib | Nominal dalam IDR. Minimum default 10.000; akun Anda juga bisa memiliki batas maksimum dan batas harian. |
note | string | Opsional | Teks bebas, maks. 191 |
Idempotensi. Mengulang payout_id dengan rekening dan nominal yang sama akan mengembalikan payout yang sudah ada; tidak ada penarikan ganda. Dengan nilai berbeda, hasilnya DUPLICATE_PAYOUT_ID.
Contoh 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()
Respons
{
"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 | Tipe | Keterangan |
|---|---|---|
payout_id | string | Referensi Anda |
withdraw_id | integer | Referensi dari kami |
amount | integer | Nominal dalam IDR yang diterima bank |
fee | integer | Fee withdraw, dikenakan di atas amount (0 jika tidak ada) |
total_debit | integer | amount + fee, dipotong dari saldo Anda |
status | string | Lihat nilai status payout |
bank | object | bank_id, bank_name, account_number yang disamarkan |
note | string | null | Catatan Anda |
requested_at | string | ISO 8601 |
processed_at | string | null | ISO 8601, terisi saat sudah final |
Status Payout
/payout/statusSignature v2Query parameter: merchant (wajib) dan salah satu dari payout_id atau withdraw_id. Isi data pada respons sama dengan Buat 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()
Nilai Status Payout
| status | Arti | Final |
|---|---|---|
pending | Diterima; nominal ditahan dari saldo Anda | Tidak |
processing | Sedang ditransfer ke bank | Tidak |
success | Sudah ditransfer | Ya |
failed | Ditolak atau gagal; nominal dan fee yang ditahan dikembalikan ke saldo tersedia | Ya |
Callback Pembayaran
Saat pembayaran mencapai status final, kami mengirim POST berisi body JSON ke URL callback pembayaran Anda.
| Header | Nilai |
|---|---|
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 | Tipe | Keterangan |
|---|---|---|
idrpayment_id | string | Referensi dari provider pembayaran |
transaction_id | string | Referensi dari kami |
order_id | string | null | Referensi Anda |
amount | integer | Nominal dalam IDR |
status | integer | 1 dibayar, 2 gagal, 0 kedaluwarsa (dilaporkan oleh provider) |
signature_v2 | string | HMAC-SHA256, verifikasi yang ini |
signature | string | Lama: md5(transaction_id + amount + secret_key). Dipertahankan untuk integrasi lama; tidak mencakup status. |
sandbox | boolean | true untuk pembayaran sandbox |
Memverifikasi signature
signature_v2 = hmac_sha256_hex(transaction_id|order_id|amount|status|idrpayment_id, secret_key)
Gabungkan kelima nilai dengan |, gunakan string kosong jika order_id bernilai null. Bandingkan dengan perbandingan 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
Callback Payout
Saat payout menjadi success atau failed, kami mengirim POST berisi body JSON ke URL callback penarikan Anda. Ini berlaku untuk payout yang diminta lewat API maupun dari dashboard (untuk yang dari dashboard, payout_id bernilai 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 | Tipe | Keterangan |
|---|---|---|
payout_id | string | null | Referensi Anda |
withdraw_id | integer | Referensi dari kami |
reference | string | Isi note, atau payout_id jika note tidak diisi |
amount | integer | Nominal dalam IDR yang diterima bank |
fee | integer | Fee withdraw yang dikenakan di atas nominal (dikembalikan jika failed). Tidak termasuk dalam signature_v2. |
status | string | success atau failed |
note | string | null | Catatan pemrosesan |
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
Pengiriman & Retry
- Balas dengan status HTTP
2xxapa pun sebagai tanda diterima. Selain itu, atau jika tidak ada jawaban dalam 5 detik (default), dianggap gagal. - Pengiriman yang gagal akan dicoba ulang, secara default total 3 kali percobaan, dengan jeda sekitar 10, 60, dan 300 detik (300 detik untuk percobaan tambahan jika akun Anda diatur lebih banyak).
- URL callback wajib memakai
httpsdan mengarah ke alamat publik. Redirect tidak diikuti. - Notifikasi yang sama bisa datang lebih dari sekali. Buat handler Anda idempoten: gunakan
transaction_id(atauwithdraw_id) sebagai kunci dan terapkan setiap status final hanya sekali. - Setiap pengiriman beserta responsnya tercatat di dashboard pada Callback Log, dan pengiriman yang gagal bisa dikirim ulang dari sana.
- Selalu verifikasi
signature_v2sebelum memercayai callback, dan cocokkan nominalnya dengan order Anda. - Pembayaran yang hanya melewati
expire_datebelum tentu menghasilkan callback. Untuk order yang masih terbuka setelah kedaluwarsa, tanyakan ke Status Pembayaran.
Endpoint Lama
Integrasi lama tetap bisa memakai alias berikut. Perilakunya sama persis dengan endpoint yang baru. Integrasi baru sebaiknya memakai path yang baru.
| Lama | Baru |
|---|---|
POST /services/CreatePayment.php | POST /payment |
GET /services/GetStatus.php | GET /payment/status |
API key hanya diterima di header X-API-KEY, tidak pernah di URL.
Health Check
/upMengembalikan HTTP 200 jika API berjalan. Tanpa autentikasi.
Bantuan
Untuk kredensial, aktivasi payout lewat API, atau pertanyaan tentang transaksi tertentu, hubungi account manager Anda dan sertakan transaction_id atau withdraw_id. Jangan pernah mengirim secret key Anda kepada siapa pun, termasuk kepada kami.