> ## Documentation Index
> Fetch the complete documentation index at: https://docs.singapay.id/llms.txt
> Use this file to discover all available pages before exploring further.

# Product Expiration Webhook

> Optional batch notifications when payment links, virtual accounts, or QRIS transactions expire. Track and clean up expired products in a single webhook call, or query product status via API for real-time updates.

<Note>
  **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](/api-reference/webhooks/transaction-moneyin-expiration).
</Note>

## Information

<div className="overflow-x-auto">
  | Method                                                                                                              | Path                                | Format | Authentication        |
  | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------ | --------------------- |
  | <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-blue-500 text-white">POST</span> | `https://your-webhook-url/callback` | json   | HMAC SHA512 Signature |
</div>

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.

<Info>
  **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.
</Info>

***

## Request Details

### Headers

| Field           | Value                     | Type         | Mandatory | Description                                                                |
| --------------- | ------------------------- | ------------ | :-------: | -------------------------------------------------------------------------- |
| `Content-Type`  | `application/json`        | Alphabetic   |    Yes    | Specifies JSON format for the request body                                 |
| `User-Agent`    | `SingaPaymentGateway/1.0` | Alphabetic   |    Yes    | Identifies the source of the webhook                                       |
| `Accept`        | `application/json`        | Alphabetic   |    Yes    | Expected response format                                                   |
| `X-PARTNER-ID`  | —                         | Alphanumeric |    Yes    | Your API Key from the merchant dashboard                                   |
| `X-Signature`   | —                         | Alphanumeric |    Yes    | HMAC SHA512 signature (128 chars) for request verification                 |
| `X-Timestamp`   | —                         | Numeric      |    Yes    | Unix timestamp in **seconds** when the request was sent                    |
| `Authorization` | `Bearer <random_token>`   | Alphanumeric |    Yes    | System-generated random bearer token; used as a component in the signature |

