Singapay Home Page
Logo Icon
  1. Webhooks
  2. Subscription Cycle

Information

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

  • A cycle charge succeeds (initial charge after card linking, every recurring cycle, or a retry after a previous failure).
  • A cycle charge fails (initial attempt and every retry attempt are delivered).
  • The plan’s status changes (e.g. 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.

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 retry.true (HTTP 200)
subscription.plan.status_changedThe plan’s status transitions to suspended, cancelled, or completed.true

Request Details

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.

Headers Structure

FieldValueTypeMandatoryLengthDescriptionExample
Content-Typeapplication/jsonAlphabeticMandatorySpecifies JSON format for the request body.application/json
User-AgentSingaPaymentGateway/1.0AlphabeticMandatoryIdentifies the source of the webhook.SingaPaymentGateway/1.0
Acceptapplication/jsonAlphabeticMandatoryExpected response format.application/json
X-PARTNER-IDAlphanumericMandatoryYour API Key from the merchant dashboard.pk_live_abc123def456
X-SignatureAlphanumericMandatory128HMAC SHA512 signature for request verification.5f4dcc3b5aa765d61d8327deb882cf99…
X-TimestampNumericMandatory10Unix timestamp in seconds when the request was sent.1745126400
AuthorizationBearer <random_token>AlphanumericMandatoryBearer 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.


Body Structure — 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.

FieldTypeMandatoryDescriptionExample
statusNumericMandatoryHTTP-style status code (200).200
successBooleanMandatoryAlways true — indicates the webhook itself was emitted successfully, not the charge outcome.true
eventStringMandatorysubscription.cycle.payment_success or subscription.cycle.payment_failed.subscription.cycle.payment_success
timestampStringMandatoryEvent timestamp (format: d M Y H:i:s).20 Apr 2026 10:00:00
dataObjectMandatoryEvent payload.-
> planObjectMandatorySnapshot of the subscription plan at the time of the event.-
>> plan.idStringMandatorySubscription Plan ID (26-char ULID).01JAB3CD4E5F6G7H8J9K0M1N2
>> plan.subscription_idStringMandatoryGlobally unique merchant-supplied plan reference.PLAN-20260420-001
>> plan.merchant_reff_noString/NullOptionalMerchant-side reference label (echoed from plan creation).SUB-CUST-ACME-001
>> plan.nameStringMandatoryPlan display name.Premium Monthly
>> plan.amountNumberMandatoryPer-cycle charge amount.150000
>> plan.currencyStringMandatoryISO 4217 currency code.IDR
>> plan.statusStringMandatoryCurrent plan status.active
>> plan.parent_plan_idString/NullOptionalID of the previous plan this one superseded via upgrade.null
>> plan.retry_policyObjectMandatoryPlan-level retry policy (mirrors the Show Plan API).-
>>> plan.retry_policy.max_attemptsIntegerMandatoryMax retry attempts after the initial charge fails.3
>>> plan.retry_policy.interval_daysIntegerMandatoryDays between consecutive retries.3
>>> plan.retry_policy.failed_payment_actionStringMandatorycontinue_plan or stop_plan.continue_plan
> billObjectMandatoryThe specific cycle bill this event relates to.-
>> bill.idIntegerMandatoryInternal bill ID.12345
>> bill.bill_numberStringMandatoryHuman-readable bill number (also used as the webhook reff_no).SUBBILL-202604-0001
>> bill.statusStringMandatorypending | processing | paid | failed.paid
>> bill.total_amountNumberMandatoryCharge amount for this bill.150000
>> bill.currencyStringMandatoryISO 4217 currency code.IDR
>> bill.due_dateString/NullOptionalCycle due date (ISO 8601).2026-05-01T00:00:00+07:00
>> bill.paid_dateString/NullOptionalPaid timestamp (ISO 8601). null on payment_failed.2026-05-01T00:00:15+07:00
>> bill.failure_reasonString/NullOptionalHuman-readable failure reason (present on payment_failed).card_declined
>> bill.payment_referenceString/NullOptionalProvider-side reference for this charge (e.g. Nicepay TrxId).IONPAYTEST01202604201200000001
>> bill.retryObjectMandatoryPer-bill retry state combining the plan policy with the live attempt counter.-
>>> bill.retry.attemptIntegerMandatoryHow many retry attempts have been made on this bill so far. 0 on the initial attempt.1
>>> bill.retry.max_attemptsIntegerMandatoryMax retry attempts (mirrors plan.retry_policy.max_attempts).3
>>> bill.retry.attempts_remainingIntegerMandatorymax_attempts - attempt, clamped to 0.2
>>> bill.retry.max_attempts_reachedBooleanMandatorytrue when no more retries will fire.false
>>> bill.retry.interval_daysIntegerMandatoryDays between retries.3
>>> bill.retry.failed_payment_actionStringMandatorycontinue_plan or stop_plan.continue_plan
>>> bill.retry.next_retry_atString/NullOptionalNext retry attempt timestamp (ISO 8601). null when no more retries are scheduled.2026-05-04T00:00:00+07:00
>>> bill.retry.last_attempt_atString/NullOptionalMost recent retry attempt timestamp (ISO 8601).2026-05-01T00:00:15+07:00
>>> bill.retry.historyArrayMandatoryChronological list of retry attempts on this bill.-
>>>> bill.retry.history[].attemptIntegerMandatoryRetry sequence number (1-indexed).1
>>>> bill.retry.history[].statusStringMandatorypending | processing | paid | failed.failed
>>>> bill.retry.history[].retry_dateString/NullOptionalWhen this retry was executed (ISO 8601).2026-05-01T00:00:15+07:00
>>>> bill.retry.history[].next_retry_dateString/NullOptionalWhen the next retry is scheduled (ISO 8601).2026-05-04T00:00:00+07:00
>>>> bill.retry.history[].failure_reasonString/NullOptionalReason this particular retry failed.card_declined
> cycleObject/NullOptionalThe cycle this bill belongs to.-
>> cycle.idIntegerMandatoryInternal cycle ID.7890
>> cycle.cycle_numberIntegerMandatory1-indexed cycle number within the plan.3
>> cycle.statusStringMandatorypending | paid | failed | skipped.paid
>> cycle.period_startString/NullOptionalCycle period start (ISO 8601).2026-05-01T00:00:00+07:00
>> cycle.period_endString/NullOptionalCycle period end (ISO 8601).2026-06-01T00:00:00+07:00

Body Example — subscription.cycle.payment_success

Success: 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"
    }
  }
}

