Singapay Home Page
Logo Icon
  1. Subscription (Recurring)
  2. Update / Upgrade Plan

Overview

PATCH behaves differently depending on which fields you include:

  • Soft update — When only 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.
  • Upgrade flow — When 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).


Information

MethodPathFormatAuthentication
PATCH/api/v2.0/recurring/plans/{id}jsonOAuth 2.0 with Access Token

Request Details

Path Parameters

ParameterTypeMandatoryDescriptionExample
idStringRequiredSubscription Plan ID (26-char ULID)01JAB3CD4E5F6G7H8J9K0M1N2

Headers Structure

FieldValueTypeMandatoryDescriptionExample
AuthorizationBearer {access_token}AlphanumericMandatoryBearer token obtained from the access token endpoint.Bearer eyJ0eXAiOiJKV1{…}
X-PARTNER-IDAlphanumericMandatoryYour API Key from the merchant dashboard.pk_live_abc123def456
Content-Typeapplication/jsonAlphabeticMandatorySpecifies JSON as the request body format.application/json

Body Structure

All fields are optional individually, but the request must contain at least one recognized field to have any effect.

ParameterTypeMandatoryValidationDescriptionExample
nameStringOptionalmax: 255Cosmetic. New plan display name.Premium Monthly v2
merchant_reff_noStringOptionalmax: 255Cosmetic. Merchant-side reference label. Pass null to clear.SUB-CUST-ACME-001
metadataObjectOptional-Cosmetic. Free-form metadata merge (deep-merged onto existing metadata).-
> metadata.descriptionStringOptionalmax: 1000Cosmetic. Plan description override.New description
amountNumberConditionalmin: card channel minimum (IDR)Upgrade trigger. New per-cycle charge in IDR. Mutually exclusive with items. Plan must be amount-only.180000
itemsArrayConditionalmin: 1 itemUpgrade trigger. Replacement line items. Mutually exclusive with amount. Plan must be itemized. Per-cycle charge = Σ(qty × price).see example below
> items[].item_nameStringRequiredmax: 191Line item name.Premium Seat
> items[].item_typeStringOptionalmax: 50Item category (defaults to product).service
> items[].quantityIntegerRequiredmin: 1Quantity.3
> items[].unit_priceNumberRequiredmin: 0Unit price in IDR.75000
prorated_charge_modeStringOptionalauto, manual (default: auto)Upgrade only. auto — service computes the prorated charge; manual — use prorated_charge_amount as-is.auto
prorated_charge_amountNumberOptionalmin: 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

Request Example — Soft Update (Cosmetic Only)

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

Request Example — Upgrade (Amount Change, Auto-Prorate)

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
}

Request Example — Upgrade (Itemized, Manual Proration)

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 }'

Response Details

Response Structure — Soft Update

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.

Response Structure — Upgrade

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.

FieldTypeMandatoryDescriptionExample
response_codeStringMandatorySP000 on success.SP000
response_messageStringMandatoryHuman-readable response message.Successfully
dataObjectMandatoryNew plan payload (same shape as Show Plan).-
> idStringMandatoryNew Subscription Plan ID. Use this on all subsequent calls.01JBK9PZ123456789ABCDEFGHJ
> parent_plan_idStringMandatoryID of the plan this one superseded.01JAB3CD4E5F6G7H8J9K0M1N2
> created_fromStringMandatoryupgrade or downgrade.upgrade
> upgradeObjectMandatoryUpgrade operation summary.-
>> upgrade.previous_plan_idStringMandatoryID of the closed old plan.01JAB3CD4E5F6G7H8J9K0M1N2
>> upgrade.directionStringMandatoryupgrade (new amount higher) or downgrade (new amount lower).upgrade
>> upgrade.differenceObject/NullOptionalNumeric difference between the old and new per-cycle charge (shape: { amount, percentage }).{ "amount": 30000, "percentage": 20 }
>> upgrade.prorated_chargeObject/NullOptionalPresent when a prorated charge was created; null when no immediate charge is needed.-
>>> upgrade.prorated_charge.bill_idNumberMandatoryInternal bill ID for the prorated charge.98765
>>> upgrade.prorated_charge.amountNumberMandatoryProrated charge amount in IDR.25000
>>> upgrade.prorated_charge.statusStringMandatorypending | processing | paid | failed.pending
>> upgrade.payment_link_urlString/NullOptionalURL to pay the prorated charge, when one is required. null when no immediate charge is needed.https://pay.singapay.id/link/xyz789

Response Example — Soft Update (HTTP 200)

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

Response Example — Upgrade with Prorated Charge (HTTP 200)

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 Response — Amount / Items in Same Request (HTTP 422)

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 Response — Wrong Shape for Plan Type (HTTP 409)

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 Response — Plan Not Upgradeable (HTTP 409)

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

Error Response — Plan Not Found (HTTP 404)

{
    "response_code": "SP100",
    "response_message": "Subscription Plan Not Found",
    "data": {}
}

Proration Rules

When an upgrade fires, the service decides whether an immediate prorated charge is needed:

ConditionBehavior
direction = upgrade (new amount higher), mode autoService computes the remaining days in the current cycle × per-day price difference and issues a prorated bill.
direction = upgrade, mode manualprorated_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 = 0Explicitly skips the immediate charge. The new amount takes effect on the next scheduled cycle.

Important Notes

  • New Plan ID: An upgrade returns a new plan ID. Store 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.
  • Idempotent Soft Updates: Sending the same cosmetic PATCH twice is a no-op.
  • What Cannot Be Updated: currency, customer_*, schedule.*, and retry_policy cannot be changed via the API. Use the merchant dashboard for those changes.
  • Card Linking: Upgrades reuse the tokenized card from the previous plan — the customer does not need to re-enter card details. The prorated charge (when present) uses the same saved card.
  • Status Requirements: The plan must be in active, paused, or similar non-terminal status. Plans in cancelled or completed cannot be updated.