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

MethodPathFormatAuthentication
POSThttps://your-webhook-url/callbackjsonHMAC SHA512 Signature
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

EventWhen it firessuccess field
subscription.cycle.payment_successA cycle bill is paid (initial, recurring, or successful retry)true
subscription.cycle.payment_failedA cycle bill attempt fails — fired on every attempt including each retrytrue (HTTP 200)
subscription.plan.status_changedThe plan transitions to suspended, cancelled, or completedtrue
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

FieldValueTypeMandatoryDescription
Content-Typeapplication/jsonAlphabeticYesSpecifies JSON format for the request body
User-AgentSingaPaymentGateway/1.0AlphabeticYesIdentifies the source of the webhook
Acceptapplication/jsonAlphabeticYesExpected response format
X-PARTNER-IDAlphanumericYesYour API Key from the merchant dashboard
X-SignatureAlphanumericYesHMAC SHA512 signature (128 chars) for request verification
X-TimestampNumericYesUnix timestamp in seconds when the request was sent
AuthorizationBearer <random_token>AlphanumericYesSystem-generated random bearer token; used as a component in the signature
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.
status
number
required
HTTP-style status code. Always 200.
success
boolean
required
Always true — indicates the webhook was emitted successfully, not the charge outcome.
event
string
required
"subscription.cycle.payment_success" or "subscription.cycle.payment_failed".
timestamp
string
required
Event timestamp in format "d M Y H:i:s". Example: "01 May 2026 00:00:15"
data
object
required
Event payload.

Payload Examples — Payment Events

{
  "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"
    }
  }
}
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.
status
number
required
Always 200.
success
boolean
required
Always true.
event
string
required
"subscription.plan.status_changed"
timestamp
string
required
Event timestamp in format "d M Y H:i:s". Example: "07 May 2026 00:05:00"
data
object
required

Payload Example — Plan Status Changed

Plan suspended after retries 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"
  }
}

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.