
Webhooks
The Payment Link Inquiry Webhooks API provides optional real-time notifications when someone opens or views a payment link. This webhook allows you to track engagement with your payment links, monitor when customers start the payment process, and analyze payment link usage patterns.
| Method | Path | Format | Authentication |
|---|---|---|---|
| POST | https://your-webhook-url/callback | json | HMAC SHA512 Signature |
Important: This webhook is completely optional. You can choose not to configure this webhook if you don’t need inquiry notifications. The payment link will work normally without this webhook - it only serves as a notification mechanism when someone opens/views your payment link.
This webhook sends a POST request to your configured payment_link_inquiry_notif_url when:
payment_link.inquiry)payment_link.inquiry.expired)
The payment_link_inquiry_notif_url configuration can be accessed from the settings page as shown in the screenshot above.
When someone opens a payment link, Singa Payment Gateway can send a webhook notification to your registered callback URL. The request includes security headers for verification.
| Field | Value | Type | Mandatory | Length | Description | Example |
|---|---|---|---|---|---|---|
| Content-Type | application/json | Alphabetic | Mandatory | Specifies JSON format for the request body | application/json | |
| User-Agent | SingaPaymentGateway/1.0 | Alphabetic | Mandatory | Identifies the source of the webhook | SingaPaymentGateway/1.0 | |
| X-Signature | Alphanumeric | Optional* | 128 | HMAC SHA512 signature for request verification (included when signature security is enabled) | 5f4dcc3b5aa765d61d8327deb882cf99… | |
| X-Timestamp | Numeric | Optional* | 10 | Unix timestamp in seconds when the request was sent (included when signature security is enabled) | 1695711945 | |
| Authorization | Bearer <random_token> | Alphanumeric | Optional* | Bearer token with random value (system-generated, not user token) | Bearer a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 |
* Note: Headers marked as “Optional” are included when you enable signature-based security. See the Security Mechanisms section below to understand the different security options available.
Authorization Note: Unlike other webhooks, the access token in the Authorization header is a randomly generated string (not a user access token) because this webhook is triggered by the system, not by a user action.
| Field | Type | Mandatory | Length | Description | Example |
|---|---|---|---|---|---|
| status | Numeric | Mandatory | 3 | HTTP Status Code | 200 |
| success | Boolean | Mandatory | 1 | Indicates if the webhook was sent successfully | true |
| event | String | Mandatory | - | Event type identifier | payment_link.inquiry |
| timestamp | String | Mandatory | - | Event timestamp in format “d M Y H:i:s” | 26 Dec 2025 13:35:45 |
| data | Object | Mandatory | - | Container for payment link details | - |
| > payment_link_history | Object | Mandatory | - | Current payment link history (transaction attempt) | - |
| >> id | Numeric | Mandatory | - | Payment link history ID | 12345 |
| >> reff_no | String | Mandatory | - | Unique reference number for this history | PLH-20251226-ABC123 |
| >> status | String | Mandatory | - | History status | pending |
| >> amount | Object | Mandatory | - | Transaction amount | - |
| >>> value | Numeric | Mandatory | - | Amount value | 50000 |
| >>> currency | String | Mandatory | 3 | Currency code | IDR |
| >> vendor_fee | Numeric | Conditional | - | Vendor fee (null if not calculated yet) | 1500 or null |
| >> our_margin | Numeric | Conditional | - | Platform margin (null if not calculated yet) | 500 or null |
| >> net_amount | Numeric | Conditional | - | Net amount (null if not calculated yet) | 48000 or null |
| >> payment_method_name | String | Conditional | - | Selected payment method name (null if not selected) | QRIS or null |
| >> payment_method_value | String | Conditional | - | Payment method value (null if not selected) | qris or null |
| >> payment_method_additional | Object/Null | Conditional | - | Additional payment method info | {} or null |
| >> customer_name | String | Conditional | - | Customer name (null if not provided yet) | John Doe or null |
| >> customer_email | String | Conditional | - | Customer email (null if not provided yet) | john@example.com or null |
| >> customer_phone | String | Conditional | - | Customer phone (null if not provided yet) | 081234567890 or null |
| >> ip_address | String | Conditional | - | Customer IP address | 103.123.45.67 or null |
| >> expired_at | String | Conditional | - | History expiration time | 2025-12-26 14:35:45 or null |
| >> created_at | String | Mandatory | - | History creation time | 2025-12-26 13:35:45 |
| >> updated_at | String | Mandatory | - | History last update time | 2025-12-26 13:35:45 |
| > payment_link | Object | Mandatory | - | Parent payment link details | - |
| >> id | Numeric | Mandatory | - | Payment link ID | 678 |
| >> reff_no | String | Mandatory | - | Payment link reference number | PL-20251220-XYZ789 |
| >> title | String | Mandatory | - | Payment link title | Donasi Amal |
| >> description | String | Conditional | - | Payment link description | Donasi untuk kegiatan sosial or null |
| >> status | String | Mandatory | - | Payment link status | active |
| >> total_amount | Object | Mandatory | - | Payment link amount | - |
| >>> value | Numeric | Mandatory | - | Amount value | 50000 |
| >>> currency | String | Mandatory | 3 | Currency code | IDR |
| >> max_usage | Numeric | Conditional | - | Maximum usage limit (null if unlimited) | 100 or null |
| >> current_usage | Numeric | Mandatory | - | Current usage count | 25 |
| >> payment_url | String | Mandatory | - | Full payment link URL | https://pay.singapay.id/pl/abc123 |
| >> required_customer_detail | Boolean | Mandatory | - | Whether customer details are required | true or false |
| >> expired_at | String | Conditional | - | Payment link expiration | 2025-12-31 23:59:59 or null |
| >> created_at | String | Mandatory | - | Payment link creation time | 2025-12-20 10:00:00 |
| >> updated_at | String | Mandatory | - | Payment link last update time | 2025-12-26 13:35:45 |
Inquiry Event: Here’s an example when a customer opens a payment link.
{
"status": 200,
"success": true,
"event": "payment_link.inquiry",
"timestamp": "26 Dec 2025 13:35:45",
"data": {
"payment_link_history": {
"id": 12345,
"reff_no": "PLH-20251226-ABC123",
"status": "pending",
"amount": {
"value": 50000,
"currency": "IDR"
},
"vendor_fee": null,
"our_margin": null,
"net_amount": null,
"payment_method_name": null,
"payment_method_value": null,
"payment_method_additional": null,
"customer_name": null,
"customer_email": null,
"customer_phone": null,
"ip_address": "103.123.45.67",
"expired_at": "2025-12-26 14:35:45",
"created_at": "2025-12-26 13:35:45",
"updated_at": "2025-12-26 13:35:45"
},
"payment_link": {
"id": 678,
"reff_no": "PL-20251220-XYZ789",
"title": "Donasi Amal",
"description": "Donasi untuk kegiatan sosial",
"status": "active",
"total_amount": {
"value": 50000,
"currency": "IDR"
},
"max_usage": 100,
"current_usage": 25,
"payment_url": "https://pay.singapay.id/pl/abc123",
"required_customer_detail": true,
"expired_at": "2025-12-31 23:59:59",
"created_at": "2025-12-20 10:00:00",
"updated_at": "2025-12-26 13:35:45"
}
}
}
Expired Event: Here’s an example when a payment link history expires.
{
"status": 200,
"success": true,
"event": "payment_link.inquiry.expired",
"timestamp": "26 Dec 2025 14:35:45",
"data": {
"payment_link_history": {
"id": 12345,
"reff_no": "PLH-20251226-ABC123",
"status": "expired",
"amount": {
"value": 50000,
"currency": "IDR"
},
"vendor_fee": null,
"our_margin": null,
"net_amount": null,
"payment_method_name": null,
"payment_method_value": null,
"payment_method_additional": null,
"customer_name": null,
"customer_email": null,
"customer_phone": null,
"ip_address": "103.123.45.67",
"expired_at": "2025-12-26 14:35:45",
"created_at": "2025-12-26 13:35:45",
"updated_at": "2025-12-26 14:35:45"
},
"payment_link": {
"id": 678,
"reff_no": "PL-20251220-XYZ789",
"title": "Donasi Amal",
"description": "Donasi untuk kegiatan sosial",
"status": "active",
"total_amount": {
"value": 50000,
"currency": "IDR"
},
"max_usage": 100,
"current_usage": 25,
"payment_url": "https://pay.singapay.id/pl/abc123",
"required_customer_detail": true,
"expired_at": "2025-12-31 23:59:59",
"created_at": "2025-12-20 10:00:00",
"updated_at": "2025-12-26 14:35:45"
}
}
}
To ensure the security and authenticity of webhook requests from Singa Payment Gateway, we provide two recommended security mechanisms. You can choose one or combine both for maximum protection.
Important Note: Signature validation is optional but highly recommended. You can secure your webhook endpoint using either IP whitelisting, signature validation, or both methods together.
This is the simplest security method where you restrict webhook access to only authorized IP addresses from Singa Payment Gateway.
How it works:
Pros:
Cons:
Implementation Example (Nginx):
location /webhook/payment-link-inquiry {
# Only allow Singa Payment Gateway IPs
allow 103.xxx.xxx.xxx; # Replace with actual IPs from Singa
allow 103.xxx.xxx.xxx; # Replace with actual IPs from Singa
deny all;
proxy_pass http://your-backend;
}
Implementation Example (PHP):
<?php
// Define allowed IPs (get these from Singa Payment Gateway)
$allowedIPs = [
'103.xxx.xxx.xxx',
'103.xxx.xxx.xxx',
];
$requestIP = $_SERVER['REMOTE_ADDR'];
if (!in_array($requestIP, $allowedIPs)) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Access denied']);
exit;
}
// Process webhook...
?>
This method uses cryptographic signatures to verify that:
How it works:
Pros:
Cons:
When to use: Production environments, handling sensitive data, or when maximum security is required.
See the detailed implementation guide in the “How to Validate Signature” section below.
For production environments, we strongly recommend using both IP whitelisting and signature validation together.
Implementation order:
Benefits:
Example Implementation:
<?php
// Layer 1: IP Whitelist
$allowedIPs = ['103.xxx.xxx.xxx', '103.xxx.xxx.xxx'];
$requestIP = $_SERVER['REMOTE_ADDR'];
if (!in_array($requestIP, $allowedIPs)) {
http_response_code(403);
exit;
}
// Layer 2: Signature Validation
$requestBody = file_get_contents('php://input');
$headers = getallheaders();
$clientSecret = 'your-client-secret';
$endpoint = '/webhook/payment-link-inquiry';
if (!validateWebhookSignature($requestBody, $headers, $clientSecret, $endpoint)) {
http_response_code(401);
exit;
}
// Both checks passed - process webhook
$payload = json_decode($requestBody, true);
// ... process inquiry notification ...
?>
| Recommended Approach | Reason |
|---|---|
| IP Whitelist only | Simple to implement, easy to debug, suitable for testing environments |
| Signature validation only | Higher security, good for testing signature implementation |
| IP Whitelist + Signature ⭐ | Maximum security, industry best practice, recommended for production |
| IP Whitelist + Signature + Timestamp validation | Additional protection against replay attacks for high-security requirements |
To implement IP whitelisting, contact our support team or check your merchant dashboard for the official list of Singa Payment Gateway IP addresses.
Note: We will notify you in advance if our IP addresses change.
The X-Signature header is a security mechanism that ensures the webhook request is authentic and comes from Singa Payment Gateway. This signature is generated using HMAC SHA512 algorithm.
Implementation Note: Payment Link Inquiry webhooks use the signature generation method from the GeneratesCallbackSignature trait, which is the standard signature implementation across most webhooks.
Note: While signature validation is optional, we strongly recommend implementing it, especially for production environments, to ensure maximum security and data integrity.
The signature is created using a multi-step process that combines the request method, endpoint, access token, hashed body, and timestamp.
Reference Implementation:
App\Traits\GeneratesCallbackSignatureGeneratesCallbackSignature::generateHeadersCallback() (lines 30-81)GeneratesCallbackSignature::hashNormalizedJson() (lines 90-118)GeneratesCallbackSignature::sortRecursive() (lines 129-143)GeneratesCallbackSignature::extractEndpoint() (lines 154-167)Extract the following headers from the incoming webhook request:
Important: The access token in this webhook is a randomly generated string, not a user access token, since the webhook is triggered by the system.
Retrieve your Client Secret from the merchant dashboard. This is the same secret used for API authentication and is required as the HMAC key for signature verification.
Important: The Client Secret must be kept secure and never exposed in client-side code or logs.
Extract the endpoint path from your webhook URL. For example:
https://yourdomain.com/webhook/payment-link-inquiry?param=value/webhook/payment-link-inquiry?param=valueThe endpoint includes the path and any query parameters.
The request body must be normalized before hashing to ensure consistent results:
JSON_UNESCAPED_UNICODE - Don’t escape Unicode charactersJSON_UNESCAPED_SLASHES - Don’t escape forward slashesExample:
Original JSON: {"status":200,"success":true,"event":"payment_link.inquiry"}
After sorting: {"event":"payment_link.inquiry","status":200,"success":true}
SHA-256 Hash: 5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a
Concatenate the following values with colon (:) as separator:
StringToSign = METHOD + ":" + ENDPOINT + ":" + ACCESS_TOKEN + ":" + HASHED_BODY + ":" + TIMESTAMP
Example:
Method: POST
Endpoint: /webhook/payment-link-inquiry
Access Token: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Hashed Body: 5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a
Timestamp: 1695711945
StringToSign = POST:/webhook/payment-link-inquiry:a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6:5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a:1695711945
Use your Client Secret as the HMAC key and hash the string to sign:
Calculated Signature = HMAC-SHA512(StringToSign, Client Secret)
Compare the calculated signature with the signature from the X-Signature header using a constant-time comparison to prevent timing attacks.
Important: Use hash_equals() in PHP, crypto.timingSafeEqual() in Node.js, or hmac.compare_digest() in Python for secure comparison.
if (hash_equals($calculatedSignature, $receivedSignature)) {
// Signature is valid - process webhook
} else {
// Signature is invalid - reject request
}
Best Practices
hash_equals(), crypto.timingSafeEqual(), or hmac.compare_digest())client_secret as the HMAC keyCommon Mistakes to Avoid
:, not _ or -)Your webhook endpoint must return an appropriate HTTP response:
When the webhook is processed successfully:
Status Code: 200 OK
{
"status": "success"
}
Invalid Signature (401 Unauthorized):
{
"status": "error",
"message": "Invalid signature"
}
Processing Error (500 Internal Server Error):
{
"status": "error",
"message": "Failed to process webhook"
}
Important: Singa Payment Gateway will retry failed webhooks (non-200 responses) up to 3 times with exponential backoff.
This webhook is completely optional. If you don’t configure a payment_link_inquiry_notif_url, no webhooks will be sent for inquiry events. The payment link will function normally - customers can still view and pay through the link.
When to use this webhook:
When you can skip this webhook:
The webhook supports two event types:
payment_link.inquiry - Triggered when a customer opens/views a payment link
payment_link_history record with status pendingpayment_link.inquiry.expired - Triggered when a payment link history record expires
payment_link_history status changes to expiredThe webhook contains two main objects:
payment_link_history - Represents the current inquiry/transaction attempt
pending, expired, paid, failedpayment_link - The parent payment link details
current_usage / max_usage)Example use case:
$payload = json_decode($requestBody, true);
// Track new inquiry
if ($payload['event'] === 'payment_link.inquiry') {
$historyId = $payload['data']['payment_link_history']['id'];
$paymentLinkId = $payload['data']['payment_link']['id'];
$currentUsage = $payload['data']['payment_link']['current_usage'];
$maxUsage = $payload['data']['payment_link']['max_usage'];
// Log analytics
logInquiry($historyId, $paymentLinkId);
// Alert if usage is near limit
if ($maxUsage && $currentUsage >= $maxUsage * 0.9) {
sendAlert("Payment link is 90% full");
}
}
// Handle expired inquiry
if ($payload['event'] === 'payment_link.inquiry.expired') {
$historyReffNo = $payload['data']['payment_link_history']['reff_no'];
// Log abandonment for conversion tracking
logAbandonedPayment($historyReffNo);
// Trigger follow-up email if customer info was provided
if (!empty($payload['data']['payment_link_history']['customer_email'])) {
sendFollowUpEmail($payload['data']['payment_link_history']['customer_email']);
}
}
Customer data in payment_link_history may be null depending on when the webhook is triggered:
null (name, email, phone)payment_method_name and payment_method_value populated after selectionAlways check for null values:
$customerName = $payload['data']['payment_link_history']['customer_name'] ?? 'Unknown';
$customerEmail = $payload['data']['payment_link_history']['customer_email'];
if ($customerEmail !== null) {
// Send email notification
sendEmail($customerEmail, "Thank you for viewing our payment link");
}
$paymentMethod = $payload['data']['payment_link_history']['payment_method_name'];
if ($paymentMethod !== null) {
// Customer has selected a payment method
trackMethodSelection($paymentMethod);
}
Important: This webhook uses human-readable timestamp format, not Unix timestamps:
d M Y H:i:s (e.g., “26 Dec 2025 13:35:45”)timestamp (root level)payment_link_history.created_atpayment_link_history.updated_atpayment_link_history.expired_atpayment_link.created_atpayment_link.updated_atpayment_link.expired_atParsing timestamps:
// PHP
$timestamp = $payload['timestamp']; // "26 Dec 2025 13:35:45"
$date = DateTime::createFromFormat('d M Y H:i:s', $timestamp);
// Or use Carbon (Laravel)
$date = \Carbon\Carbon::createFromFormat('d M Y H:i:s', $timestamp);
# Python
from datetime import datetime
timestamp = payload['timestamp'] # "26 Dec 2025 13:35:45"
date = datetime.strptime(timestamp, '%d %b %Y %H:%M:%S')
// JavaScript
const timestamp = payload.timestamp; // "26 Dec 2025 13:35:45"
const date = new Date(timestamp);
Note: Unlike other webhooks (e.g., disbursement) which use Unix milliseconds in the body, this webhook uses human-readable datetime strings for better readability.
The Authorization header contains a randomly generated token, not a user access token:
// This is NOT a user JWT token - it's a random string
Authorization: Bearer a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Why random?
Str::random(32) for each webhookFor signature validation:
Use the reff_no field to detect duplicate webhooks:
$historyReffNo = $payload['data']['payment_link_history']['reff_no'];
// Check if already processed
$existingRecord = findInquiryByReffNo($historyReffNo);
if ($existingRecord) {
// Already processed, return success without re-processing
return http_response_code(200);
}
// Process new inquiry
processNewInquiry($payload);
Monitor payment link usage with the current_usage and max_usage fields:
$paymentLink = $payload['data']['payment_link'];
$currentUsage = $paymentLink['current_usage']; // e.g., 25
$maxUsage = $paymentLink['max_usage']; // e.g., 100 (or null if unlimited)
if ($maxUsage !== null) {
$usagePercentage = ($currentUsage / $maxUsage) * 100;
if ($usagePercentage >= 90) {
sendAlert("Payment link '{$paymentLink['title']}' is at {$usagePercentage}% capacity");
}
if ($currentUsage >= $maxUsage) {
sendAlert("Payment link '{$paymentLink['title']}' has reached maximum usage");
}
}
Both the payment link and individual histories can expire:
// Check payment link expiration
$paymentLinkExpiredAt = $payload['data']['payment_link']['expired_at'];
if ($paymentLinkExpiredAt !== null) {
$expiryDate = DateTime::createFromFormat('Y-m-d H:i:s', $paymentLinkExpiredAt);
if ($expiryDate < new DateTime()) {
// Payment link has expired
logExpiredLink($payload['data']['payment_link']['id']);
}
}
// Check history expiration (for individual inquiry session)
$historyExpiredAt = $payload['data']['payment_link_history']['expired_at'];
if ($historyExpiredAt !== null) {
$expiryDate = DateTime::createFromFormat('Y-m-d H:i:s', $historyExpiredAt);
// This is when the current inquiry session will expire
}