Skip to main content
Recurring billing has three resources. A product is the thing you sell. A price attaches an amount, a currency and an interval to a product. A subscription binds a customer to one or more prices and generates an invoice each cycle. Prices are immutable on the fields that matter. To change what you charge, create a new price and archive the old one. Paths are relative to https://api.southpay.io/api/v2.

Authentication and scope

Reads require billing:read, writes require billing:write. Publishable keys are rejected. Idempotency keys are not used anywhere in billing. Retrying a create makes a second record.

Products

Create a product

POST /billing/products. Scope billing:write. Fields go inside a required product object.
string
required
string
object
boolean
Accepted but ignored on create. New products are always active. Use PATCH to deactivate one.

List products

GET /billing/products. Scope billing:read. Returns a bare array with no envelope and no pagination.
string
Send true to return only active products. Any other value is ignored and everything is returned.

Retrieve and update

GET /billing/products/{id}. Scope billing:read. Includes a prices array, newest first. PATCH /billing/products/{id}. Scope billing:write. Accepts name, description, active and metadata. This is the only way to set active. Deactivating a product does not archive its prices or cancel subscriptions using them. Do those separately. Unknown id returns 404 {"error": "Product not found"}.

Prices

Create a price

POST /billing/products/{product_id}/prices. Scope billing:write. Fields go inside a required price object.
integer
required
Minor units of currency.
string
required
Fiat currency. Every price on one subscription must share it.
string
required
daily, weekly, monthly or yearly.
integer
How many intervals per cycle. interval: "monthly" with interval_count: 3 bills quarterly.
object
product_id, unit_amount_cents, currency, interval and interval_count are immutable. There is no PATCH for a price. To change an amount, create a new price and archive the old one. Existing subscriptions keep billing the price they were created with until you update them.

List and retrieve prices

GET /billing/products/{product_id}/prices. Scope billing:read. A bare array, newest first, unpaginated. Includes archived prices. GET /billing/products/{product_id}/prices/{id}. Scope billing:read. Unknown id returns 404 {"error": "Price not found"}.

Archive and activate

POST /billing/products/{product_id}/prices/{id}/archive sets active: false. The price can no longer be used on new subscriptions. POST /billing/products/{product_id}/prices/{id}/activate sets it back to true. Both take no body and return 200 with the price.

Subscriptions

Create a subscription

POST /billing/subscriptions. Scope billing:write. Fields go inside a required subscription object.
string
required
Must be an active customer.
string
The anchor price. Use this for a single-price subscription.
object[]
Multiple prices on one subscription. Use instead of price_id.
integer
default:"1"
Quantity for the anchor price.
integer
default:"0"
Days before the first charge. Non-zero starts the subscription in trialing.
integer
default:"1"
How many failed cycles are tolerated in past_due before cancellation.
object
Every price on a subscription must share the same interval, interval_count and currency. Mixing them is rejected.
The first entry in items is the anchor price and always has id: null. It is synthesized from price_id rather than being a stored item row. Only add-on items have real ids. Do not use items[].id as a stable key without checking for null.

Response fields

string
required
trialing, active, past_due, paused or canceled. See lifecycle.
string
required
Start of the cycle being billed.
string
required
End of that cycle. Also the effective date for a period-end cancellation.
string | null
When the next invoice is generated. During a trial this is trial_end.
string | null
Null when trial_period_days was zero.
boolean
required
True when a cancellation is scheduled. The subscription is still active and still billing until cancel_at.
string | null
When the scheduled cancellation takes effect.
integer
required
Consecutive failed cycles. Once it exceeds grace_cycles the subscription is canceled.
integer
required
Tolerance before cancellation. Default 1.
integer
Total per cycle across all items. Present on the list view only.

List subscriptions

GET /billing/subscriptions. Scope billing:read.
string
trialing, active, past_due, paused or canceled.
string
This endpoint is unpaginated. It returns a bare array of every matching subscription in one response, with no data wrapper, no meta, and no page or per_page. Invoices and customers on the same billing surface are paginated with {data, meta}. Filter by status or customer_id to keep the response bounded.
Rows include customer, amount_cents, currency, interval and interval_count on top of the base fields.

Retrieve and update

GET /billing/subscriptions/{id}. Scope billing:read. Adds price, items and customer. PATCH /billing/subscriptions/{id}. Scope billing:write. Accepts price_id, quantity, grace_cycles, items and metadata. Sending items replaces the full set. A canceled subscription cannot be changed: 422 {"error": "Cannot change a canceled subscription"}. Unknown id returns 404 {"error": "Subscription not found"}.

Cancel

POST /billing/subscriptions/{id}/cancel. Scope billing:write.
boolean
default:"false"
Sent at the top level, not inside a subscription object. Omitting it cancels immediately.
Immediately: status becomes canceled and canceled_at is stamped. At period end: the subscription stays active, cancel_at_period_end becomes true and cancel_at is set to current_period_end. Both return 200 with the full subscription. Canceling an already-canceled subscription is a no-op 200.

Uncancel

POST /billing/subscriptions/{id}/uncancel. Scope billing:write. No body. Clears a scheduled period-end cancellation. It cannot revive an already-canceled subscription. That is terminal, and the call returns 200 with the subscription unchanged. Check status on the response, not the status code.

Pause and resume

POST /billing/subscriptions/{id}/pause stops billing and sets paused_at. POST /billing/subscriptions/{id}/resume sets status back to active, clears paused_at, resets failed_cycle_count to zero, and restarts the billing period from now. current_period_start, current_period_end and next_billing_at are all recalculated. The customer is not charged for the paused span. Both take no body and return 200 with the subscription.

Subscription lifecycle

Allowed transitions: trialing to active or canceled; active to past_due, paused or canceled; past_due to active or canceled; paused to active or canceled. An illegal transition returns 422 {"error": "Cannot move subscription from canceled to active"}. Four webhook events fire: subscription.created, subscription.updated, subscription.past_due and subscription.canceled. Pausing, resuming, uncanceling and scheduling a period-end cancellation all emit subscription.updated, so inspect the payload to tell them apart. Billing events carry the full subscription object in data.object and do not fan out to platform accounts.

Errors

Last modified on August 21, 2026