Skip to main content
Optional webhook — This webhook is completely optional. Unpaid transactions expire normally without it. Configure transaction_expiration_notif_url only if you need batch expiration notifications for unpaid transaction attempts. For expired products (payment links, VAs, QRIS codes), see Product Expiration.

Information

MethodPathFormatAuthentication
POSThttps://your-webhook-url/callbackjsonHMAC SHA512 Signature
SingaPay sends a POST request to your configured transaction_expiration_notif_url when money-in transactions expire before payment is completed. This is a batch notification that can include multiple expired transaction records in a single call.
Batch notification: One webhook may contain expired Payment Link Histories, Virtual Account Transactions, and QRIS Histories together. Arrays can be empty when no transactions of that type expired. If transaction_expiration_notif_url is not configured, no transaction expiration webhooks are sent.

Request Details

Headers

FieldValueTypeMandatoryDescription
Content-Typeapplication/jsonAlphabeticYesSpecifies JSON format for the request body
User-AgentSingaPaymentGateway/1.0AlphabeticYesIdentifies the source of the webhook
Acceptapplication/jsonAlphabeticYesExpected response format
X-PARTNER-IDAlphanumericYesYour API Key from the merchant dashboard
X-SignatureAlphanumericYesHMAC SHA512 signature (128 chars) for request verification
X-TimestampNumericYesUnix timestamp in seconds when the request was sent
AuthorizationBearer <random_token>AlphanumericYesSystem-generated random bearer token; used as a component in the signature
The Authorization token is a randomly generated string — not a user access token. This webhook is triggered by the system’s scheduled expiration checker, not a user API call. Extract the token as-is and use it in the string to sign. See How to Validate Signature below.

Body Parameters

status
number
required
HTTP status code. Example: 200
success
boolean
required
Indicates whether the webhook was sent successfully. Example: true
event
string
required
Event type identifier. Always "transaction_expiration" for this webhook.
timestamp
string
required
Event timestamp in format "d M Y H:i:s". Example: "26 Dec 2025 14:00:00"
merchant
object
required
Merchant information.
data
object
required
Container for expired transactions. Each array is always present and may be empty.
summary
object
required
Summary counts for the batch.

Payload Examples

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


Security and responses

Return HTTP 200 promptly after validating the request. For retry behavior, see Webhook retry mechanism. Verify every webhook using Security and signature validation. Use your configured callback path when building StringToSign. Handle duplicate deliveries idempotently using stable identifiers from the payload (for example transaction_id or reff_no).

Transaction money-in expiration specific notes

When to use this webhook

Use when

  • Track unpaid invoices and abandoned payment attempts
  • Automated follow-up and remarketing for expired sessions
  • Conversion and abandonment analytics
  • Alert collections on high expiration volumes

Skip when

  • You poll transaction status via API on demand
  • You only need product-level expiration (Product Expiration)
  • Manual monitoring is sufficient

Transaction vs product expiration

This webhook is not the same as Product Expiration. Understanding the difference is essential.
AspectTransaction expiration (this webhook)Product expiration
What expiresUnpaid transaction attempts / recordsPayment products themselves
Payment linkPayment link history (one attempt)Payment link (the product/page)
Virtual accountVA transaction (one unpaid txn)Virtual account (the VA number)
QRISQRIS history (one attempt)QRIS transaction (the QR product)
Use caseAbandoned payments, follow-up remindersProduct cleanup, inventory management
ReusabilityParent product can still be reusedProduct is expired and cannot be reused
Example timeline:
  1. Customer opens a payment link at 10:00 — creates a payment link history (expires 10:30 if unpaid).
  2. At 10:30 — this webhook reports the expired history; the payment link itself remains active.
  3. Customer opens the same link at 11:00 — a new history is created.
  4. End of day — payment link product expires — Product Expiration fires; the link can no longer accept new attempts.
For a single session expiring when a customer views a link, you may also receive Payment Link Inquiry (payment_link.inquiry.expired).

Batch notification

  • Multiple transaction types in one payload
  • Scheduled cron execution (not real-time)
  • Empty arrays are normal when a type had no expirations
Process batch
<?php
$payload = json_decode($requestBody, true);

if ($payload['summary']['total_expired'] === 0) {
    http_response_code(200);
    exit;
}

foreach ($payload['data']['payment_link_histories'] as $plHistory) {
    updatePaymentLinkHistoryStatus($plHistory['id'], 'expired');
    notifyCustomerAbandonedPayment($plHistory['reff_no']);
    logAbandonedTransaction('payment_link', $plHistory['id']);
}

foreach ($payload['data']['virtual_account_transactions'] as $vaTxn) {
    updateVATransactionStatus($vaTxn['id'], 'expired');
    notifyCustomerVAExpired($vaTxn['reff_no'], $vaTxn['virtual_account_id']);
    logAbandonedTransaction('virtual_account', $vaTxn['id']);
}

foreach ($payload['data']['qris_histories'] as $qrisHistory) {
    updateQrisHistoryStatus($qrisHistory['id'], 'expired');
    logAbandonedTransaction('qris', $qrisHistory['id']);
}

if ($payload['summary']['total_expired'] > 100) {
    alertHighAbandonmentRate($payload['summary']['total_expired']);
}

http_response_code(200);
echo json_encode(['status' => 'success']);
?>

Transaction types included

ArrayRecord typeParent ID fieldNote
data.payment_link_historiesUnpaid payment link attemptpayment_link_idParent link stays active
data.virtual_account_transactionsUnpaid VA transactionvirtual_account_idParent VA stays active
data.qris_historiesUnpaid QRIS attemptqris_transaction_idParent QRIS may stay active
Use parent IDs to fetch full product details via API and send new payment attempts on the same product.
Fetch parent payment link
$plHistory = $payload['data']['payment_link_histories'][0];
$paymentLink = fetchPaymentLinkAPI($plHistory['payment_link_id']);

sendReminderEmail(
    getCustomerEmail($plHistory['reff_no']),
    $paymentLink['payment_url'],
    'Your previous payment session expired. Click here to pay again!'
);

Simplified payload

Minimal fields per transaction to keep batch payloads small. Not included: amount, currency, customer details, payment method, VA number, QR string, created/updated timestamps. Query by id, reff_no, or parent product ID via API when you need full details.

Timestamp format

FieldFormatExample
timestamp (root)d M Y H:i:s26 Dec 2025 14:00:00
expired_at (per item)Y-m-d H:i:s2025-12-26 14:00:00
For signature validation, use X-Timestamp (Unix seconds in the header).

Idempotency

$webhookId = $payload['event'] . '_' . $payload['merchant']['id'] . '_' . strtotime($payload['timestamp']);

if (isWebhookProcessed($webhookId)) {
    http_response_code(200);
    exit;
}

processTransactionExpirationBatch($payload);
markWebhookProcessed($webhookId);

Summary statistics

$summary = $payload['summary'];

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'],
]);

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

Scheduling and timing

Triggered by a system cron job (frequency varies — confirm with SingaPay support). Not real-time; allow delay between expiration and notification. For immediate handling, poll via API or use individual transaction webhooks.
  • Use both optional expiration webhooks — transaction expiration for abandonment; product expiration for inventory cleanup.
  • Set sensible session timeouts — e.g. 15–30 minutes for payment links, longer for VA invoices, shorter for in-store QRIS.
  • Limit remarketing frequency — avoid spamming customers who abandon repeatedly.
  • Correlate with success webhooks — compare expired vs paid volumes to tune expiry settings and UX.