<Note>
  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](#how-to-validate-signature) below.
</Note>

***

### Body Parameters

<ParamField body="status" type="number" required>
  HTTP status code. Example: `200`
</ParamField>

<ParamField body="success" type="boolean" required>
  Indicates whether the webhook was sent successfully. Example: `true`
</ParamField>

<ParamField body="event" type="string" required>
  Event type identifier. Always `"product_expiration"` for this webhook.
</ParamField>

<ParamField body="timestamp" type="string" required>
  Event timestamp in format `"d M Y H:i:s"`. Example: `"26 Dec 2025 14:00:00"`
</ParamField>

<ParamField body="merchant" type="object" required>
  Merchant information.

  <Expandable title="merchant fields">
    <ParamField body="id" type="number" required>Merchant ID. Example: `123`</ParamField>
    <ParamField body="name" type="string" required>Merchant name. Example: `"PT Example Indonesia"`</ParamField>
  </Expandable>
</ParamField>

<ParamField body="data" type="object" required>
  Container for expired products. Each array is always present and may be empty.

  <Expandable title="data fields">
    <ParamField body="payment_links" type="array" required>
      Expired payment links.

      <Expandable title="payment_links item fields">
        <ParamField body="id" type="number" required>Payment link ID.</ParamField>
        <ParamField body="reff_no" type="string" required>Payment link reference number.</ParamField>
        <ParamField body="title" type="string" required>Payment link title.</ParamField>
        <ParamField body="status" type="string" required>Status. Should be `"expired"`.</ParamField>
        <ParamField body="expired_at" type="string" required>Expiration datetime (`Y-m-d H:i:s`).</ParamField>
      </Expandable>
    </ParamField>

    <ParamField body="virtual_accounts" type="array" required>
      Expired virtual accounts.

      <Expandable title="virtual_accounts item fields">
        <ParamField body="id" type="number" required>Virtual account ID.</ParamField>
        <ParamField body="reff_no" type="string" required>VA reference number.</ParamField>
        <ParamField body="virtual_account_number" type="string" required>Virtual account number (16 digits).</ParamField>
        <ParamField body="status" type="string" required>Status. Should be `"expired"`.</ParamField>
        <ParamField body="expired_at" type="string" required>Expiration datetime (`Y-m-d H:i:s`).</ParamField>
      </Expandable>
    </ParamField>

    <ParamField body="qris_transactions" type="array" required>
      Expired QRIS transactions.

      <Expandable title="qris_transactions item fields">
        <ParamField body="id" type="number" required>QRIS transaction ID.</ParamField>
        <ParamField body="reff_no" type="string" required>QRIS reference number.</ParamField>
        <ParamField body="nmid" type="string" required>NMID (merchant identifier for QRIS).</ParamField>
        <ParamField body="status" type="string" required>Status. Should be `"expired"`.</ParamField>
        <ParamField body="expired_at" type="string" required>Expiration datetime (`Y-m-d H:i:s`).</ParamField>
      </Expandable>
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="summary" type="object" required>
  Summary counts for the batch.

  <Expandable title="summary fields">
    <ParamField body="total_expired" type="number" required>Total count of all expired products.</ParamField>
    <ParamField body="payment_links_count" type="number" required>Count of expired payment links.</ParamField>
    <ParamField body="virtual_accounts_count" type="number" required>Count of expired virtual accounts.</ParamField>
    <ParamField body="qris_transactions_count" type="number" required>Count of expired QRIS transactions.</ParamField>
  </Expandable>
</ParamField>

***

### Payload Examples

<CodeGroup>
  ```json Batch — Multiple Types theme={null}
  {
    "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
    }
  }
  ```

  ```json Single Type — Virtual Accounts Only theme={null}
  {
    "status": 200,
    "success": true,
    "event": "product_expiration",
    "timestamp": "26 Dec 2025 14:00:00",
    "merchant": {
      "id": 123,
      "name": "PT Example Indonesia"
    },
    "data": {
      "payment_links": [],
      "virtual_accounts": [
        {
          "id": 789,
          "reff_no": "VA-20251226-ABC123",
          "virtual_account_number": "7872955146576837",
          "status": "expired",
          "expired_at": "2025-12-26 14:00:00"
        }
      ],
      "qris_transactions": []
    },
    "summary": {
      "total_expired": 1,
      "payment_links_count": 0,
      "virtual_accounts_count": 1,
      "qris_transactions_count": 0
    }
  }
  ```
</CodeGroup>

***

***

## Security and responses

Return HTTP `200` promptly after validating the request. For retry behavior, see [Webhook retry mechanism](/api-reference/webhooks/retry-mechanism).

Verify every webhook using [Security and signature validation](/api-reference/webhooks/security-and-signature). 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

<CardGroup cols={2}>
  <Card title="Use when" icon="circle-check">
    * 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
  </Card>

  <Card title="Skip when" icon="circle-xmark">
    * You poll product status via API on demand
    * Manual monitoring is sufficient
    * You need real-time expiration handling (use product APIs or individual webhooks)
  </Card>
</CardGroup>

### 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

<Warning>
  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.
</Warning>

### Processing batch webhooks

```php Process all expired products theme={null}
<?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

| Array                    | Product                     | Key fields                                              | Typical action                       |
| ------------------------ | --------------------------- | ------------------------------------------------------- | ------------------------------------ |
| `data.payment_links`     | Payment link pages          | `id`, `reff_no`, `title`, `expired_at`                  | Resend or create new links           |
| `data.virtual_accounts`  | Temporary and permanent VAs | `id`, `reff_no`, `virtual_account_number`, `expired_at` | Archive VA numbers, notify customers |
| `data.qris_transactions` | QRIS payment transactions   | `id`, `reff_no`, `nmid`, `expired_at`                   | Update 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

| Field                      | Format        | Example                |
| -------------------------- | ------------- | ---------------------- |
| `timestamp` (root)         | `d M Y H:i:s` | `26 Dec 2025 14:00:00` |
| `expired_at` (per product) | `Y-m-d H:i:s` | `2025-12-26 14:00:00`  |

For signature validation, use **`X-Timestamp`** (Unix seconds in the header), not the body `timestamp` field.

```php Parse timestamps theme={null}
$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:

```php theme={null}
$webhookId = $payload['event'] . '_' . $payload['merchant']['id'] . '_' . strtotime($payload['timestamp']);

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

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

### Summary statistics

```php theme={null}
$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.
