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

# Subscription Cycle Webhook

> Real-time notifications for every recurring billing attempt (success, failure, retry) and plan status transitions. Keep your subscription state in sync with SingaPay.

<Note>
  **Dedicated URL** — Unlike `transaction_notif_url` which is shared across VA, QRIS, Payment Link, and E-Wallet events, the `subscription_cycle_notif_url` is exclusively for subscription events. Configure it separately in the merchant dashboard.
</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>

This webhook fires whenever one of the following happens on any subscription plan:

* A cycle charge **succeeds** — initial charge after card linking, every recurring cycle, or a successful retry
* A cycle charge **fails** — initial attempt and every retry attempt are delivered separately
* The plan **status changes** — e.g. `suspended` after retries exhausted, `completed` after the last cycle, or `cancelled` (including system-initiated auto-cancel for `charge_immediately = true` plans rejected by the issuer)

The `event` field distinguishes between them.

***

## Events

| Event                                | When it fires                                                            |   `success` field   |
| ------------------------------------ | ------------------------------------------------------------------------ | :-----------------: |
| `subscription.cycle.payment_success` | A cycle bill is paid (initial, recurring, or successful retry)           |        `true`       |
| `subscription.cycle.payment_failed`  | A cycle bill attempt fails — fired on every attempt including each retry | `true` (HTTP `200`) |
| `subscription.plan.status_changed`   | The plan transitions to `suspended`, `cancelled`, or `completed`         |        `true`       |

<Info>
  `success: true` on `payment_failed` events indicates the webhook itself was emitted successfully — **not** that the charge succeeded. Always check the `event` field to determine the actual outcome.
