Skip to main content
Optional webhook — This webhook is completely optional. Products expire normally without it. Configure product_expiration_notif_url only if you need batch expiration notifications for products (links, VAs, QR codes). For expired unpaid transaction attempts, see Transaction Money-In Expiration.

Information

MethodPathFormatAuthentication
POSThttps://your-webhook-url/callbackjsonHMAC SHA512 Signature
SingaPay sends a POST request to your configured product_expiration_notif_url when payment products reach their expiration time. Unlike transaction webhooks, this is a batch notification that can include multiple expired products in a single call.
Batch notification: One webhook may contain expired Payment Links, Virtual Accounts, and QRIS transactions together. Arrays can be empty when no products of that type expired. If product_expiration_notif_url is not configured, no 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 "product_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 products. Each array is always present and may be empty.
summary
object
required
Summary counts for the batch.

Payload Examples

{
  "status": 200,
  "success": true,
  "event": "product_expiration",
  "timestamp": "26 Dec 2025 14:00:00",
  "merchant": {
    "id": 123,
    "name": "PT Example Indonesia"
  },
  "data": {
    "payment_links": [
      {
        "id": 456,
        "reff_no": "PL-20251220-XYZ789",
        "title": "Donasi Amal",
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      },
      {
        "id": 457,
        "reff_no": "PL-20251221-ABC123",
        "title": "Pembayaran Tagihan",
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      }
    ],
    "virtual_accounts": [
      {
        "id": 789,
        "reff_no": "VA-20251226-ABC123",
        "virtual_account_number": "7872955146576837",
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      },
      {
        "id": 790,
        "reff_no": "VA-20251226-DEF456",
        "virtual_account_number": "7872955146576838",
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      },
      {
        "id": 791,
        "reff_no": "VA-20251226-GHI789",
        "virtual_account_number": "7872955146576839",
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      }
    ],
    "qris_transactions": [
      {
        "id": 321,
        "reff_no": "QRIS-20251226-DEF456",
        "nmid": "ID1234567890123",
        "status": "expired",
        "expired_at": "2025-12-26 14:00:00"
      }
    ]
  },
  "summary": {
    "total_expired": 6,
    "payment_links_count": 2,
    "virtual_accounts_count": 3,
    "qris_transactions_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).

Product expiration specific notes

When to use this webhook

Use when

  • Automated cleanup of expired products in your system
  • Customer notifications for expired payment links or VAs
  • Expiration analytics and operational reporting
  • Trigger re-engagement flows (new links, new VAs)
  • Alert operations on high expiration volumes

Skip when

  • You poll product status via API on demand
  • Manual monitoring is sufficient
  • You need real-time expiration handling (use product APIs or individual webhooks)

Batch notification

This webhook is unique: it batches multiple expired products into one call.
  • Multiple product types — Payment Links, Virtual Accounts, and QRIS in one payload
  • Multiple items per type — Each array can contain many records
  • Scheduled execution — Triggered by a system cron job, not real-time
  • Efficient — Fewer webhook calls than one notification per product
Do not rely on this webhook for real-time expiration. There may be a delay between expiration and notification. For immediate handling, poll product status via API.

Processing batch webhooks

Process all expired products
<?php
$payload = json_decode($requestBody, true);

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

foreach ($payload['data']['payment_links'] as $paymentLink) {
    updatePaymentLinkStatus($paymentLink['id'], 'expired');
    notifyCustomerPaymentLinkExpired($paymentLink['reff_no'], $paymentLink['title']);
    logExpiration('payment_link', $paymentLink['id']);
}

foreach ($payload['data']['virtual_accounts'] as $va) {
    updateVAStatus($va['id'], 'expired');
    archiveVirtualAccount($va['virtual_account_number']);
    logExpiration('virtual_account', $va['id']);
}

foreach ($payload['data']['qris_transactions'] as $qris) {
    updateQrisStatus($qris['id'], 'expired');
    logExpiration('qris', $qris['id']);
}

$totalExpired = $payload['summary']['total_expired'];
if ($totalExpired > 100) {
    alertHighExpirationRate($totalExpired);
}

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

Product types included

ArrayProductKey fieldsTypical action
data.payment_linksPayment link pagesid, reff_no, title, expired_atResend or create new links
data.virtual_accountsTemporary and permanent VAsid, reff_no, virtual_account_number, expired_atArchive VA numbers, notify customers
data.qris_transactionsQRIS payment transactionsid, reff_no, nmid, expired_atUpdate lifecycle status

Simplified payload

The webhook sends minimal fields per product to keep batch payloads manageable. The following are not included:
  • Amount, currency, payment URL, usage counts
  • Bank code (VAs), QR string (QRIS)
  • Created or updated timestamps
Use id or reff_no to fetch full details via the product API when needed.

Timestamp format

FieldFormatExample
timestamp (root)d M Y H:i:s26 Dec 2025 14:00:00
expired_at (per product)Y-m-d H:i:s2025-12-26 14:00:00
For signature validation, use X-Timestamp (Unix seconds in the header), not the body timestamp field.
Parse timestamps
$timestamp = $payload['timestamp'];
$date = DateTime::createFromFormat('d M Y H:i:s', $timestamp);

$expiredAt = $payload['data']['payment_links'][0]['expired_at'] ?? null;
if ($expiredAt) {
    $expiry = DateTime::createFromFormat('Y-m-d H:i:s', $expiredAt);
}

Access token format

The Authorization header contains a random system-generated token, not a user JWT. Extract it normally and include it in the string to sign.

Idempotency

Use a batch-level identifier to avoid reprocessing the same scheduled run:
$webhookId = $payload['event'] . '_' . $payload['merchant']['id'] . '_' . strtotime($payload['timestamp']);

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

processExpirationBatch($payload);
markWebhookProcessed($webhookId);

Summary statistics

$summary = $payload['summary'];

logExpirationBatch([
    'total' => $summary['total_expired'],
    'payment_links' => $summary['payment_links_count'],
    'vas' => $summary['virtual_accounts_count'],
    'qris' => $summary['qris_transactions_count'],
    'timestamp' => $payload['timestamp'],
]);

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

if ($summary['payment_links_count'] > $summary['total_expired'] * 0.7) {
    alertTeam('Most expirations are payment links — review expiration settings');
}

Scheduling and timing

  • Triggered by a system cron job (typically hourly or daily — confirm schedule with SingaPay support)
  • Batches all products that expired since the last check
  • Not real-time — plan for a delay between expiration and notification

Use cases

Automated cleanup — Archive expired VAs and remove them from active lists. Customer re-engagement — Follow up when payment links expire; issue new links where appropriate. Analytics — Generate expiration reports using summary counts by product type. Operations monitoring — Alert when total_expired exceeds historical averages.