Body Example — 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"
    }
  }
}

Body Example — 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" }
        ]
      }
    }
  }
}

Body Structure — Plan Status Changed

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

FieldTypeMandatoryDescriptionExample
statusNumericMandatory200.200
successBooleanMandatorytrue.true
eventStringMandatorysubscription.plan.status_changed.subscription.plan.status_changed
timestampStringMandatoryEvent timestamp (format: d M Y H:i:s).07 May 2026 00:05:00
dataObjectMandatoryEvent payload.-
> planObjectMandatoryPlan snapshot (same shape as the plan block above).-
> previous_statusStringMandatoryStatus the plan was in before the change (e.g. active).active

Body Example — subscription.plan.status_changed

Plan 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"
  }
}

Security Mechanisms

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:

  1. IP Whitelist — Restrict your webhook endpoint to SingaPay’s official IP addresses at the firewall or reverse proxy layer.
  2. HMAC Signature Validation — Verify X-Signature using your Client Secret. See How to Validate Signature for a step-by-step PHP, Node.js, and Python walkthrough.
  3. Timestamp Freshness — Reject requests where X-Timestamp is more than 5 minutes off from server time to mitigate replay attacks.

Signing Scheme Reminder

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.

Expected Response

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.


Idempotency

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:

  • A successful charge triggers a retry at the webhook-delivery layer (not the charge layer) because your endpoint returned a non-2xx status.
  • Your webhook processor reruns historical events (replay).

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.


Important Notes

  • One event per attempt: 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.
  • Plan status events are separate: When retries are exhausted with 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".
  • Lineage in upgrades: 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.
  • 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.
  • Currency in bill: bill.currency is always IDR today (card recurring runs in IDR only).