
Subscription (Recurring)
Register a new recurring subscription plan. Returns a payment_link_url for the customer to complete the one-time card linking and initial charge.
| Method | Path | Format | Authentication |
|---|---|---|---|
| POST | /api/v2.0/recurring/plans | json | OAuth 2.0 with Access Token |
Creates a new subscription plan for the merchant. The plan starts in pending_card_linking status and returns a payment_link_url that must be opened by the customer to enter their card details once. The first cycle is charged as part of the card linking flow; subsequent cycles are auto-charged by SingaPay on the configured schedule.
Amount vs. Items: Send exactly ONE of amount (amount-only plan) or items[] (itemized plan). Sending both in the same request returns a 422 validation error.
| 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 |
| Parameter | Type | Mandatory | Validation | Description | Example |
|---|---|---|---|---|---|
| name | String | Required | max: 255 | Display name of the subscription plan. | Premium Monthly |
| subscription_id | String | Optional | max: 100, unique per merchant | Globally unique merchant-supplied identifier for this plan. Auto-generated if omitted. | PLAN-20260420-001 |
| merchant_reff_no | String | Optional | max: 255 | Merchant-side reference label (non-unique). Echoed back on every cycle webhook for easy matching in the merchant’s system. | SUB-CUST-ACME-001 |
| amount | Number | Conditional | min: card channel minimum (IDR) | Per-cycle charge in IDR. Required when items is not provided. Mutually exclusive with items. | 150000 |
| items | Array | Conditional | min: 1 item | Line items for an itemized plan. Required when amount is not provided. Mutually exclusive with amount. 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 | Free-form item category (defaults to product). | service |
| > items[].quantity | Integer | Required | min: 1 | Quantity of the line item. | 2 |
| > items[].unit_price | Number | Required | min: 0 | Unit price in IDR. | 75000 |
| currency | String | Optional | size: 3 (ISO 4217) | Currency code. Defaults to IDR. | IDR |
| customer_name | String | Required | max: 191 | Customer’s full name (used for invoicing and notifications). | John Doe |
| customer_email | String | Required | email, max: 191 | Customer’s email address. | john@example.com |
| customer_phone | String | Required | max: 50 | Customer’s phone number. | 08123456789 |
| customer_id | String | Optional | max: 100 | Customer identifier in your system. Auto-generated if omitted. | CUST-001 |
| account_id | String | Required | ULID; must belong to the merchant | Merchant Account ULID the plan is scoped to. | 01K5G4FZZ18DMK0M5QTR8Y9QY9 |
| schedule | Object | Required | - | Recurring schedule configuration. | - |
| > schedule.interval | Integer | Required | min: 1 | Number of interval_units between charges. | 1 |
| > schedule.interval_unit | String | Required | day, week, month | Time unit for the interval. | month |
| > schedule.total_interval | Integer | Optional | min: 1 | Total number of cycles to charge. Omit for an open-ended subscription. | 12 |
| > schedule.start_time | String | Required | date, on or after today | First cycle start date (YYYY-MM-DD). | 2026-05-01 |
| payment_type | String | Optional | credit_card, gopay | Restrict the customer checkout to a specific method. If omitted, all configured methods on the account are offered. | credit_card |
| return_url | String | Optional | url, max: 2048 | URL the customer is redirected to after the one-time card linking flow (success, failure, or cancel). | https://merchant.com/callback |
| retry_policy | Object | Optional | - | Failed-cycle retry policy. Defaults applied when omitted. | - |
| > retry_policy.max_attempts | Integer | Optional | 1–5 (default: 3) | Automatic retry attempts after the initial charge fails. | 3 |
| > retry_policy.interval_days | Integer | Optional | 1–7 (default: 3) | Days to wait between consecutive retry attempts. | 3 |
| > retry_policy.failed_payment_action | String | Optional | continue_plan, stop_plan | Behavior after retries are exhausted. Defaults to stop_plan. | stop_plan |
| charge_immediately | Boolean | Optional | - | When true, the plan runs the first charge immediately after card linking regardless of schedule.start_time. | false |
| allow_manual_payment | Boolean | Optional | - | When true, the merchant dashboard allows manual charging outside the automatic schedule. | false |
| allow_user_notification | Boolean | Optional | default: false | When true, the plan is created with email notifications enabled for every subscription event (plan created, cycle charged, payment failed, plan cancelled, etc.) addressed to customer_email. When false or omitted, no notification preferences are created — the merchant can still configure them later from the dashboard. | true |
| metadata | Object | Optional | - | Free-form metadata stored with the plan. | - |
| > metadata.description | String | Optional | max: 1000 | Plan description (surfaced on invoices and dashboards). | Premium monthly subscription |
Deprecated flat retry fields: retry_count, retry_interval_days, and failed_payment_action are still accepted as top-level fields for backward compatibility with pre-release integrations, but new integrations should use the nested retry_policy object shown above. The nested form is symmetric with API responses and webhook payloads.
{
"name": "Premium Monthly",
"subscription_id": "PLAN-20260420-001",
"merchant_reff_no": "SUB-CUST-ACME-001",
"amount": 150000,
"currency": "IDR",
"customer_name": "John Doe",
"customer_email": "john@example.com",
"customer_phone": "08123456789",
"customer_id": "CUST-001",
"account_id": "01K5G4FZZ18DMK0M5QTR8Y9QY9",
"schedule": {
"interval": 1,
"interval_unit": "month",
"total_interval": 12,
"start_time": "2026-05-01"
},
"payment_type": "credit_card",
"return_url": "https://merchant.com/callback",
"retry_policy": {
"max_attempts": 3,
"interval_days": 3,
"failed_payment_action": "stop_plan"
},
"allow_user_notification": true,
"metadata": {
"description": "Premium monthly subscription"
}
}
{
"name": "Team Plan",
"merchant_reff_no": "SUB-CUST-ACME-TEAM",
"items": [
{
"item_name": "Premium Seat",
"item_type": "service",
"quantity": 3,
"unit_price": 75000
},
{
"item_name": "Premium Support",
"item_type": "service",
"quantity": 1,
"unit_price": 50000
}
],
"customer_name": "John Doe",
"customer_email": "john@example.com",
"customer_phone": "08123456789",
"account_id": "01K5G4FZZ18DMK0M5QTR8Y9QY9",
"schedule": {
"interval": 1,
"interval_unit": "month",
"start_time": "2026-05-01"
},
"payment_type": "credit_card",
"return_url": "https://merchant.com/callback"
}
cURL Example:
curl -X POST "https://your-domain.com/api/v2.0/recurring/plans" \
-H "X-PARTNER-ID: {api_key}" \
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc..." \
-H "Content-Type: application/json" \
-d '{
"name": "Premium Monthly",
"merchant_reff_no": "SUB-CUST-ACME-001",
"amount": 150000,
"customer_name": "John Doe",
"customer_email": "john@example.com",
"customer_phone": "08123456789",
"account_id": "01K5G4FZZ18DMK0M5QTR8Y9QY9",
"schedule": {
"interval": 1,
"interval_unit": "month",
"start_time": "2026-05-01"
},
"payment_type": "credit_card",
"return_url": "https://merchant.com/callback"
}'
| Field | Type | Mandatory | Description | Example |
|---|---|---|---|---|
| response_code | String | Mandatory | Response code. SP000 on success. | SP000 |
| response_message | String | Mandatory | Human-readable response message. | Successfully |
| data | Object | Mandatory | Plan payload. | - |
| > id | String | Mandatory | Subscription Plan ID (26-char ULID). Use this on all subsequent GET/PATCH/Cancel calls. | 01JAB3CD4E5F6G7H8J9K0M1N2 |
| > name | String | Mandatory | Plan display name. | Premium Monthly |
| > amount | String | Mandatory | Per-cycle charge, formatted as a string to preserve precision. | “150000” |
| > currency | String | Mandatory | ISO 4217 currency code. | IDR |
| > created_at | String | Mandatory | Plan creation timestamp (ISO 8601). | 2026-04-20T10:00:00+07:00 |
| > schedule | Object | Mandatory | Schedule configuration + live progress. | - |
| >> schedule.interval | Integer | Mandatory | Interval value. | 1 |
| >> schedule.interval_unit | String | Mandatory | day | week | month. | month |
| >> schedule.current_interval | Integer | Mandatory | Number of cycles produced so far. | 0 |
| >> schedule.total_interval | Integer/Null | Optional | Total configured cycles, or null for open-ended. | 12 |
| >> schedule.start_time | String | Mandatory | First cycle start (ISO 8601). | 2026-05-01T00:00:00+07:00 |
| >> schedule.previous_payment_at | String/Null | Optional | Last successful charge timestamp (ISO 8601). | null |
| >> schedule.next_payment_at | String/Null | Optional | Next scheduled charge timestamp (ISO 8601). | 2026-05-01T00:00:00+07:00 |
| > status | String | Mandatory | Plan status (see lifecycle section on the Subscription landing page). | pending_card_linking |
| > payment_type | String/Null | Optional | Active payment method for the plan, or null before card linking completes. | credit_card |
| > retry_policy | Object | Mandatory | Resolved retry policy (applied defaults included). | - |
| >> retry_policy.max_attempts | Integer | Mandatory | Resolved max retry attempts. | 3 |
| >> retry_policy.interval_days | Integer | Mandatory | Resolved retry interval (days). | 3 |
| >> retry_policy.failed_payment_action | String | Mandatory | continue_plan | stop_plan. | stop_plan |
| > metadata | Object | Mandatory | Plan metadata container. | - |
| >> metadata.description | String/Null | Optional | Plan description. | Premium monthly subscription |
| >> metadata.extra | Object | Optional | Free-form merchant metadata stored on the plan. | |
| > subscription_id | String | Mandatory | Globally unique plan reference. | PLAN-20260420-001 |
| > merchant_reff_no | String/Null | Optional | Merchant-side reference label. | SUB-CUST-ACME-001 |
| > payment_link_url | String | Mandatory | Open this URL in the customer’s browser to complete card linking + initial charge. | https://pay.singapay.id/link/abc123 |
| > parent_plan_id | String/Null | Optional | Previous plan this one superseded via upgrade. null for plans created directly. | null |
| > created_from | String/Null | Optional | Origin marker: upgrade, downgrade, or null for directly-created plans. | null |
Success: Plan created. Redirect the customer to payment_link_url to complete card linking.
{
"response_code": "SP000",
"response_message": "Successfully",
"data": {
"id": "01JAB3CD4E5F6G7H8J9K0M1N2",
"name": "Premium Monthly",
"amount": "150000",
"currency": "IDR",
"created_at": "2026-04-20T10:00:00+07:00",
"schedule": {
"interval": 1,
"interval_unit": "month",
"current_interval": 0,
"total_interval": 12,
"start_time": "2026-05-01T00:00:00+07:00",
"previous_payment_at": null,
"next_payment_at": "2026-05-01T00:00:00+07:00"
},
"status": "pending_card_linking",
"payment_type": "credit_card",
"retry_policy": {
"max_attempts": 3,
"interval_days": 3,
"failed_payment_action": "stop_plan"
},
"metadata": {
"description": "Premium monthly subscription",
"extra": {
"payment_type": "credit_card",
"return_url": "https://merchant.com/callback",
"api_created": true
}
},
"subscription_id": "PLAN-20260420-001",
"merchant_reff_no": "SUB-CUST-ACME-001",
"payment_link_url": "https://pay.singapay.id/link/abc123",
"parent_plan_id": null,
"created_from": null
}
}
Error: Sent both amount and items in the same request.
{
"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: account_id does not belong to the authenticated merchant.
{
"response_code": "SP020",
"response_message": "Merchant Account Not Found",
"data": {}
}
payment_link_url in the customer’s browser. This is a one-time flow that collects card details and performs 3DS if required.charge_immediately: true or start_time is today) or deferred to schedule.start_time.active once the first charge succeeds.subscription_cycle_notif_url receives a subscription.cycle.payment_success or subscription.cycle.payment_failed notification for every cycle attempt. See the Subscription Cycle webhook reference.Auto-cancel on initial linking failure (charge_immediately = true only): If the customer submits card details and the issuer / acquirer rejects the charge, the plan is auto-cancelled instead of queueing retries — there is no saved card to retry against. The merchant receives a subscription.plan.status_changed webhook with plan.metadata.cancellation_reason set to initial_linking_failed. To re-attempt, create a new plan.
payment_type must be credit_card (or omitted to inherit the account default).Σ quantity × unit_price).schedule.start_time must be today or in the future.id is a 26-character ULID. Use it on all subsequent GET, PATCH, and Cancel requests.