Skip to main content
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.

Information

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

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.

Request Details

Headers

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

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.
number
required
HTTP-style status code. Always 200.
boolean
required
Always true — indicates the webhook was emitted successfully, not the charge outcome.
string
required
"subscription.cycle.payment_success" or "subscription.cycle.payment_failed".
string
required
Event timestamp in format "d M Y H:i:s". Example: "01 May 2026 00:00:15"
object
required
Event payload.

Payload Examples — Payment Events

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.

Body Parameters — Plan Status Changed

Fires when the plan transitions to suspended, cancelled, or completed.
number
required
Always 200.
boolean
required
Always true.
string
required
"subscription.plan.status_changed"
string
required
Event timestamp in format "d M Y H:i:s". Example: "07 May 2026 00:05:00"
object
required

Payload Example — Plan Status Changed

Plan suspended after retries exhausted

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

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

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.
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.
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.
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.
bill.currency is always "IDR" today — card recurring runs in IDR only.