
Subscription (Recurring)
Patch a subscription plan. Cosmetic changes are applied in place; amount/items changes trigger the full upgrade flow (close old plan, create new plan with lineage, optional proration).
PATCH behaves differently depending on which fields you include:
name, merchant_reff_no, or metadata.description are present, the existing plan is patched in place. No new plan is created and no charge is made.amount or items is present, SingaPay closes the existing plan and creates a new plan with parent_plan_id pointing at the old one. Proration is applied (auto by default), and a fresh payment_link_url is issued for the prorated charge when one is required. The response surfaces the new plan plus an upgrade block with the full lineage.This mirrors the dashboard “Upgrade Plan” feature 1:1, so plans created via the API and plans managed via the dashboard share a consistent history model.
Amount vs. Items: Send at most ONE of amount or items in the same request. Additionally, the chosen shape must match the plan’s existing type (amount-only plans require amount; itemized plans require items). Violations return 422 (same-request conflict) or 409 (shape mismatch with existing plan, response code SP102).
| Method | Path | Format | Authentication |
|---|---|---|---|
| PATCH | /api/v2.0/recurring/plans/{id} | json | OAuth 2.0 with Access Token |
| Parameter | Type | Mandatory | Description | Example |
|---|---|---|---|---|
| id | String | Required | Subscription Plan ID (26-char ULID) | 01JAB3CD4E5F6G7H8J9K0M1N2 |
| Field | Value | Type | Mandatory | Description | Example |
|---|---|---|---|---|---|
| Authorization | Bearer {access_token} | Alphanumeric | Mandatory | Bearer token obtained from the access token endpoint. | Bearer eyJ0eXAiOiJKV1{…} |
| X-PARTNER-ID | Alphanumeric | Mandatory | Your API Key from the merchant dashboard. | pk_live_abc123def456 | |
| Content-Type | application/json | Alphabetic | Mandatory | Specifies JSON as the request body format. | application/json |
All fields are optional individually, but the request must contain at least one recognized field to have any effect.
| Parameter | Type | Mandatory | Validation | Description | Example |
|---|---|---|---|---|---|
| name | String | Optional | max: 255 | Cosmetic. New plan display name. | Premium Monthly v2 |
| merchant_reff_no | String | Optional | max: 255 | Cosmetic. Merchant-side reference label. Pass null to clear. | SUB-CUST-ACME-001 |
| metadata | Object | Optional | - | Cosmetic. Free-form metadata merge (deep-merged onto existing metadata). | - |
| > metadata.description | String | Optional | max: 1000 | Cosmetic. Plan description override. | New description |
| amount | Number | Conditional | min: card channel minimum (IDR) | Upgrade trigger. New per-cycle charge in IDR. Mutually exclusive with items. Plan must be amount-only. | 180000 |
| items | Array | Conditional | min: 1 item | Upgrade trigger. Replacement line items. Mutually exclusive with amount. Plan must be itemized. Per-cycle charge = Σ(qty × price). | see example below |
| > items[].item_name | String | Required | max: 191 | Line item name. | Premium Seat |
| > items[].item_type | String | Optional | max: 50 | Item category (defaults to product). | service |
| > items[].quantity | Integer | Required | min: 1 | Quantity. | 3 |
| > items[].unit_price | Number | Required | min: 0 | Unit price in IDR. | 75000 |
| prorated_charge_mode | String | Optional | auto, manual (default: auto) | Upgrade only. auto — service computes the prorated charge; manual — use prorated_charge_amount as-is. | auto |
| prorated_charge_amount | Number | Optional | min: 0 (or 0 to skip) | Upgrade only, manual mode. Prorated charge amount in IDR. Must clear the card channel minimum unless set to 0 to skip the immediate charge. | 25000 |
Updates the plan in place. No new plan is created, no charge is made.
{
"name": "Premium Monthly v2",
"metadata": {
"description": "Updated description for the premium plan"
}
}
Triggers the full upgrade flow: closes the existing plan and creates a new one with parent_plan_id linking back. Proration is computed automatically for the remainder of the current cycle.
{
"amount": 180000
}
Replaces the line items of an itemized plan. prorated_charge_amount: 0 skips the immediate prorated charge (useful for customer-friendly mid-cycle adjustments).
{
"items": [
{
"item_name": "Premium Seat",
"item_type": "service",
"quantity": 5,
"unit_price": 75000
},
{
"item_name": "Premium Support",
"item_type": "service",
"quantity": 1,
"unit_price": 50000
}
],
"prorated_charge_mode": "manual",
"prorated_charge_amount": 0
}
cURL Example:
curl -X PATCH "https://your-domain.com/api/v2.0/recurring/plans/01JAB3CD4E5F6G7H8J9K0M1N2" \
-H "X-PARTNER-ID: {api_key}" \
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc..." \
-H "Content-Type: application/json" \
-d '{ "amount": 180000 }'
When no upgrade is triggered, the response is the same shape as Show Plan: the plan’s id is unchanged and the patched fields are reflected.
When amount or items is present, the response carries the new plan (with created_from: "upgrade" and parent_plan_id set to the old plan ID) plus an upgrade block summarizing the operation.
| Field | Type | Mandatory | Description | Example |
|---|---|---|---|---|
| response_code | String | Mandatory | SP000 on success. | SP000 |
| response_message | String | Mandatory | Human-readable response message. | Successfully |
| data | Object | Mandatory | New plan payload (same shape as Show Plan). | - |
| > id | String | Mandatory | New Subscription Plan ID. Use this on all subsequent calls. | 01JBK9PZ123456789ABCDEFGHJ |
| > parent_plan_id | String | Mandatory | ID of the plan this one superseded. | 01JAB3CD4E5F6G7H8J9K0M1N2 |
| > created_from | String | Mandatory | upgrade or downgrade. | upgrade |
| > upgrade | Object | Mandatory | Upgrade operation summary. | - |
| >> upgrade.previous_plan_id | String | Mandatory | ID of the closed old plan. | 01JAB3CD4E5F6G7H8J9K0M1N2 |
| >> upgrade.direction | String | Mandatory | upgrade (new amount higher) or downgrade (new amount lower). | upgrade |
| >> upgrade.difference | Object/Null | Optional | Numeric difference between the old and new per-cycle charge (shape: { amount, percentage }). | { "amount": 30000, "percentage": 20 } |
| >> upgrade.prorated_charge | Object/Null | Optional | Present when a prorated charge was created; null when no immediate charge is needed. | - |
| >>> upgrade.prorated_charge.bill_id | Number | Mandatory | Internal bill ID for the prorated charge. | 98765 |
| >>> upgrade.prorated_charge.amount | Number | Mandatory | Prorated charge amount in IDR. | 25000 |
| >>> upgrade.prorated_charge.status | String | Mandatory | pending | processing | paid | failed. | pending |
| >> upgrade.payment_link_url | String/Null | Optional | URL to pay the prorated charge, when one is required. null when no immediate charge is needed. | https://pay.singapay.id/link/xyz789 |
Success: Cosmetic fields patched in place. Plan ID is unchanged.
{
"response_code": "SP000",
"response_message": "Successfully",
"data": {
"id": "01JAB3CD4E5F6G7H8J9K0M1N2",
"name": "Premium Monthly v2",
"amount": "150000",
"currency": "IDR",
"status": "active",
"merchant_reff_no": "SUB-CUST-ACME-001",
"parent_plan_id": null,
"created_from": null,
"metadata": {
"description": "Updated description for the premium plan"
}
}
}
Success: New plan created. Collect the prorated charge by redirecting the customer to upgrade.payment_link_url.
{
"response_code": "SP000",
"response_message": "Successfully",
"data": {
"id": "01JBK9PZ123456789ABCDEFGHJ",
"name": "Premium Monthly",
"amount": "180000",
"currency": "IDR",
"created_at": "2026-04-20T11:30:00+07:00",
"schedule": {
"interval": 1,
"interval_unit": "month",
"current_interval": 0,
"total_interval": 10,
"start_time": "2026-04-20T11:30:00+07:00",
"previous_payment_at": null,
"next_payment_at": "2026-05-20T11:30:00+07:00"
},
"status": "active",
"payment_type": "credit_card",
"retry_policy": {
"max_attempts": 3,
"interval_days": 3,
"failed_payment_action": "continue_plan"
},
"subscription_id": "PLAN-20260420-001",
"merchant_reff_no": "SUB-CUST-ACME-001",
"payment_link_url": null,
"parent_plan_id": "01JAB3CD4E5F6G7H8J9K0M1N2",
"created_from": "upgrade",
"upgrade": {
"previous_plan_id": "01JAB3CD4E5F6G7H8J9K0M1N2",
"direction": "upgrade",
"difference": {
"amount": 30000,
"percentage": 20
},
"prorated_charge": {
"bill_id": 98765,
"amount": 25000,
"status": "pending"
},
"payment_link_url": "https://pay.singapay.id/link/xyz789"
}
}
}
Error: Sent both amount and items in the same PATCH.
{
"message": "The amount field prohibits items from being present.",
"errors": {
"amount": [
"The amount field prohibits items from being present."
],
"items": [
"The items field prohibits amount from being present."
]
}
}
Error: Plan is amount-only but the PATCH sent items (or vice versa).
{
"response_code": "SP102",
"response_message": "This plan is amount-only. Send `amount` to change the cycle charge, not `items`.",
"data": {}
}
Error: Plan is in a terminal state (cancelled/completed) and cannot be updated.
{
"response_code": "SP102",
"response_message": "Plan cannot be updated in its current state.",
"data": {}
}
{
"response_code": "SP100",
"response_message": "Subscription Plan Not Found",
"data": {}
}
When an upgrade fires, the service decides whether an immediate prorated charge is needed:
| Condition | Behavior |
|---|---|
direction = upgrade (new amount higher), mode auto | Service computes the remaining days in the current cycle × per-day price difference and issues a prorated bill. |
direction = upgrade, mode manual | prorated_charge_amount is used as-is. Must clear the card channel minimum or be 0. |
direction = downgrade (new amount lower) | No immediate charge. The lower amount takes effect on the next scheduled cycle. |
prorated_charge_amount = 0 | Explicitly skips the immediate charge. The new amount takes effect on the next scheduled cycle. |
data.id from the response and use it for all subsequent operations. The old plan ID is available on upgrade.previous_plan_id and parent_plan_id.currency, customer_*, schedule.*, and retry_policy cannot be changed via the API. Use the merchant dashboard for those changes.active, paused, or similar non-terminal status. Plans in cancelled or completed cannot be updated.