Singapay Home Page
Logo Icon
  1. Webhooks
  2. Payment Link Inquiry

Information

MethodPathFormatAuthentication
POSThttps://your-webhook-url/callbackjsonHMAC 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:

  • A customer opens/views a payment link (payment_link.inquiry)
  • A payment link history record expires (payment_link.inquiry.expired)
Transaction Notification URL Configuration

The payment_link_inquiry_notif_url configuration can be accessed from the settings page as shown in the screenshot above.

Request Details

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.

Headers Structure

FieldValueTypeMandatoryLengthDescriptionExample
Content-Typeapplication/jsonAlphabeticMandatorySpecifies JSON format for the request bodyapplication/json
User-AgentSingaPaymentGateway/1.0AlphabeticMandatoryIdentifies the source of the webhookSingaPaymentGateway/1.0
X-SignatureAlphanumericOptional*128HMAC SHA512 signature for request verification (included when signature security is enabled)5f4dcc3b5aa765d61d8327deb882cf99…
X-TimestampNumericOptional*10Unix timestamp in seconds when the request was sent (included when signature security is enabled)1695711945
AuthorizationBearer <random_token>AlphanumericOptional*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.


Body Structure

FieldTypeMandatoryLengthDescriptionExample
statusNumericMandatory3HTTP Status Code200
successBooleanMandatory1Indicates if the webhook was sent successfullytrue
eventStringMandatory-Event type identifierpayment_link.inquiry
timestampStringMandatory-Event timestamp in format “d M Y H:i:s”26 Dec 2025 13:35:45
dataObjectMandatory-Container for payment link details-
> payment_link_historyObjectMandatory-Current payment link history (transaction attempt)-
>> idNumericMandatory-Payment link history ID12345
>> reff_noStringMandatory-Unique reference number for this historyPLH-20251226-ABC123
>> statusStringMandatory-History statuspending
>> amountObjectMandatory-Transaction amount-
>>> valueNumericMandatory-Amount value50000
>>> currencyStringMandatory3Currency codeIDR
>> vendor_feeNumericConditional-Vendor fee (null if not calculated yet)1500 or null
>> our_marginNumericConditional-Platform margin (null if not calculated yet)500 or null
>> net_amountNumericConditional-Net amount (null if not calculated yet)48000 or null
>> payment_method_nameStringConditional-Selected payment method name (null if not selected)QRIS or null
>> payment_method_valueStringConditional-Payment method value (null if not selected)qris or null
>> payment_method_additionalObject/NullConditional-Additional payment method info{} or null
>> customer_nameStringConditional-Customer name (null if not provided yet)John Doe or null
>> customer_emailStringConditional-Customer email (null if not provided yet)john@example.com or null
>> customer_phoneStringConditional-Customer phone (null if not provided yet)081234567890 or null
>> ip_addressStringConditional-Customer IP address103.123.45.67 or null
>> expired_atStringConditional-History expiration time2025-12-26 14:35:45 or null
>> created_atStringMandatory-History creation time2025-12-26 13:35:45
>> updated_atStringMandatory-History last update time2025-12-26 13:35:45
> payment_linkObjectMandatory-Parent payment link details-
>> idNumericMandatory-Payment link ID678
>> reff_noStringMandatory-Payment link reference numberPL-20251220-XYZ789
>> titleStringMandatory-Payment link titleDonasi Amal
>> descriptionStringConditional-Payment link descriptionDonasi untuk kegiatan sosial or null
>> statusStringMandatory-Payment link statusactive
>> total_amountObjectMandatory-Payment link amount-
>>> valueNumericMandatory-Amount value50000
>>> currencyStringMandatory3Currency codeIDR
>> max_usageNumericConditional-Maximum usage limit (null if unlimited)100 or null
>> current_usageNumericMandatory-Current usage count25
>> payment_urlStringMandatory-Full payment link URLhttps://pay.singapay.id/pl/abc123
>> required_customer_detailBooleanMandatory-Whether customer details are requiredtrue or false
>> expired_atStringConditional-Payment link expiration2025-12-31 23:59:59 or null
>> created_atStringMandatory-Payment link creation time2025-12-20 10:00:00
>> updated_atStringMandatory-Payment link last update time2025-12-26 13:35:45

Body Example

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"
    }
  }
}

Security Mechanisms

Overview

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.

Security Options

