C ChaosPay API Docs
API REFERENCE ยท v1

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:

Production
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

  1. 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.
  2. 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).
  3. Whitelist your server IPs (recommended) in the same place.
  4. Create a payment with POST /payment, show the returned QR code to your customer and wait for the callback.
  5. Test end to end in the sandbox before going live.
cURL
# 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).

EndpointLimit
POST /payment120 requests / minute
GET /payment/status, GET /account/balance300 requests / minute
POST /payout, GET /payout/status, GET /account/banks30 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).

HeaderRequiredDescription
X-API-KEYRequiredYour API key (or sandbox API key). Never send it in the URL.
Content-TypePOSTapplication/json
X-Timestampv2Unix time in seconds. Must be within 5 minutes of our clock.
X-Noncev2A random string, 8โ€“64 characters of A-Z a-z 0-9 _ -, never reused.
X-Signaturev2Lower-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:

Formula
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.

String to sign
METHOD + "\n" + PATH + "\n" + X-Timestamp + "\n" + X-Nonce + "\n" + sha256_hex(PAYLOAD)

X-Signature = hmac_sha256_hex(string_to_sign, secret_key)
PartValue
METHODUpper case, e.g. POST
PATHThe path only, without host or query, e.g. /payout
PAYLOADPOST: 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),
    ];
}
Common mistakes
  • 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 gives REQUEST_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:

FieldTypeDescription
codeinteger200 on success, otherwise the error status
messagestringOK, or a machine-readable error code
dataobject | array | nullThe result; usually null on error
Success
{
  "code": 200,
  "message": "OK",
  "data": { }
}
Error
{
  "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

HTTPmessageMeaning
400INVALID_PARAMETERA parameter is missing or has the wrong format.
401INVALID_SIGNATUREThe v2 signature or its headers are malformed or do not match.
401REQUEST_EXPIREDX-Timestamp is more than 5 minutes from our clock.
401NONCE_REUSEDThis X-Nonce was already used.
401SIGNATURE_V2_REQUIREDThis endpoint (or your account) requires a v2 signature.
403API_KEY_REQUIREDThe X-API-KEY header is missing.
403INVALID_API_KEY_OR_MERCHANTThe key and merchant identifier do not match an active account.
403IP_NOT_WHITELISTEDThe calling IP is not in your whitelist.
403INVALID_CLIENT_SIGNATUREclient_signature does not match.
403UNAUTHORIZEDThe request could not be tied to your account.
403API_PAYOUTS_DISABLEDPayouts by API are not enabled for your account. Contact us to enable them.
403NOT_AVAILABLE_IN_SANDBOXThe endpoint does not work with sandbox keys.
404TRANSACTION_NOT_FOUNDNo payment with that transaction_id / order_id on your account.
404PAYOUT_NOT_FOUNDNo payout with that payout_id / withdraw_id on your account.
404NOT_FOUNDUnknown endpoint.
405METHOD_NOT_ALLOWEDWrong HTTP method for this endpoint.
409DUPLICATE_PAYOUT_IDThe payout_id was already used with a different bank account or amount.
422INVALID_BANKbank_id is not one of your registered bank accounts.
422AMOUNT_BELOW_MINIMUMThe payout is below the minimum; data.minimum gives it.
422AMOUNT_ABOVE_MAXIMUMThe payout is above your maximum per payout; data.maximum gives it.
422DAILY_LIMIT_EXCEEDEDThe daily payment volume, payout volume or payout count set for your account is reached. Create Payment returns data.limit and data.used.
422INSUFFICIENT_BALANCEYour available balance does not cover the payout amount plus its fee.
429TOO_MANY_REQUESTSRate limit exceeded. Wait for Retry-After seconds.
500SERVICE_ERRORUnexpected 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_SIGNATUREThe payment provider could not be reached, or its answer was malformed or failed our signature check. A retry opens a new payment.
503PROVIDER_NOT_CONFIGUREDNo 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

POST/payment

Creates 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

ParameterTypeRequiredDescription
merchantstringRequiredYour merchant identifier
usernamestringRequiredYour customer's username or ID (max 191)
amountintegerRequiredAmount in IDR. Default range 10,000 โ€“ 10,000,000; your account may have its own.
client_signaturestringRequired*md5(username + amount + secret_key). *Not needed when the request is signed with v2.
order_idstringOptionalYour own reference, max 64, A-Z a-z 0-9 . _ : -. Returned in the response and the callback, and usable to look the payment up.
emailstringOptionalPayer email
mobilestringOptionalPayer phone, 6โ€“20 digits, optional leading +
account_namestringOptionalPayer name (max 100)
account_nostringOptionalPayer account number, 3โ€“32 digits
bank_codestringOptionalPayer 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"
  }'

Response

200 OK
{
  "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
  }
}
FieldTypeDescription
transaction_idstringOur payment reference. Keep it with your order.
order_idstring | nullYour reference, as sent
amountintegerAmount in IDR
create_datestringY-m-d H:i:s, Asia/Jakarta
expire_datestringAfter this time the QR can no longer be paid
statusinteger0 on creation, see status codes
qrstringQRIS payload. Render it as a QR code for the customer to scan.
payment_urlstring | nullA hosted payment page, when the channel provides one (https only)
sandboxbooleantrue when created with a sandbox key

