Skip to main content

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.

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)
Bank Transfer1440 (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.

DO — integer arithmetic
$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"
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