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 you used on the original attempt.
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.
$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:
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 method | Recommended expire_minutes |
|---|---|
| QRIS | 15 (customers expect to pay immediately) |
| Bank Transfer | 1440 (24 hours — customers may pay from branches) |
Short expiry reduces unresolved order cleanup. Very short expiry (< 5 minutes) can frustrate customers who are slow to open their payment app.
Amount Arithmetic
Always use integers in minor units for calculations — never floats. Floating-point arithmetic produces rounding errors on currency values.
$amountMinor = 5000000; // IDR 50,000
$mdrMinor = (int) round($amountMinor * 0.007); // 0.7% MDR
$netMinor = $amountMinor - $mdrMinor;
// Display only (not for calculation):
$displayAmount = number_format($amountMinor / 100, 0, ',', '.'); // "50.000"
$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_keyand Legacylegacy_client_secretcan be rotated from the merchant dashboard; RSA key pairs require re-uploading the public key after rotation