</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. Extract it as-is and use it in the string to sign. The signing scheme is identical to other SingaPay webhooks. See [How to Validate Signature](#security--signature-validation) below.
</Note>

***

## Body Parameters — Payment Success / Failed

These two events share the same body shape. The only difference is the `event` value and, on failure, whether `bill.retry` indicates more attempts are coming.

<ParamField body="status" type="number" required>
  HTTP-style status code. Always `200`.
</ParamField>

<ParamField body="success" type="boolean" required>
  Always `true` — indicates the webhook was emitted successfully, **not** the charge outcome.
</ParamField>

<ParamField body="event" type="string" required>
  `"subscription.cycle.payment_success"` or `"subscription.cycle.payment_failed"`.
</ParamField>

<ParamField body="timestamp" type="string" required>
  Event timestamp in format `"d M Y H:i:s"`. Example: `"01 May 2026 00:00:15"`
</ParamField>

<ParamField body="data" type="object" required>
  Event payload.

  <Expandable title="data fields">
    <ParamField body="plan" type="object" required>
      Snapshot of the subscription plan at the time of the event.

      <Expandable title="plan fields">
        <ParamField body="id" type="string" required>Subscription Plan ID (26-char ULID). Example: `"01JAB3CD4E5F6G7H8J9K0M1N2"`</ParamField>
        <ParamField body="subscription_id" type="string" required>Globally unique merchant-supplied plan reference. Example: `"PLAN-20260420-001"`</ParamField>
        <ParamField body="merchant_reff_no" type="string">Merchant-side reference label echoed from plan creation. `null` if not set.</ParamField>
        <ParamField body="name" type="string" required>Plan display name. Example: `"Premium Monthly"`</ParamField>
        <ParamField body="amount" type="number" required>Per-cycle charge amount. Example: `150000`</ParamField>
        <ParamField body="currency" type="string" required>ISO 4217 currency code. Example: `"IDR"`</ParamField>
        <ParamField body="status" type="string" required>Current plan status. Example: `"active"`</ParamField>
        <ParamField body="parent_plan_id" type="string">ID of the previous plan superseded via upgrade. `null` if not an upgrade.</ParamField>

        <ParamField body="retry_policy" type="object" required>
          Plan-level retry policy.

          <Expandable title="retry_policy fields">
            <ParamField body="max_attempts" type="integer" required>Max retry attempts after the initial charge fails. Example: `3`</ParamField>
            <ParamField body="interval_days" type="integer" required>Days between consecutive retries. Example: `3`</ParamField>
            <ParamField body="failed_payment_action" type="string" required>`"continue_plan"` or `"stop_plan"`.</ParamField>
          </Expandable>
        </ParamField>
      </Expandable>
    </ParamField>

    <ParamField body="bill" type="object" required>
      The specific cycle bill this event relates to.

      <Expandable title="bill fields">
        <ParamField body="id" type="integer" required>Internal bill ID. Example: `12345`</ParamField>
        <ParamField body="bill_number" type="string" required>Human-readable bill number — also used as the webhook `reff_no` and idempotency key. Example: `"SUBBILL-202605-0001"`</ParamField>
        <ParamField body="status" type="string" required>`"pending"` | `"processing"` | `"paid"` | `"failed"`</ParamField>
        <ParamField body="total_amount" type="number" required>Charge amount for this bill. Example: `150000`</ParamField>
        <ParamField body="currency" type="string" required>ISO 4217 currency code. Always `"IDR"` today.</ParamField>
        <ParamField body="due_date" type="string">Cycle due date (ISO 8601). Example: `"2026-05-01T00:00:00+07:00"`</ParamField>
        <ParamField body="paid_date" type="string">Paid timestamp (ISO 8601). `null` on `payment_failed`.</ParamField>
        <ParamField body="failure_reason" type="string">Human-readable failure reason. Present on `payment_failed`. Example: `"card_declined"`</ParamField>
        <ParamField body="payment_reference" type="string">Provider-side reference for this charge. Example: `"IONPAYTEST01202604201200000001"`</ParamField>

        <ParamField body="retry" type="object" required>
          Per-bill retry state combining the plan policy with the live attempt counter.

          <Expandable title="retry fields">
            <ParamField body="attempt" type="integer" required>How many retry attempts have been made so far. `0` on the initial attempt.</ParamField>
            <ParamField body="max_attempts" type="integer" required>Max retry attempts (mirrors `plan.retry_policy.max_attempts`).</ParamField>
            <ParamField body="attempts_remaining" type="integer" required>`max_attempts - attempt`, clamped to `0`.</ParamField>
            <ParamField body="max_attempts_reached" type="boolean" required>`true` when no more retries will fire.</ParamField>
            <ParamField body="interval_days" type="integer" required>Days between retries.</ParamField>
            <ParamField body="failed_payment_action" type="string" required>`"continue_plan"` or `"stop_plan"`.</ParamField>
            <ParamField body="next_retry_at" type="string">Next retry timestamp (ISO 8601). `null` when no more retries are scheduled.</ParamField>
            <ParamField body="last_attempt_at" type="string">Most recent retry attempt timestamp (ISO 8601).</ParamField>

            <ParamField body="history" type="array" required>
              Chronological list of retry attempts on this bill.

              <Expandable title="history item fields">
                <ParamField body="attempt" type="integer" required>Retry sequence number (1-indexed).</ParamField>
                <ParamField body="status" type="string" required>`"pending"` | `"processing"` | `"paid"` | `"failed"`</ParamField>
                <ParamField body="retry_date" type="string">When this retry was executed (ISO 8601).</ParamField>
                <ParamField body="next_retry_date" type="string">When the next retry is scheduled (ISO 8601).</ParamField>
                <ParamField body="failure_reason" type="string">Reason this particular retry failed.</ParamField>
              </Expandable>
            </ParamField>
          </Expandable>
        </ParamField>
      </Expandable>
    </ParamField>

    <ParamField body="cycle" type="object">
      The cycle this bill belongs to.

      <Expandable title="cycle fields">
        <ParamField body="id" type="integer" required>Internal cycle ID. Example: `7890`</ParamField>
        <ParamField body="cycle_number" type="integer" required>1-indexed cycle number within the plan. Example: `3`</ParamField>
        <ParamField body="status" type="string" required>`"pending"` | `"paid"` | `"failed"` | `"skipped"`</ParamField>
        <ParamField body="period_start" type="string">Cycle period start (ISO 8601).</ParamField>
        <ParamField body="period_end" type="string">Cycle period end (ISO 8601).</ParamField>
      </Expandable>
    </ParamField>
  </Expandable>
</ParamField>

***

### Payload Examples — Payment Events

<CodeGroup>
  ```json payment_success (Cycle 3, first attempt) theme={null}
  {
    "status": 200,
    "success": true,
    "event": "subscription.cycle.payment_success",
    "timestamp": "01 May 2026 00:00:15",
    "data": {
      "plan": {
        "id": "01JAB3CD4E5F6G7H8J9K0M1N2",
        "subscription_id": "PLAN-20260420-001",
        "merchant_reff_no": "SUB-CUST-ACME-001",
        "name": "Premium Monthly",
        "amount": 150000,
        "currency": "IDR",
        "status": "active",
        "parent_plan_id": null,
        "retry_policy": {
          "max_attempts": 3,
          "interval_days": 3,
          "failed_payment_action": "continue_plan"
        }
      },
      "bill": {
        "id": 12345,
        "bill_number": "SUBBILL-202605-0001",
        "status": "paid",
        "total_amount": 150000,
        "currency": "IDR",
        "due_date": "2026-05-01T00:00:00+07:00",
        "paid_date": "2026-05-01T00:00:15+07:00",
        "failure_reason": null,
        "payment_reference": "IONPAYTEST01202605010000000001",
        "retry": {
          "attempt": 0,
          "max_attempts": 3,
          "attempts_remaining": 3,
          "max_attempts_reached": false,
          "interval_days": 3,
          "failed_payment_action": "continue_plan",
          "next_retry_at": null,
          "last_attempt_at": null,
          "history": []
        }
      },
      "cycle": {
        "id": 7890,
        "cycle_number": 3,
        "status": "paid",
        "period_start": "2026-05-01T00:00:00+07:00",
        "period_end": "2026-06-01T00:00:00+07:00"
      }
    }
  }
  ```

  ```json payment_failed (retry coming) theme={null}
  {
    "status": 200,
    "success": true,
    "event": "subscription.cycle.payment_failed",
    "timestamp": "01 May 2026 00:00:20",
    "data": {
      "plan": {
        "id": "01JAB3CD4E5F6G7H8J9K0M1N2",
        "subscription_id": "PLAN-20260420-001",
        "merchant_reff_no": "SUB-CUST-ACME-001",
        "name": "Premium Monthly",
        "amount": 150000,
        "currency": "IDR",
        "status": "active",
        "parent_plan_id": null,
        "retry_policy": {
          "max_attempts": 3,
          "interval_days": 3,
          "failed_payment_action": "continue_plan"
        }
      },
      "bill": {
        "id": 12346,
        "bill_number": "SUBBILL-202605-0002",
        "status": "failed",
        "total_amount": 150000,
        "currency": "IDR",
        "due_date": "2026-05-01T00:00:00+07:00",
        "paid_date": null,
        "failure_reason": "card_declined",
        "payment_reference": "IONPAYTEST01202605010000000002",
        "retry": {
          "attempt": 1,
          "max_attempts": 3,
          "attempts_remaining": 2,
          "max_attempts_reached": false,
          "interval_days": 3,
          "failed_payment_action": "continue_plan",
          "next_retry_at": "2026-05-04T00:00:00+07:00",
          "last_attempt_at": "2026-05-01T00:00:20+07:00",
          "history": [
            {
              "attempt": 1,
              "status": "failed",
              "retry_date": "2026-05-01T00:00:20+07:00",
              "next_retry_date": "2026-05-04T00:00:00+07:00",
              "failure_reason": "card_declined"
            }
          ]
        }
      },
      "cycle": {
        "id": 7891,
        "cycle_number": 4,
        "status": "pending",
        "period_start": "2026-05-01T00:00:00+07:00",
        "period_end": "2026-06-01T00:00:00+07:00"
      }
    }
  }
  ```

  ```json payment_failed (retries exhausted) theme={null}
  {
    "status": 200,
    "success": true,
    "event": "subscription.cycle.payment_failed",
    "timestamp": "07 May 2026 00:00:20",
    "data": {
      "plan": { "...": "..." },
      "bill": {
        "id": 12346,
        "bill_number": "SUBBILL-202605-0002",
        "status": "failed",
        "total_amount": 150000,
        "currency": "IDR",
        "failure_reason": "card_declined",
        "retry": {
          "attempt": 3,
          "max_attempts": 3,
          "attempts_remaining": 0,
          "max_attempts_reached": true,
          "interval_days": 3,
          "failed_payment_action": "stop_plan",
          "next_retry_at": null,
          "last_attempt_at": "2026-05-07T00:00:20+07:00",
          "history": [
            { "attempt": 1, "status": "failed", "retry_date": "2026-05-01T00:00:20+07:00", "failure_reason": "card_declined" },
            { "attempt": 2, "status": "failed", "retry_date": "2026-05-04T00:00:20+07:00", "failure_reason": "card_declined" },
            { "attempt": 3, "status": "failed", "retry_date": "2026-05-07T00:00:20+07:00", "failure_reason": "card_declined" }
          ]
        }
      }
    }
  }
  ```
</CodeGroup>

<Warning>
  When `bill.retry.max_attempts_reached` is `true` and `failed_payment_action` is `"stop_plan"`, expect a subsequent `subscription.plan.status_changed` event with `plan.status: "suspended"` to arrive shortly after.
</Warning>

***

## Body Parameters — Plan Status Changed

Fires when the plan transitions to `suspended`, `cancelled`, or `completed`.

<ParamField body="status" type="number" required>Always `200`.</ParamField>
<ParamField body="success" type="boolean" required>Always `true`.</ParamField>
<ParamField body="event" type="string" required>`"subscription.plan.status_changed"`</ParamField>
<ParamField body="timestamp" type="string" required>Event timestamp in format `"d M Y H:i:s"`. Example: `"07 May 2026 00:05:00"`</ParamField>

<ParamField body="data" type="object" required>
  <Expandable title="data fields">
    <ParamField body="plan" type="object" required>
      Plan snapshot — same shape as the `plan` block in payment events above. `plan.status` reflects the **new** status.
    </ParamField>

    <ParamField body="previous_status" type="string" required>
      Status the plan was in before the transition. Example: `"active"`
    </ParamField>
  </Expandable>
</ParamField>

### Payload Example — Plan Status Changed

```json Plan suspended after retries exhausted theme={null}
{
  "status": 200,
  "success": true,
  "event": "subscription.plan.status_changed",
  "timestamp": "07 May 2026 00:05:00",
  "data": {
    "plan": {
      "id": "01JAB3CD4E5F6G7H8J9K0M1N2",
      "subscription_id": "PLAN-20260420-001",
      "merchant_reff_no": "SUB-CUST-ACME-001",
      "name": "Premium Monthly",
      "amount": 150000,
      "currency": "IDR",
      "status": "suspended",
      "parent_plan_id": null,
      "retry_policy": {
        "max_attempts": 3,
        "interval_days": 3,
        "failed_payment_action": "stop_plan"
      }
    },
    "previous_status": "active"
  }
}
```

***

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

***

## Idempotency

Use `bill.bill_number` as your primary idempotency key when storing successful deliveries. The same bill number may arrive more than once if your endpoint returned a non-2xx response and SingaPay retried the webhook delivery.

For failed charge retries, the `bill_number` stays the same but `bill.retry.attempt` increments. Use the compound key `(bill_number, retry.attempt)` if you need per-attempt idempotency.

***

## Important Notes

<AccordionGroup>
  <Accordion title="One event per attempt" icon="bell">
    Every retry attempt emits its own `subscription.cycle.payment_failed` (or `payment_success` on the attempt that finally clears). Monitor `bill.retry.attempts_remaining` to know whether another automatic attempt is coming.
  </Accordion>

  <Accordion title="Plan status events are separate" icon="arrow-right-arrow-left">
    When retries are exhausted with `failed_payment_action: stop_plan`, expect a `payment_failed` event followed shortly by a `plan.status_changed` event with `plan.status: "suspended"`. These are two distinct webhook deliveries.
  </Accordion>

  <Accordion title="Plan lineage after upgrades" icon="arrow-up-right-dots">
    After a PATCH upgrade, the new plan has a different `plan.id` but the same `plan.subscription_id` and `plan.merchant_reff_no`. The old plan's ID is available on `plan.parent_plan_id` for reconciliation.
  </Accordion>

  <Accordion title="merchant_reff_no is optional" icon="tag">
    If you didn't set `merchant_reff_no` at plan creation, it will be `null` on every cycle webhook. Set it during plan creation to have it echoed back reliably for your own tracking.
  </Accordion>

  <Accordion title="Currency is always IDR" icon="circle-dollar-to-slot">
    `bill.currency` is always `"IDR"` today — card recurring runs in IDR only.
  </Accordion>
</AccordionGroup>
