
Webhooks
The Subscription Cycle Webhook delivers real-time notifications for every recurring billing attempt (success, failure, retry) and for plan status transitions (suspended, cancelled, completed). Use it to keep your subscription state in sync with SingaPay.
| Method | Path | Format | Authentication |
|---|---|---|---|
| POST | https://your-webhook-url/callback | json | HMAC SHA512 Signature |
This webhook is delivered via POST to your configured subscription_cycle_notif_url whenever one of the following happens on any subscription plan:
suspended after retries are exhausted with failed_payment_action: stop_plan, completed after the last scheduled cycle, or cancelled — including the system-initiated auto-cancel for charge_immediately = true plans whose initial card linking is rejected by the issuer / acquirer; in that case plan.metadata.cancellation_reason is initial_linking_failed).The event field distinguishes between them.
Dedicated URL: Unlike the transaction_notif_url which is shared across multiple money-in events (VA, QRIS, Payment Link, E-Wallet), the subscription_cycle_notif_url is dedicated to subscription events. Configure it separately in the merchant dashboard.
| 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’s status transitions to suspended, cancelled, or completed. | true |
When a subscription event fires, SingaPay sends a webhook to the registered subscription_cycle_notif_url. The request is signed with the same HMAC SHA512 scheme used by other SingaPay webhooks.
| Field | Value | Type | Mandatory | Length | Description | Example |
|---|---|---|---|---|---|---|
| Content-Type | application/json | Alphabetic | Mandatory | Specifies JSON format for the request body. | application/json | |
| User-Agent | SingaPaymentGateway/1.0 | Alphabetic | Mandatory | Identifies the source of the webhook. | SingaPaymentGateway/1.0 | |
| Accept | application/json | Alphabetic | Mandatory | Expected response format. | application/json | |
| X-PARTNER-ID | Alphanumeric | Mandatory | Your API Key from the merchant dashboard. | pk_live_abc123def456 | ||
| X-Signature | Alphanumeric | Mandatory | 128 | HMAC SHA512 signature for request verification. | 5f4dcc3b5aa765d61d8327deb882cf99… | |
| X-Timestamp | Numeric | Mandatory | 10 | Unix timestamp in seconds when the request was sent. | 1745126400 | |
| Authorization | Bearer <random_token> | Alphanumeric | Mandatory | Bearer token with random value (system-generated, used as component in signature). | Bearer a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 |
Note: The access token in the Authorization header is a randomly generated string (not a user access token) because this webhook is triggered by the system, not by a user action. Extract it from the header and use it as-is in the string-to-sign. See How to Validate Signature for the full signing scheme — it is identical to the one used for payment-link and VA-transaction webhooks.
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.
| Field | Type | Mandatory | Description | Example |
|---|---|---|---|---|
| status | Numeric | Mandatory | HTTP-style status code (200). | 200 |
| success | Boolean | Mandatory | Always true — indicates the webhook itself was emitted successfully, not the charge outcome. | true |
| event | String | Mandatory | subscription.cycle.payment_success or subscription.cycle.payment_failed. | subscription.cycle.payment_success |
| timestamp | String | Mandatory | Event timestamp (format: d M Y H:i:s). | 20 Apr 2026 10:00:00 |
| data | Object | Mandatory | Event payload. | - |
| > plan | Object | Mandatory | Snapshot of the subscription plan at the time of the event. | - |
| >> plan.id | String | Mandatory | Subscription Plan ID (26-char ULID). | 01JAB3CD4E5F6G7H8J9K0M1N2 |
| >> plan.subscription_id | String | Mandatory | Globally unique merchant-supplied plan reference. | PLAN-20260420-001 |
| >> plan.merchant_reff_no | String/Null | Optional | Merchant-side reference label (echoed from plan creation). | SUB-CUST-ACME-001 |
| >> plan.name | String | Mandatory | Plan display name. | Premium Monthly |
| >> plan.amount | Number | Mandatory | Per-cycle charge amount. | 150000 |
| >> plan.currency | String | Mandatory | ISO 4217 currency code. | IDR |
| >> plan.status | String | Mandatory | Current plan status. | active |
| >> plan.parent_plan_id | String/Null | Optional | ID of the previous plan this one superseded via upgrade. | null |
| >> plan.retry_policy | Object | Mandatory | Plan-level retry policy (mirrors the Show Plan API). | - |
| >>> plan.retry_policy.max_attempts | Integer | Mandatory | Max retry attempts after the initial charge fails. | 3 |
| >>> plan.retry_policy.interval_days | Integer | Mandatory | Days between consecutive retries. | 3 |
| >>> plan.retry_policy.failed_payment_action | String | Mandatory | continue_plan or stop_plan. | continue_plan |
| > bill | Object | Mandatory | The specific cycle bill this event relates to. | - |
| >> bill.id | Integer | Mandatory | Internal bill ID. | 12345 |
| >> bill.bill_number | String | Mandatory | Human-readable bill number (also used as the webhook reff_no). | SUBBILL-202604-0001 |
| >> bill.status | String | Mandatory | pending | processing | paid | failed. | paid |
| >> bill.total_amount | Number | Mandatory | Charge amount for this bill. | 150000 |
| >> bill.currency | String | Mandatory | ISO 4217 currency code. | IDR |
| >> bill.due_date | String/Null | Optional | Cycle due date (ISO 8601). | 2026-05-01T00:00:00+07:00 |
| >> bill.paid_date | String/Null | Optional | Paid timestamp (ISO 8601). null on payment_failed. | 2026-05-01T00:00:15+07:00 |
| >> bill.failure_reason | String/Null | Optional | Human-readable failure reason (present on payment_failed). | card_declined |
| >> bill.payment_reference | String/Null | Optional | Provider-side reference for this charge (e.g. Nicepay TrxId). | IONPAYTEST01202604201200000001 |
| >> bill.retry | Object | Mandatory | Per-bill retry state combining the plan policy with the live attempt counter. | - |
| >>> bill.retry.attempt | Integer | Mandatory | How many retry attempts have been made on this bill so far. 0 on the initial attempt. | 1 |
| >>> bill.retry.max_attempts | Integer | Mandatory | Max retry attempts (mirrors plan.retry_policy.max_attempts). | 3 |
| >>> bill.retry.attempts_remaining | Integer | Mandatory | max_attempts - attempt, clamped to 0. | 2 |
| >>> bill.retry.max_attempts_reached | Boolean | Mandatory | true when no more retries will fire. | false |
| >>> bill.retry.interval_days | Integer | Mandatory | Days between retries. | 3 |
| >>> bill.retry.failed_payment_action | String | Mandatory | continue_plan or stop_plan. | continue_plan |
| >>> bill.retry.next_retry_at | String/Null | Optional | Next retry attempt timestamp (ISO 8601). null when no more retries are scheduled. | 2026-05-04T00:00:00+07:00 |
| >>> bill.retry.last_attempt_at | String/Null | Optional | Most recent retry attempt timestamp (ISO 8601). | 2026-05-01T00:00:15+07:00 |
| >>> bill.retry.history | Array | Mandatory | Chronological list of retry attempts on this bill. | - |
| >>>> bill.retry.history[].attempt | Integer | Mandatory | Retry sequence number (1-indexed). | 1 |
| >>>> bill.retry.history[].status | String | Mandatory | pending | processing | paid | failed. | failed |
| >>>> bill.retry.history[].retry_date | String/Null | Optional | When this retry was executed (ISO 8601). | 2026-05-01T00:00:15+07:00 |
| >>>> bill.retry.history[].next_retry_date | String/Null | Optional | When the next retry is scheduled (ISO 8601). | 2026-05-04T00:00:00+07:00 |
| >>>> bill.retry.history[].failure_reason | String/Null | Optional | Reason this particular retry failed. | card_declined |
| > cycle | Object/Null | Optional | The cycle this bill belongs to. | - |
| >> cycle.id | Integer | Mandatory | Internal cycle ID. | 7890 |
| >> cycle.cycle_number | Integer | Mandatory | 1-indexed cycle number within the plan. | 3 |
| >> cycle.status | String | Mandatory | pending | paid | failed | skipped. | paid |
| >> cycle.period_start | String/Null | Optional | Cycle period start (ISO 8601). | 2026-05-01T00:00:00+07:00 |
| >> cycle.period_end | String/Null | Optional | Cycle period end (ISO 8601). | 2026-06-01T00:00:00+07:00 |
subscription.cycle.payment_successSuccess: Cycle 3 was paid on the first attempt. No retry block activity.
{
"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"
}
}
}
subscription.cycle.payment_failed (Retry Coming)Error: First charge attempt failed. SingaPay will auto-retry in 3 days.
{
"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"
}
}
}
subscription.cycle.payment_failed (Retries Exhausted)When bill.retry.max_attempts_reached is true and the plan is configured with failed_payment_action: stop_plan, expect a subsequent subscription.plan.status_changed event with plan.status: "suspended".
{
"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" }
]
}
}
}
}
Fires when the plan transitions to suspended, cancelled, or completed.
| Field | Type | Mandatory | Description | Example |
|---|---|---|---|---|
| status | Numeric | Mandatory | 200. | 200 |
| success | Boolean | Mandatory | true. | true |
| event | String | Mandatory | subscription.plan.status_changed. | subscription.plan.status_changed |
| timestamp | String | Mandatory | Event timestamp (format: d M Y H:i:s). | 07 May 2026 00:05:00 |
| data | Object | Mandatory | Event payload. | - |
| > plan | Object | Mandatory | Plan snapshot (same shape as the plan block above). | - |
| > previous_status | String | Mandatory | Status the plan was in before the change (e.g. active). | active |
subscription.plan.status_changedPlan was suspended after retries were exhausted.
{
"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"
}
}
Subscription Cycle webhooks use the same HMAC SHA512 signing scheme as all other SingaPay outbound webhooks. Validation logic that works for the Payment Link or VA Transaction webhooks works verbatim here — the only difference is the endpoint path used in the string-to-sign.
Recommended layered security:
X-Signature using your Client Secret. See How to Validate Signature for a step-by-step PHP, Node.js, and Python walkthrough.X-Timestamp is more than 5 minutes off from server time to mitigate replay attacks.StringToSign = POST + ":" + ENDPOINT + ":" + ACCESS_TOKEN + ":" + HASHED_BODY + ":" + TIMESTAMP
Signature = HMAC-SHA512(StringToSign, Client Secret)
Where:
ENDPOINT is the path + query string of your webhook URL (e.g. /webhook/subscription-cycle).ACCESS_TOKEN is the random string after Bearer in the Authorization header.HASHED_BODY is sha256 of the JSON body after recursive key sorting and re-encoding with JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES.TIMESTAMP is the integer seconds value from the X-Timestamp header.Return HTTP 200 with any JSON body (or an empty body) to acknowledge receipt. Non-2xx responses trigger the standard webhook retry mechanism.
{ "status": "ok" }
See Webhook Retry Mechanism for the retry schedule and dead-letter behavior applied when your endpoint is unreachable.
Use bill.bill_number (also delivered as the webhook reff_no) as your idempotency key when storing successful deliveries. The same bill number may arrive multiple times if:
For retries of a failed charge, the bill_number stays the same but bill.retry.attempt increases — use the pair (bill_number, retry.attempt) as a compound key if you need per-attempt idempotency.
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.failed_payment_action: stop_plan, expect a subscription.cycle.payment_failed event followed shortly by a subscription.plan.status_changed event with plan.status: "suspended".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.merchant_reff_no is optional: If you didn’t set it at plan creation, it will be null here. Set it when creating the plan to get it echoed back on every cycle webhook.bill.currency is always IDR today (card recurring runs in IDR only).