Lewati ke konten utama

Best Practices

Always Use Idempotency Keys​

Network failures can cause your request to succeed on the server but return an error to you. Without idempotency keys, a retry creates a duplicate order or disbursement.

V1 API: Always send the Idempotency-Key header on POST /v1/orders.

POST /v1/orders
Idempotency-Key: order-INV-2026-00123

A good key is unique per intent: include your order ID and a stable prefix. Never use random UUIDs on retries — use the same key and byte-identical request body used on the original attempt. If the result is a timeout, ORDER_PENDING_CONFIRMATION, or VENDOR_ERROR, first query GET /v1/orders?merchant_order_id=<your-id>. Do not create a second order or present a second QR until the first attempt is reconciled.

Legacy API: The orderId field serves as the implicit idempotency key. Use a stable, meaningful ID (e.g., your invoice number) rather than a generated UUID.

Return 200 Immediately from Webhooks​

Your webhook endpoint has a 10-second timeout. If you do database writes, external API calls, or slow processing in the handler, you risk timeouts — which trigger retries, which cause duplicate processing.

Client Request → [return 200 immediately] → [process event asynchronously]

Enqueue the event in a job queue or background task and return 200 right away.

Verify Webhook Signatures​

If you have configured a webhook secret in the dashboard, always verify the X-Signature header before processing the payload. Unverified webhooks can be spoofed.

PHP
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $webhookSecret);
if (!hash_equals($expected, $_SERVER['HTTP_X_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}

Deduplicate Webhook Events​

The delivery system guarantees at-least-once delivery. Your handler may receive the same event more than once (retries after network failure, server restart, etc.).

Check whether you have already processed the event before updating your database:

$orderId = $payload['merchant_order_id'];
$order = db_get_order($orderId);

if ($order['status'] === 'PAID') {
// Already processed — skip
return;
}

db_mark_paid($orderId);

Wait for PAID, Not PENDING​

Order webhooks may fire for multiple status transitions. A PENDING webhook does not mean the customer has paid — it means the order is awaiting payment. Only trigger fulfillment on PAID.

if ($payload['status'] === 'PAID') {
fulfill($payload['merchant_order_id']);
}
// Ignore PENDING, EXPIRED, etc.

Phone Number Format​

Normalize all phone numbers to 62xxxxxxxxx format before sending. The API normalizes common formats, but being explicit avoids edge cases:

PHP
function normalizePhone(string $phone): string
{
$phone = preg_replace('/\D/', '', $phone); // remove non-digits
if (str_starts_with($phone, '0')) return '62' . substr($phone, 1);
if (str_starts_with($phone, '62')) return $phone;
if (str_starts_with($phone, '+62')) return substr($phone, 1);
return '62' . $phone;
}

Set Appropriate Expiry Times​

Payment methodRecommended expire_minutes
QRIS15 (customers expect to pay immediately)

V1 order creation currently supports QRIS only.

Short expiry reduces unresolved order cleanup. Very short expiry (< 5 minutes) can frustrate customers who are slow to open their payment app.

Amount Arithmetic​

Send whole-Rupiah integers in API requests. For internal percentage calculations, use decimal arithmetic (or your own integer subunit representation) and apply your documented rounding rule; never use binary floating point for money.

DO — integer arithmetic
$amountRupiah = 50000; // send this value as API field `amount`
$mdrRupiah = intdiv($amountRupiah * 7 + 999, 1000); // example 0.7%, rounded up
$netRupiah = $amountRupiah - $mdrRupiah;
DON'T — float arithmetic on money
$amount = 50000.00; // ❌ floating point
$mdr = $amount * 0.007; // may produce 349.99999...

Keep Credentials Secure​

  • Store api_key, legacy_client_secret, and RSA private keys in environment variables or a secrets manager — never in source code
  • Use different credentials per environment if possible
  • Rotate credentials immediately if they are ever exposed
  • The V1 api_key and Legacy legacy_client_secret can be rotated from the merchant dashboard; RSA key pairs require re-uploading the public key after rotation