Option 1: IP Whitelist (Simpler Approach)

This is the simplest security method where you restrict webhook access to only authorized IP addresses from Singa Payment Gateway.

How it works:

  • Configure your firewall or application to only accept requests from specific IP addresses
  • Singa Payment Gateway will provide you with our official IP addresses
  • Any requests from unauthorized IPs will be automatically rejected

Pros:

  • ✅ Simple to implement
  • ✅ No complex cryptographic operations required
  • ✅ Works well for basic security needs

Cons:

  • ❌ Less secure if IP addresses are compromised
  • ❌ Requires manual updates if our IPs change
  • ❌ Cannot verify request integrity (body tampering)

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:

  1. The request actually comes from Singa Payment Gateway
  2. The request body has not been tampered with during transmission

How it works:

  • Singa Payment Gateway signs each webhook request using HMAC-SHA512
  • You validate the signature using your Client Secret
  • Only requests with valid signatures are processed

Pros:

  • ✅ Highest security level
  • ✅ Verifies both authenticity and integrity
  • ✅ Protects against man-in-the-middle attacks
  • ✅ No dependency on IP addresses

Cons:

  • ❌ Requires implementation of signature validation logic
  • ❌ Slightly more complex to implement

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:

  1. First layer: Check IP whitelist (fast, blocks unauthorized IPs immediately)
  2. Second layer: Validate signature (ensures request integrity)

Benefits:

  • ✅ Defense in depth - multiple security layers
  • ✅ Protection against both unauthorized access and tampering
  • ✅ Industry best practice for webhook security

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 ...
?>

Which Option Should You Choose?

Recommended ApproachReason
IP Whitelist onlySimple to implement, easy to debug, suitable for testing environments
Signature validation onlyHigher security, good for testing signature implementation
IP Whitelist + SignatureMaximum security, industry best practice, recommended for production
IP Whitelist + Signature + Timestamp validationAdditional protection against replay attacks for high-security requirements

Getting Singa Payment Gateway IP Addresses

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.


Overview

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.

Signature Algorithm: HMAC SHA512

The signature is created using a multi-step process that combines the request method, endpoint, access token, hashed body, and timestamp.

Reference Implementation:

  • Trait: App\Traits\GeneratesCallbackSignature
  • Main method: GeneratesCallbackSignature::generateHeadersCallback() (lines 30-81)
  • Body hashing: GeneratesCallbackSignature::hashNormalizedJson() (lines 90-118)
  • Recursive sorting: GeneratesCallbackSignature::sortRecursive() (lines 129-143)
  • Endpoint extraction: GeneratesCallbackSignature::extractEndpoint() (lines 154-167)

Step-by-Step Validation Guide

Step 1: Extract Headers

Extract the following headers from the incoming webhook request:

  • X-Signature - The signature to validate
  • X-Timestamp - Unix timestamp (in seconds)
  • Authorization - Bearer token (extract the token part)

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.

Step 2: Get Your Client Secret

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.

Step 3: Extract Endpoint Path

Extract the endpoint path from your webhook URL. For example:

  • Full URL: https://yourdomain.com/webhook/payment-link-inquiry?param=value
  • Endpoint: /webhook/payment-link-inquiry?param=value

The endpoint includes the path and any query parameters.

Step 4: Normalize and Hash the Request Body

The request body must be normalized before hashing to ensure consistent results:

  1. Parse JSON: Decode the JSON body into an object/array
  2. Sort Keys Recursively: Sort all object keys alphabetically at every level
  3. Re-encode JSON: Encode back to JSON with these flags:
    • JSON_UNESCAPED_UNICODE - Don’t escape Unicode characters
    • JSON_UNESCAPED_SLASHES - Don’t escape forward slashes
  4. Hash with SHA-256: Generate SHA-256 hash of the normalized JSON

Example:

Original JSON: {"status":200,"success":true,"event":"payment_link.inquiry"}
After sorting: {"event":"payment_link.inquiry","status":200,"success":true}
SHA-256 Hash: 5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a

Step 5: Build the String to Sign

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

Step 6: Generate HMAC SHA512 Signature

Use your Client Secret as the HMAC key and hash the string to sign:

Calculated Signature = HMAC-SHA512(StringToSign, Client Secret)

Step 7: Compare Signatures

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
}

Important Notes

