Webhooks
When an order's status changes, the API sends an HTTP POST to your notify_url. This is how you know when a payment is confirmed.
Setting Your Notify URL
You can specify notify_url on each order creation request. If omitted, the system falls back to the default notify_url configured in your merchant profile (set in the dashboard under Settings → Notifications).
Payload Format
{
"event": "order.PAID",
"ref_code": "ORD-260601103045-A1B2",
"merchant_order_id": "your-internal-order-id",
"amount_minor": 5000000,
"currency": "IDR",
"status": "PAID",
"paid_at": "2026-06-01T10:30:45Z"
}
Fields
| Field | Type | Description |
|---|---|---|
event | string | order.<STATUS> — e.g., order.PAID, order.FAILED, order.EXPIRED |
ref_code | string | PGA internal order reference |
merchant_order_id | string | Your original order ID |
amount_minor | integer | Order amount in minor units |
currency | string | Always IDR |
status | string | New order status |
paid_at | string (ISO 8601) | Timestamp of payment; null if not yet paid |
Status Values
| Status | Meaning |
|---|---|
PENDING | Awaiting payment |
PAID | Payment confirmed |
EXPIRED | Order expired before payment |
FAILED | Payment failed or was rejected |
CANCELLED | Order was cancelled |
REFUNDED | Fully refunded |
PARTIALLY_REFUNDED | Partially refunded |
Responding to Webhooks
Your endpoint must return HTTP 200 within 10 seconds. Any other response code (or a timeout) is treated as a delivery failure and triggers a retry.
Immediately return 200 OK and process the event asynchronously. Do not block the response on database writes or downstream API calls.
<?php
// Acknowledge immediately
http_response_code(200);
echo json_encode(['received' => true]);
// Close the connection so PHP can continue processing
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// Now process safely
$payload = json_decode(file_get_contents('php://input'), true);
if ($payload['status'] === 'PAID') {
// update your order in DB
}
Verifying Webhook Signatures
If you have configured a Webhook Secret in the dashboard, every webhook delivery includes an X-Signature header. Verify it to ensure the request is genuinely from Nusio Saka:
$rawBody = file_get_contents('php://input');
$receivedSig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expectedSig = 'sha256=' . hash_hmac('sha256', $rawBody, $webhookSecret);
if (!hash_equals($expectedSig, $receivedSig)) {
http_response_code(401);
exit('Invalid signature');
}
const crypto = require('crypto');
function verifySignature(rawBody, receivedSig, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(receivedSig)
);
}
Always use a constant-time comparison function (hash_equals in PHP, timingSafeEqual in Node.js) to prevent timing attacks.
Retry Schedule
If your endpoint does not return HTTP 200, delivery is retried on this schedule:
| Attempt | Delay after previous |
|---|---|
| 1 (initial) | Immediate |
| 2 | +30 seconds |
| 3 | +2 minutes |
| 4 | +10 minutes |
| 5 | +30 minutes |
| 6 (final) | +2 hours |
After the final attempt, the webhook is marked as permanently failed. You can still poll the order status via GET /v1/orders/:ref_code.
Idempotency
Your system may receive the same webhook event more than once (network failures, retries). Always check the order status in your own database before updating — use ref_code or merchant_order_id as the deduplication key.