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

# Transaction Money-In Expiration Webhook

> Optional batch notifications when unpaid money-in transactions expire. Track expired payment link histories, virtual account transactions, and QRIS histories in a single webhook call to improve collection and abandonment monitoring.

<Note>
  **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](/api-reference/webhooks/product-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 `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.

<Info>
  **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.
</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 `"transaction_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 transactions. Each array is always present and may be empty.

  <Expandable title="data fields">
    <ParamField body="payment_link_histories" type="array" required>
      Expired payment link histories (unpaid payment attempts).

      <Expandable title="payment_link_histories item fields">
        <ParamField body="id" type="number" required>Payment link history ID.</ParamField>
        <ParamField body="reff_no" type="string" required>History reference number.</ParamField>
        <ParamField body="payment_link_id" type="number" required>Parent payment link ID.</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_account_transactions" type="array" required>
      Expired virtual account transactions (unpaid).

      <Expandable title="virtual_account_transactions item fields">
        <ParamField body="id" type="number" required>VA transaction ID.</ParamField>
        <ParamField body="reff_no" type="string" required>Transaction reference number.</ParamField>
        <ParamField body="virtual_account_id" type="number" required>Parent virtual account ID.</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_histories" type="array" required>
      Expired QRIS histories (unpaid payment attempts).

      <Expandable title="qris_histories item fields">
        <ParamField body="id" type="number" required>QRIS history ID.</ParamField>
        <ParamField body="reff_no" type="string" required>History reference number.</ParamField>
        <ParamField body="qris_transaction_id" type="number" required>Parent QRIS transaction ID.</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 transactions.</ParamField>
    <ParamField body="payment_link_histories_count" type="number" required>Count of expired payment link histories.</ParamField>
    <ParamField body="virtual_account_transactions_count" type="number" required>Count of expired VA transactions.</ParamField>
    <ParamField body="qris_histories_count" type="number" required>Count of expired QRIS histories.</ParamField>
  </Expandable>
</ParamField>

***

### Payload Examples

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

  ```json Single Type — VA Transactions Only theme={null}
  {
    "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
    }
  }
  ```
</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`).

***

## Transaction money-in expiration specific notes

### When to use this webhook

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

  <Card title="Skip when" icon="circle-xmark">
    * You poll transaction status via API on demand
    * You only need product-level expiration ([Product Expiration](/api-reference/webhooks/product-expiration))
    * Manual monitoring is sufficient
  </Card>
</CardGroup>

### Transaction vs product expiration

<Warning>
  This webhook is **not** the same as [Product Expiration](/api-reference/webhooks/product-expiration). Understanding the difference is essential.
</Warning>

| Aspect          | Transaction expiration (this webhook)   | [Product expiration](/api-reference/webhooks/product-expiration) |
| --------------- | --------------------------------------- | ---------------------------------------------------------------- |
| What expires    | Unpaid transaction attempts / records   | Payment products themselves                                      |
| Payment link    | Payment link **history** (one attempt)  | Payment link (the product/page)                                  |
| Virtual account | VA **transaction** (one unpaid txn)     | Virtual account (the VA number)                                  |
| QRIS            | QRIS **history** (one attempt)          | QRIS transaction (the QR product)                                |
| Use case        | Abandoned payments, follow-up reminders | Product cleanup, inventory management                            |
| Reusability     | Parent product can still be reused      | Product 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](/api-reference/webhooks/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](/api-reference/webhooks/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

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

| Array                               | Record type                 | Parent ID field       | Note                        |
| ----------------------------------- | --------------------------- | --------------------- | --------------------------- |
| `data.payment_link_histories`       | Unpaid payment link attempt | `payment_link_id`     | Parent link stays active    |
| `data.virtual_account_transactions` | Unpaid VA transaction       | `virtual_account_id`  | Parent VA stays active      |
| `data.qris_histories`               | Unpaid QRIS attempt         | `qris_transaction_id` | Parent QRIS may stay active |

Use parent IDs to fetch full product details via API and send new payment attempts on the same product.

```php Fetch parent payment link theme={null}
$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

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

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

### Idempotency

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

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

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

### Summary statistics

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

### Recommended practices

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