Best Practices

  • Always validate signatures: Never process webhook requests without validating the signature first
  • Use constant-time comparison: Prevents timing attacks (use hash_equals(), crypto.timingSafeEqual(), or hmac.compare_digest())
  • Validate timestamp: Check that the timestamp is recent (within 5 minutes) to prevent replay attacks
  • Preserve raw body: Don’t parse the JSON before validation - use the raw request body for hashing
  • Case sensitivity: The signature is case-sensitive (lowercase hex)
  • Character encoding: Use UTF-8 encoding for all strings
  • HMAC key: Always use your client_secret as the HMAC key
  • Secure storage: Store your Client Secret securely (environment variables, secure vault)
  • HTTPS only: Always use HTTPS for your webhook endpoints

Common Mistakes to Avoid

  • ❌ Not sorting JSON keys before hashing (order matters!)
  • ❌ Using wrong hash algorithm (must use SHA-256 for body, SHA-512 for signature)
  • ❌ Wrong separator in string to sign (must use :, not _ or -)
  • ❌ Using API Key instead of Client Secret
  • ❌ Not preserving raw request body (parsing JSON before validation)
  • ❌ Using simple string comparison instead of constant-time comparison
  • ❌ Wrong order in string to sign (must be: METHOD:ENDPOINT:TOKEN:HASH:TIMESTAMP)
  • ❌ Forgetting to extract Bearer token from Authorization header
  • ❌ Not including query parameters in endpoint path
  • ❌ Processing webhook without validating signature first

Response Requirements

Your webhook endpoint must return an appropriate HTTP response:

Success Response

When the webhook is processed successfully:

Status Code: 200 OK

{
  "status": "success"
}

Error Responses

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.

Optional Webhook

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:

  • Track engagement metrics (how many people view your payment links)
  • Monitor conversion funnel (views vs completed payments)
  • Send internal notifications when high-value payment links are accessed
  • Analyze customer behavior patterns
  • Implement fraud detection based on access patterns
  • Trigger marketing automation when links are viewed but not completed

When you can skip this webhook:

  • You only care about completed payments (use the payment link transaction webhook instead)
  • You don’t need inquiry/view tracking
  • Simple payment link usage without analytics requirements

Event Types

The webhook supports two event types:

  1. payment_link.inquiry - Triggered when a customer opens/views a payment link

    • Occurs when someone accesses the payment link URL
    • Creates a new payment_link_history record with status pending
    • Customer may or may not complete the payment
  2. payment_link.inquiry.expired - Triggered when a payment link history record expires

    • Occurs when the inquiry session timeout is reached
    • The payment_link_history status changes to expired
    • Customer did not complete payment within the allowed time

Understanding the Data Structure

The webhook contains two main objects:

  1. payment_link_history - Represents the current inquiry/transaction attempt

    • Unique for each time someone opens the payment link
    • Status can be: pending, expired, paid, failed
    • Contains customer information (if provided)
    • Contains selected payment method (if chosen)
  2. payment_link - The parent payment link details

    • Remains constant across multiple inquiries
    • Shows usage tracking (current_usage / max_usage)
    • Contains payment link configuration

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 Handling

Customer data in payment_link_history may be null depending on when the webhook is triggered:

  • On initial inquiry: Customer fields are typically null (name, email, phone)
  • After customer fills form: Customer fields contain actual data
  • Payment method selection: payment_method_name and payment_method_value populated after selection

Always 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);
}

Timestamp Format

Important: This webhook uses human-readable timestamp format, not Unix timestamps:

  • Format: d M Y H:i:s (e.g., “26 Dec 2025 13:35:45”)
  • Timezone: Server timezone (typically Asia/Jakarta / WIB)
  • Fields using this format:
    • timestamp (root level)
    • payment_link_history.created_at
    • payment_link_history.updated_at
    • payment_link_history.expired_at
    • payment_link.created_at
    • payment_link.updated_at
    • payment_link.expired_at

Parsing 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.

Access Token Format

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?

  • This webhook is triggered by the system (not by a user API call)
  • There’s no user session or JWT token to include
  • The random token is generated using Str::random(32) for each webhook

For signature validation:

  • Extract the token from the header normally
  • Use it in the string to sign
  • The signature will still be valid because it’s signed with your client secret

Idempotency

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);

Usage Tracking

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");
    }
}

Expiration Handling

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
}