Singapay Home Page
Logo Icon
  1. Webhooks
  2. Transaction Money-In Expiration

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 transaction expiration notifications. Your transactions will still expire normally - this webhook only serves as a notification mechanism when unpaid money-in transactions expire.

This webhook sends a POST request to your configured transaction_expiration_notif_url when money-in transactions (Payment Link Histories, Virtual Account Transactions, or QRIS Histories) expire before payment is completed. Unlike other webhooks, this is a batch notification that can include multiple expired transactions in a single webhook call.

Transaction Notification URL Configuration

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

Request Details

When money-in transactions expire without payment, Singa Payment Gateway can send a batch 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: 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’s scheduled expiration checker, not by a user action.


Body Structure

FieldTypeMandatoryLengthDescriptionExample
statusNumericMandatory3HTTP Status Code200
successBooleanMandatory1Indicates if the webhook was sent successfullytrue
eventStringMandatory-Event type identifier (always “transaction_expiration”)transaction_expiration
timestampStringMandatory-Event timestamp in format “d M Y H:i:s”26 Dec 2025 14:00:00
merchantObjectMandatory-Merchant information-
> idNumericMandatory-Merchant ID123
> nameStringMandatory-Merchant namePT Example Indonesia
dataObjectMandatory-Container for expired transactions-
> payment_link_historiesArrayMandatory-List of expired payment link histories (transaction attempts)[]
>> idNumericMandatory-Payment link history ID456
>> reff_noStringMandatory-History reference numberPLH-20251226-ABC123
>> payment_link_idNumericMandatory-Parent payment link ID789
>> statusStringMandatory-History status (should be “expired”)expired
>> expired_atStringMandatory-Expiration datetime2025-12-26 14:00:00
> virtual_account_transactionsArrayMandatory-List of expired VA transactions (unpaid transactions)[]
>> idNumericMandatory-VA transaction ID321
>> reff_noStringMandatory-Transaction reference numberVAT-20251226-DEF456
>> virtual_account_idNumericMandatory-Parent virtual account ID654
>> statusStringMandatory-Transaction status (should be “expired”)expired
>> expired_atStringMandatory-Expiration datetime2025-12-26 14:00:00
> qris_historiesArrayMandatory-List of expired QRIS histories (transaction attempts)[]
>> idNumericMandatory-QRIS history ID987
>> reff_noStringMandatory-History reference numberQRH-20251226-GHI789
>> qris_transaction_idNumericMandatory-Parent QRIS transaction ID246
>> statusStringMandatory-History status (should be “expired”)expired
>> expired_atStringMandatory-Expiration datetime2025-12-26 14:00:00
summaryObjectMandatory-Summary of expiration counts-
> total_expiredNumericMandatory-Total count of all expired transactions15
> payment_link_histories_countNumericMandatory-Count of expired payment link histories5
> virtual_account_transactions_countNumericMandatory-Count of expired VA transactions7
> qris_histories_countNumericMandatory-Count of expired QRIS histories3

Body Example

Batch Expiration: Here’s an example with multiple transaction types expired.

{
  "status": 200,
  "success": true,
  "event": "transaction_expiration",
  "timestamp": "26 Dec 2025 14:00:00",
  "merchant": {
    "id": 123,
    "name": "PT Example Indonesia"
  },
  "data": {
    "payment_link_histories": [
      {
        "id": 456,
        "reff_no": "PLH-20251226-ABC123",
        "payment_link_id": 789,
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      },
      {
        "id": 457,
        "reff_no": "PLH-20251226-DEF456",
        "payment_link_id": 790,
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      }
    ],
    "virtual_account_transactions": [
      {
        "id": 321,
        "reff_no": "VAT-20251226-GHI789",
        "virtual_account_id": 654,
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      },
      {
        "id": 322,
        "reff_no": "VAT-20251226-JKL012",
        "virtual_account_id": 655,
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      },
      {
        "id": 323,
        "reff_no": "VAT-20251226-MNO345",
        "virtual_account_id": 656,
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      }
    ],
    "qris_histories": [
      {
        "id": 987,
        "reff_no": "QRH-20251226-PQR678",
        "qris_transaction_id": 246,
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      }
    ]
  },
  "summary": {
    "total_expired": 6,
    "payment_link_histories_count": 2,
    "virtual_account_transactions_count": 3,
    "qris_histories_count": 1
  }
}

