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 uses the default URL configured on your merchant profile. Ask your administrator to confirm that URL before live testing.
Payload Format
{
"event": "order.PAID",
"ref_code": "ORD-260601103045-A1B2",
"merchant_order_id": "your-internal-order-id",
"amount": 50000,
"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 | FlyPay internal order reference |
merchant_order_id | string | Your original order ID |
amount | number | Order amount in Rupiah |
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 any HTTP 2xx response within 10 seconds. Non-2xx responses and timeouts trigger 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 your merchant has a Webhook Secret configured, every V1 order webhook delivery includes X-Signature: sha256=<lowercase-hex-HMAC> over the exact raw request body. Verify it before acknowledging the event. Without a configured secret, no signature is sent; provision a secret before using webhooks for fulfillment. Legacy API callbacks use their separate legacy signature format.
$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');
const actual = Buffer.from(receivedSig);
const wanted = Buffer.from(expected);
return actual.length === wanted.length && crypto.timingSafeEqual(actual, wanted);
}
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 2xx, 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.