Payment Status

GET/payment/status

Returns the current state of one of your payments. Use it to reconcile, or if a callback did not reach you.

Query Parameters

ParameterTypeRequiredDescription
merchantstringRequiredYour merchant identifier
transaction_idstringOne ofOur reference from Create Payment
order_idstringOne ofYour 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"

Response

200 OK
{
  "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
  }
}
FieldTypeDescription
merchantstringYour merchant identifier
transaction_idstringOur reference
order_idstring | nullYour reference
idrpayment_idstring | nullThe payment provider's reference
usernamestringThe customer username sent on creation
amountintegerAmount in IDR
statusinteger0, 1 or 2, see below
local_statusstringpending, completed, failed or expired
remarkstringProvider remark, if any
sandboxbooleantrue for sandbox payments

Payment Status Codes

statuslocal_statusMeaningFinal
0pendingWaiting for the customer to payNo
0expiredNot paid before expire_dateYes
1completedPaid. Credit your customer.Yes
2failedFailed or cancelledYes

Only status = 1 means the money was received. Use local_status to tell a pending payment from an expired one.

Balance

GET/account/balance

Returns your current balance. Query parameter: merchant (required).

cURL
curl -G https://api.labubu4d.lol/account/balance \
  -H "X-API-KEY: YOUR_API_KEY" \
  --data-urlencode "merchant=YOUR_MERCHANT_ID"
200 OK
{
  "code": 200,
  "message": "OK",
  "data": {
    "merchant": "YOUR_MERCHANT_ID",
    "currency": "IDR",
    "available": "12500000.00",
    "pending_settlement": "1500000.00",
    "settled_total": "84000000.00"
  }
}
FieldTypeDescription
currencystringAlways IDR
availablestringBalance you can withdraw now (decimal string, 2 places)
pending_settlementstringHeld for payouts that are not final yet
settled_totalstringTotal paid out to you so far

Bank Accounts

GET/account/banksSignature v2

Lists 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.

200 OK
{
  "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

POST/payoutSignature v2

Withdraws 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

ParameterTypeRequiredDescription
merchantstringRequiredYour merchant identifier
payout_idstringRequiredYour unique reference, max 64, A-Z a-z 0-9 . _ : -
bank_idintegerRequiredFrom Bank Accounts
amountintegerRequiredAmount in IDR. Default minimum 10,000; your account may also have a maximum and daily limits.
notestringOptionalFree 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"

Response

200 OK
{
  "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
  }
}
422
{
  "code": 422,
  "message": "AMOUNT_BELOW_MINIMUM",
  "data": { "minimum": 10000 }
}
FieldTypeDescription
payout_idstringYour reference
withdraw_idintegerOur reference
amountintegerAmount in IDR the bank receives
feeintegerWithdraw fee, charged on top of amount (0 if none)
total_debitintegeramount + fee, taken from your balance
statusstringSee payout status values
bankobjectbank_id, bank_name, masked account_number
notestring | nullYour note
requested_atstringISO 8601
processed_atstring | nullISO 8601, set when final

Payout Status

GET/payout/statusSignature v2

Query 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"

Payout Status Values

statusMeaningFinal
pendingReceived; the amount is held from your balanceNo
processingBeing transferred to the bankNo
successTransferredYes
failedRejected or failed; the held amount and fee are returned to your available balanceYes

Payment Callback

When a payment reaches a final state, we send a POST with a JSON body to your payment callback URL.

HeaderValue
Content-Typeapplication/json
Acceptapplication/json
Payload
{
  "idrpayment_id": "88231907",
  "transaction_id": "TRX2610211430151234",
  "order_id": "INV-10023",
  "amount": 50000,
  "status": 1,
  "signature_v2": "5d1c7e0f6a2b...e93a",
  "sandbox": false,
  "signature": "a8f5f167f44f4964e6c998dee827110c"
}
FieldTypeDescription
idrpayment_idstringThe payment provider's reference
transaction_idstringOur reference
order_idstring | nullYour reference
amountintegerAmount in IDR
statusinteger1 paid, 2 failed, 0 expired (reported by the provider)
signature_v2stringHMAC-SHA256, verify this one
signaturestringLegacy: md5(transaction_id + amount + secret_key). Kept for older integrations; it does not cover status.
sandboxbooleantrue for sandbox payments

Verifying the signature

Formula
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';

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).

Payload
{
  "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"
}
FieldTypeDescription
payout_idstring | nullYour reference
withdraw_idintegerOur reference
referencestringThe note, or the payout_id when no note was given
amountintegerAmount in IDR the bank receives
feeintegerWithdraw fee charged on top (refunded when failed). Not part of signature_v2.
statusstringsuccess or failed
notestring | nullProcessing note
processed_atstring | nullISO 8601
signature_v2stringHMAC-SHA256
Formula
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';

Delivery & Retries

  • Answer with any HTTP 2xx status 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 https and 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 (or withdraw_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_v2 before trusting a callback, and check the amount against your order.
  • A payment that simply runs past expire_date may 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.

LegacyCurrent
POST /services/CreatePayment.phpPOST /payment
GET /services/GetStatus.phpGET /payment/status

The API key is accepted only in the X-API-KEY header, never in the URL.

Health Check

GET/up

Returns 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.