Single Transaction Type: You might receive only one type of expired transaction.

{
  "status": 200,
  "success": true,
  "event": "transaction_expiration",
  "timestamp": "26 Dec 2025 14:00:00",
  "merchant": {
    "id": 123,
    "name": "PT Example Indonesia"
  },
  "data": {
    "payment_link_histories": [],
    "virtual_account_transactions": [
      {
        "id": 321,
        "reff_no": "VAT-20251226-GHI789",
        "virtual_account_id": 654,
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      }
    ],
    "qris_histories": []
  },
  "summary": {
    "total_expired": 1,
    "payment_link_histories_count": 0,
    "virtual_account_transactions_count": 1,
    "qris_histories_count": 0
  }
}

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/transaction-expiration {
    # 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/transaction-expiration';

if (!validateWebhookSignature($requestBody, $headers, $clientSecret, $endpoint)) {
    http_response_code(401);
    exit;
}

// Both checks passed - process webhook
$payload = json_decode($requestBody, true);
// ... process expiration 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: Transaction Money-In Expiration 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’s scheduled job.

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/transaction-expiration?param=value
  • Endpoint: /webhook/transaction-expiration?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":"transaction_expiration"}
After sorting: {"event":"transaction_expiration","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/transaction-expiration
Access Token: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Hashed Body:  5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a
Timestamp:    1695711945

StringToSign = POST:/webhook/transaction-expiration: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.

Transaction Money-In Expiration Specific Notes

Optional Webhook

This webhook is completely optional. If you don’t configure a transaction_expiration_notif_url, no webhooks will be sent for transaction expirations. Your transactions will still expire normally and change status accordingly - this webhook only serves as a notification mechanism.

When to use this webhook:

  • Track unpaid invoices and payment attempts
  • Automated follow-up for expired transactions
  • Send reminders to customers about expired payment attempts
  • Generate reports on payment conversion and abandonment rates
  • Trigger automated processes (e.g., resend payment reminders, create new transactions)
  • Monitor transaction lifecycle and payment success rates
  • Alert sales/collections team about high abandonment rates

When you can skip this webhook:

  • You check transaction status via API on-demand
  • You don’t need automated expiration notifications
  • Manual monitoring is sufficient for your use case
  • You prefer to use the product expiration webhook instead

Transaction vs Product Expiration

IMPORTANT: This webhook is different from the Product Expiration webhook. Understanding the difference is crucial:

AspectTransaction Expiration (This Webhook)Product Expiration Webhook
What expiresTransaction attempts/records (unpaid)Payment products themselves
Payment LinkPayment Link History (individual payment attempt)Payment Link (the product/page)
Virtual AccountVA Transaction (individual transaction)Virtual Account (the VA number)
QRISQRIS History (individual payment attempt)QRIS Transaction (the QR code)
Use caseTrack unpaid transactions, abandoned paymentsCleanup expired products, manage inventory
ExampleCustomer opened payment link but didn’t payPayment link reached max usage or expiry date
ReusabilityParent product can be reused after transaction expiresProduct itself is expired and cannot be reused

Example scenario to illustrate the difference:

Scenario: Payment Link with 30-minute transaction expiry

1. Customer opens payment link at 10:00 AM
   → Creates Payment Link History (transaction attempt)
   → History expires at 10:30 AM if not paid

2. At 10:30 AM - Transaction Expiration Webhook fires
   → Notifies about expired Payment Link History
   → Payment link itself is still active and can be used again

3. Customer opens same payment link again at 11:00 AM
   → Creates NEW Payment Link History
   → New history expires at 11:30 AM if not paid

4. Payment link reaches its own expiry date (e.g., end of day)
   → Product Expiration Webhook fires
   → Payment link itself is now expired
   → Can no longer be used to create new transactions

Batch Notification

This webhook is unique - it sends batch notifications containing multiple expired transactions in a single webhook call:

  • Multiple transaction types: Can include Payment Link Histories, VA Transactions, and QRIS Histories in one webhook
  • Multiple items per type: Each array can contain many expired transactions
  • Scheduled execution: Triggered by system cron job (not real-time)
  • Efficient: Reduces webhook calls by batching expirations

Example scenarios:

// Scenario 1: Only VA transactions expired
{
  "data": {
    "payment_link_histories": [],  // Empty
    "virtual_account_transactions": [/* 10 expired VA txns */],
    "qris_histories": []  // Empty
  },
  "summary": {
    "total_expired": 10,
    "virtual_account_transactions_count": 10
  }
}

// Scenario 2: Mixed expired transactions
{
  "data": {
    "payment_link_histories": [/* 3 expired PL histories */],
    "virtual_account_transactions": [/* 5 expired VA txns */],
    "qris_histories": [/* 2 expired QRIS histories */]
  },
  "summary": {
    "total_expired": 10,
    "payment_link_histories_count": 3,
    "virtual_account_transactions_count": 5,
    "qris_histories_count": 2
  }
}

Processing Batch Webhooks

When handling batch notifications, iterate through each transaction type:

$payload = json_decode($requestBody, true);

// Process expired payment link histories
foreach ($payload['data']['payment_link_histories'] as $plHistory) {
    $id = $plHistory['id'];
    $reffNo = $plHistory['reff_no'];
    $paymentLinkId = $plHistory['payment_link_id'];

    // Update your database
    updatePaymentLinkHistoryStatus($id, 'expired');

    // Notify customer about abandoned payment
    notifyCustomerAbandonedPayment($reffNo);

    // Log for analytics
    logAbandonedTransaction('payment_link', $id);

    // Trigger remarketing
    sendRemarketingEmail($paymentLinkId);
}

// Process expired VA transactions
foreach ($payload['data']['virtual_account_transactions'] as $vaTxn) {
    $id = $vaTxn['id'];
    $reffNo = $vaTxn['reff_no'];
    $vaId = $vaTxn['virtual_account_id'];

    // Update your database
    updateVATransactionStatus($id, 'expired');

    // Notify customer
    notifyCustomerVAExpired($reffNo, $vaId);

    // Log for analytics
    logAbandonedTransaction('virtual_account', $id);
}

// Process expired QRIS histories
foreach ($payload['data']['qris_histories'] as $qrisHistory) {
    $id = $qrisHistory['id'];
    $reffNo = $qrisHistory['reff_no'];
    $qrisTxnId = $qrisHistory['qris_transaction_id'];

    // Update your database
    updateQrisHistoryStatus($id, 'expired');

    // Log for analytics
    logAbandonedTransaction('qris', $id);
}

// Use summary for quick stats
$totalExpired = $payload['summary']['total_expired'];
if ($totalExpired > 100) {
    alertHighAbandonmentRate($totalExpired);
}

Transaction Types Included

The webhook can contain three types of expired transactions:

  1. Payment Link Histories (data.payment_link_histories)

    • Records: Individual payment attempts when customers open payment links
    • Fields: id, reff_no, payment_link_id, status, expired_at
    • Use case: Track abandoned payments, send follow-up reminders
    • Note: Parent payment link is still active and can be used again
  2. Virtual Account Transactions (data.virtual_account_transactions)

    • Records: Individual VA transactions that weren’t paid
    • Fields: id, reff_no, virtual_account_id, status, expired_at
    • Use case: Monitor unpaid VA transactions, follow up with customers
    • Note: Parent VA is still active and can receive new payments
  3. QRIS Histories (data.qris_histories)

    • Records: Individual QRIS payment attempts that weren’t completed
    • Fields: id, reff_no, qris_transaction_id, status, expired_at
    • Use case: Track QRIS payment abandonment, analyze conversion rates
    • Note: Parent QRIS transaction may still be active

Parent Product IDs

Important: Each expired transaction includes the parent product ID:

  • payment_link_id - ID of the parent payment link
  • virtual_account_id - ID of the parent virtual account
  • qris_transaction_id - ID of the parent QRIS transaction

Use these IDs to:

  • Fetch complete parent product details via API
  • Correlate transactions with products
  • Send new payment attempts using the same parent product
  • Track product-level conversion rates

Example:

// Get parent payment link details
$plHistory = $payload['data']['payment_link_histories'][0];
$paymentLinkId = $plHistory['payment_link_id'];

// Fetch full payment link data via API
$paymentLink = fetchPaymentLinkAPI($paymentLinkId);

// Send reminder with same payment link
sendReminderEmail(
    customer: getCustomerEmail($plHistory['reff_no']),
    paymentLink: $paymentLink['payment_url'],
    message: "Your previous payment expired. Click here to pay again!"
);

Simplified Data Structure

Note: The webhook sends minimal data for each expired transaction to keep the payload size manageable for batch notifications. The following fields are NOT included:

  • ❌ Amount/value
  • ❌ Currency
  • ❌ Customer details
  • ❌ Payment method details
  • ❌ VA number / QRIS QR string
  • ❌ Created/updated timestamps

If you need complete transaction details, use the transaction’s id or reff_no to query via API, or use the parent product ID to fetch product details.

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 14:00:00”)
  • Timezone: Server timezone (typically Asia/Jakarta / WIB)
  • Fields using this format:
    • timestamp (root level)
    • expired_at (for each transaction)

Parsing timestamps:

// PHP
$timestamp = $payload['timestamp']; // "26 Dec 2025 14:00:00"
$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 14:00:00"
date = datetime.strptime(timestamp, '%d %b %Y %H:%M:%S')
// JavaScript
const timestamp = payload.timestamp; // "26 Dec 2025 14:00:00"
const date = new Date(timestamp);

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’s scheduled cron job (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 unique reference numbers to prevent duplicate processing:

// Generate unique webhook identifier
$webhookId = $payload['event'] . '_' . $payload['merchant']['id'] . '_' . strtotime($payload['timestamp']);

// Check if already processed
if (isWebhookProcessed($webhookId)) {
    // Already processed, return success without re-processing
    return http_response_code(200);
}

// Process webhook
processExpirationBatch($payload);

// Mark as processed
markWebhookProcessed($webhookId);

Summary Statistics

Use the summary object for quick stats and alerting:

$summary = $payload['summary'];

// Log batch statistics
logTransactionExpirationBatch([
    'total' => $summary['total_expired'],
    'payment_link_histories' => $summary['payment_link_histories_count'],
    'va_transactions' => $summary['virtual_account_transactions_count'],
    'qris_histories' => $summary['qris_histories_count'],
    'timestamp' => $payload['timestamp']
]);

// Alert on high abandonment rates
if ($summary['total_expired'] > 50) {
    sendAlert("High abandonment rate: {$summary['total_expired']} transactions expired");
}

// Track abandonment patterns
if ($summary['payment_link_histories_count'] > $summary['total_expired'] * 0.7) {
    // More than 70% are payment link histories
    alertTeam("Most expirations are payment links - review link settings or send reminders");
}

Error Handling

Handle cases where arrays might be empty:

// Check if any transactions expired
if ($payload['summary']['total_expired'] === 0) {
    // No transactions expired (shouldn't normally happen, but be safe)
    return http_response_code(200);
}

// Check each transaction type before processing
if (!empty($payload['data']['payment_link_histories'])) {
    processExpiredPaymentLinkHistories($payload['data']['payment_link_histories']);
}

if (!empty($payload['data']['virtual_account_transactions'])) {
    processExpiredVATransactions($payload['data']['virtual_account_transactions']);
}

if (!empty($payload['data']['qris_histories'])) {
    processExpiredQrisHistories($payload['data']['qris_histories']);
}

Scheduling and Timing

When does this webhook fire?

  • Triggered by system cron job (scheduled task)
  • Typically runs every hour or more frequently (check with Singa support for exact schedule)
  • Not real-time - there may be a delay between expiration and notification
  • Batches all transactions that expired since last check

Important: Don’t rely on this webhook for real-time expiration detection. If you need immediate expiration handling, use individual transaction webhooks or poll transaction status via API.

Use Cases

1. Abandoned Cart Recovery:

// Send follow-up for expired transactions
foreach ($payload['data']['payment_link_histories'] as $plh) {
    $paymentLinkId = $plh['payment_link_id'];
    $customer = getCustomerByHistory($plh['reff_no']);

    if ($customer) {
        // Send reminder email
        sendEmail($customer->email, [
            'subject' => 'Complete your payment',
            'message' => 'Your payment session expired. Click here to try again!',
            'payment_url' => getPaymentLinkUrl($paymentLinkId)
        ]);

        // Track remarketing
        logRemarketingCampaign($customer->id, 'abandoned_payment');
    }
}

2. Conversion Tracking:

// Track conversion rates
foreach ($payload['data']['payment_link_histories'] as $plh) {
    $paymentLinkId = $plh['payment_link_id'];

    // Increment abandonment counter
    incrementAbandonmentCount($paymentLinkId);

    // Calculate conversion rate
    $conversionRate = calculateConversionRate($paymentLinkId);

    if ($conversionRate < 0.3) {
        // Less than 30% conversion
        alertMarketing("Low conversion rate for payment link #{$paymentLinkId}: {$conversionRate}%");
    }
}

3. Analytics and Reporting:

// Track expiration patterns
generateTransactionExpirationReport([
    'date' => $payload['timestamp'],
    'total' => $payload['summary']['total_expired'],
    'by_type' => [
        'payment_link_histories' => $payload['summary']['payment_link_histories_count'],
        'va_transactions' => $payload['summary']['virtual_account_transactions_count'],
        'qris_histories' => $payload['summary']['qris_histories_count'],
    ],
    'abandonment_rate' => calculateAbandonmentRate($payload['summary']['total_expired'])
]);

4. Collections Monitoring:

// Alert collections team
$summary = $payload['summary'];
$totalExpired = $summary['total_expired'];

// Categorize by urgency
$highPriorityCount = 0;
foreach ($payload['data']['virtual_account_transactions'] as $vaTxn) {
    if (isHighValueTransaction($vaTxn['virtual_account_id'])) {
        $highPriorityCount++;
    }
}

if ($highPriorityCount > 10) {
    alertCollectionsTeam("{$highPriorityCount} high-value transactions expired - follow up immediately");
}

// Track trends
if ($totalExpired > historicalAverage() * 1.5) {
    alertOperations("Transaction expiration rate 1.5x above average - investigate payment flow");
}

5. Customer Re-engagement:

// Segment customers by expiration behavior
foreach ($payload['data']['payment_link_histories'] as $plh) {
    $customer = getCustomerByHistory($plh['reff_no']);

    // Count abandoned payments
    $abandonmentCount = countAbandonedPayments($customer->id);

    if ($abandonmentCount >= 3) {
        // Frequent abandoner - special treatment
        triggerSpecialOffer($customer->id);
        assignAccountManager($customer->id);
    } elseif ($abandonmentCount == 1) {
        // First time abandoner - gentle reminder
        sendFriendlyReminder($customer->email);
    }
}

Best Practices

1. Combine with Product Expiration Webhook

Use both webhooks together for complete visibility:

  • Transaction Expiration: Monitor unpaid transactions, send reminders
  • Product Expiration: Clean up expired products, manage inventory

2. Set Appropriate Transaction Expiry Times

Balance between user experience and operational efficiency:

  • Payment Links: 15-30 minutes for quick transactions
  • Virtual Accounts: Several hours or days for invoices
  • QRIS: 5-10 minutes for in-store payments

3. Implement Smart Remarketing

Don’t spam customers - use intelligent follow-up:

// Track reminder frequency
$reminderCount = getReminderCount($customer->id);

if ($reminderCount < 2) {
    // Send reminder
    sendPaymentReminder($customer);
    incrementReminderCount($customer->id);
} else {
    // Too many reminders - stop
    logExcessiveReminders($customer->id);
}

4. Analyze Expiration Patterns

Use the data to improve your payment flow:

  • High expiration rate? Transaction timeout might be too short
  • Specific payment method high expiration? UI/UX issue?
  • Time-of-day patterns? Optimize reminder timing

5. Correlate with Success Webhooks

Compare expired transactions with successful payments:

$expirationRate = $totalExpired / ($totalExpired + $totalSuccessful);

if ($expirationRate > 0.5) {
    // More than 50% of transactions expire
    alertProduct("High expiration rate - review payment flow and expiry settings");
}