Guides
Managing subscriptions from your platform
Hand your customers' renewals to CentraPoint: create subscriptions with add-ons, change plans with proration and previews, credits, pauses, cancellations, reactivation, invoices, payments, webhooks, legacy migrations, and importing subscriptions your platform keeps billing (mirror mode).
On this page
- Overview
- Create a subscription
- Add-ons, metered and one-time items
- Find subscriptions
- Change plan, quantity or add-ons
- Preview a change
- Pause, cancel, reactivate, renewal date
- Credits and discounts
- Invoices and payments
- Webhooks
- Card subscriptions held at the gateway
- Migrating legacy Netcash subscriptions
- Importing subscriptions (mirror mode)
- Before you import
- Customers, package and plans
- Create the record
- Dry run
- Keep it in sync
- What CentraPoint never does for them
Overview#
A platform (hosting, messaging, cinema, support...) keeps its own customers and entitlements and lets CentraPoint run the money: the subscription, its billing periods, invoices, card / debit order / invoice collection, dunning and receipts. Everything below is the public API under /api/v1/customer-subscriptions (see the customer subscriptions API for the object) plus webhooks to keep your side in sync. Amounts are price-list amounts, like plan prices (net for tenants whose prices exclude VAT); previews and invoices show the tax on top.
| Need | Call |
|---|---|
| Create (with add-ons, trial, coupon, start date, anchor day) | POST /customer-subscriptions |
| Find by your customer id / status / plan / next billing | GET /customer-subscriptions?customerExternalReference=... |
| Change plan / seats / add-ons (proration now | next_renewal | none) | POST /customer-subscriptions/:id/change-plan |
| Preview that change (amounts incl. tax, invoice lines, periods) | POST /customer-subscriptions/:id/preview-change |
| Add / update / remove one item, record usage | /customer-subscriptions/:id/items[/:itemId[/usage]] |
| Pause / resume | POST .../pause, .../resume |
| Cancel now or at period end | POST .../cancel {when, reason} |
| Reactivate | POST .../reactivate |
| Set next renewal date, anchor day, metadata | PATCH /customer-subscriptions/:id |
| Credit / one-off discount | POST .../credit, .../discount |
| Invoices and payments of a subscription | GET .../invoices, .../payments |
| Import subscriptions your platform keeps billing (mirror mode) | collectionMethod: external, POST .../sync-period |
Create a subscription#
/api/v1/customer-subscriptions{
"customer": {
"externalReference": "nh-cust-1042"
},
"planId": "cmg4p1a2b0002pln0001abcd",
"collectionMethod": "gateway",
"quantity": 1,
"items": [
{
"code": "website_alias",
"description": "Website alias",
"unitAmount": 250,
"quantity": 3
}
],
"couponCode": "WELCOME10",
"billingAnchorDay": 1,
"externalReference": "nh-sub-1042",
"metadata": {
"domain": "example.co.za"
}
}| Field | Type | Description |
|---|---|---|
collectionMethodrequired | string | gateway, debit_order, invoice, manual (an invoice per cycle that is not emailed: you collect and record the payment on the invoice, which renews the subscription) or external (your platform bills it; CentraPoint only records it: see mirror mode). |
planoptional | string | Instead of planId: the plan's id or its code. |
dryRunoptional | boolean | Validate and preview only; nothing is written (dry run). |
itemsoptional | array | Add-ons billed every cycle, metered lines and one-time lines on the first charge. See items. |
startDateoptional | string | Future start (YYYY-MM-DD or ISO date-time): the subscription is scheduled until then, then starts its trial or first charge. |
paidThroughoptional | string | Moving an existing customer who already paid: active now, nothing charged, first renewal on this date. |
billingAnchorDayoptional | integer | Renewals fall on this day of the month (clamped to the month end); the first period is shortened to it and pro-rated. |
couponId / couponCodeoptional | string | Coupon by id or code. |
Creating twice with the same externalReference returns the existing subscription (200, created: false; new ones are 201 with created: true), so retries are safe, also when two requests arrive at the same time.
Add-ons, metered and one-time items#
| kind | Billed | Example |
|---|---|---|
addon | Every cycle: unitAmount x quantity. Included in the subscription amount. | Website alias x3 at R 250 / month |
metered | Each renewal: unitAmount x usage recorded that period (POST .../items/:itemId/usage), then reset. | SMS at R 0.35 each |
one_time | On the next charge only; negative = credit. | Installation fee, goodwill credit |
Give add-ons a code: you can then address them as /items/website_alias and send the full list in change-plan (matched by code). Invoices show each line with its billing period; credits are taken off the positive lines (invoices have no negative lines).
/api/v1/customer-subscriptions/{id}/items/{itemId}/usage{
"quantity": 120,
"action": "increment"
}Find subscriptions#
/api/v1/customer-subscriptionsFilters: status (comma list), planId, packageId, customerId, customerExternalReference, externalReference, nextBillingFrom / nextBillingTo (YYYY-MM-DD). Keyset paging with limit (max 100) and startingAfter; the response is { data, hasMore, nextCursor }.
Change plan, quantity or add-ons#
/api/v1/customer-subscriptions/{id}/change-plan{
"quantity": 5,
"items": [
{
"code": "website_alias",
"description": "Website alias",
"unitAmount": 250,
"quantity": 3
}
],
"proration": "now"
}| proration | Effect |
|---|---|
none | Default (unchanged behaviour): switch now, the new price applies from the next renewal. |
now | Switch now. Credit for the unused part of the old price and a charge for the new price over the rest of the period; a positive difference is invoiced / charged now (invoiceNow: false puts it on the next renewal), a credit goes to the next renewal. A frequency change starts a new period today. |
next_renewal | Nothing changes until the next renewal (pendingChange on the subscription; remove with DELETE .../pending-change). |
The response is the subscription plus change (the preview that applied) and charge (the proration invoice or card charge). Card subscriptions billed by the gateway change their amount in place where the gateway allows it (PayFast); otherwise the change is refused and you can replace the card subscription.
Preview a change#
/api/v1/customer-subscriptions/{id}/preview-changeSame body; nothing is changed.
{
"object": "subscription_change_preview",
"subscriptionId": "cmg4s9d8e0005csb0001abcd",
"requestedProration": "now",
"proration": "now",
"note": null,
"effectiveAt": "2026-10-16T08:00:00.000Z",
"currency": "ZAR",
"current": {
"planId": "cmg4p1a2b0002pln0001abcd",
"planName": "Pro",
"quantity": 1,
"frequency": "monthly",
"amount": 500,
"subtotal": 500,
"tax": 75,
"total": 575
},
"next": {
"planId": "cmg4p1a2b0002pln0001abcd",
"planName": "Pro",
"quantity": 1,
"frequency": "monthly",
"amount": 1250,
"items": [
{
"code": "website_alias",
"kind": "addon",
"description": "Website alias",
"unitAmount": 250,
"quantity": 3
}
],
"subtotal": 1250,
"tax": 187.5,
"total": 1437.5
},
"prorationInvoice": {
"lines": [
{
"description": "Unused time on Hosting - Pro",
"amount": -250,
"periodStart": "2026-10-16T08:00:00.000Z",
"periodEnd": "2026-10-31T00:00:00.000Z"
},
{
"description": "Remaining time on Hosting - Pro",
"amount": 625,
"periodStart": "2026-10-16T08:00:00.000Z",
"periodEnd": "2026-10-31T00:00:00.000Z"
}
],
"dueNow": true,
"creditToNextRenewal": 0,
"periodStart": "2026-10-01T00:00:00.000Z",
"periodEnd": "2026-10-31T00:00:00.000Z",
"resetsPeriod": false,
"subtotal": 375,
"tax": 56.25,
"total": 431.25
},
"nextInvoice": {
"date": "2026-10-31T00:00:00.000Z",
"periodStart": "2026-10-31T00:00:00.000Z",
"periodEnd": "2026-11-30T00:00:00.000Z",
"lines": [
{
"description": "Hosting - Pro",
"amount": 500
},
{
"description": "Website alias x3",
"amount": 750
}
],
"subtotal": 1250,
"tax": 187.5,
"total": 1437.5
}
}Pause, cancel, reactivate, renewal date#
| Call | Notes |
|---|---|
POST .../pause | Stops billing (debit orders and pausable card subscriptions too). |
POST .../cancel {when: now|period_end, reason} | atPeriodEnd still works. |
POST .../reactivate {nextBillingAt?, chargeNow?} | Cancelled / expired back to active (billing on nextBillingAt, default now), or undo a scheduled cancellation. |
PATCH /:id {nextBillingAt, billingAnchorDay} | Move the next renewal; for a scheduled subscription this moves its start. |
Credits and discounts#
/api/v1/customer-subscriptions/{id}/credit{
"amount": 150,
"description": "Downtime credit"
}/api/v1/customer-subscriptions/{id}/discount{
"percent": 20,
"description": "Loyalty"
}Credit left over after a charge carries forward to the next one.
Invoices and payments#
/api/v1/customer-subscriptions/{id}/invoices/api/v1/customer-subscriptions/{id}/paymentsInvoices have the fields of GET /api/v1/invoices/{id}: the billing period as inclusive YYYY-MM-DD dates (periodStart / periodEnd, the day before the next billing date), lines with details and their own periods, status (with partially_paid), amountDue, plus charge (first, cycle, recovery, setup, proration) and cycle.
Webhooks#
Every lifecycle change sends a subscription.* event: created, activated, renewed, trial_ending, trial_ended, payment_failed, past_due, paused, resumed, plan_changed, updated (items, credits, scheduled changes, scheduled start), cancelled, expired, reactivated. data is the subscription plus billingPeriod, and invoice (the same object as in the invoices list) / payment when the event concerns one. External (mirror) subscriptions send none.
Card subscriptions held at the gateway#
Gateway subscriptions (/api/v1/subscriptions) now report capabilities and support:
| Call | Notes |
|---|---|
POST /subscriptions/:id/update {amount?, nextDate?} | In place where the gateway can (PayFast: amount and date). Netcash cannot: an amount change emails the payer a new checkout at the new amount (result.mode replace); once paid the old Netcash subscription is deleted. While that checkout is unpaid, a replacement at the same amount re-sends the same link (also for two requests at once). If the payer completes it twice, only the first payment replaces the subscription: the second one's Netcash subscription is cancelled and the payment is reported to your staff to refund. The replaced subscription's unpaid retry links are deactivated. Subscriptions started by a recurring link for an invoice keep their amount, the invoice lines' total (409 invoice_linked). |
POST /subscriptions/:id/retry | Re-collects the latest missed charge of the subscription (its own failed renewal or charged retry; a declined checkout on a retry or replacement link is not one, nor a failed renewal of the card subscription a replacement checkout started) not re-collected yet: charges the stored card where CentraPoint can (for a subscription started by a payment link, recorded as a renewal of that link: subscription.charged / payment_failed), otherwise emails a payment link for the amount (replacing an earlier unpaid one). A pending charged retry counts until the gateway reports it. 409 nothing_to_retry when every missed charge was re-collected and no amount is sent (with amount, that amount is collected now); a subscription that never missed a charge is collected now. For a recurring link for an invoice, a missed charge not re-collected yet is required (else 409 nothing_to_retry, also with amount) and the retry payment is invoiced once, as the next period. |
GET /subscriptions/:id/charges | First payment, renewals, retries. |
Migrating legacy Netcash subscriptions#
/api/v1/subscriptions/migrations{
"subscriptions": [
{
"payerName": "Jane Smith",
"email": "[email protected]",
"amount": 115,
"frequency": "monthly",
"nextDate": "2026-11-01",
"legacyReference": "INV-2019-0042"
}
]
}For card subscriptions set up directly on Netcash Pay Now (not by CentraPoint). CentraPoint creates the customer and a gateway customer subscription and emails the payer a checkout link to confirm their card. When they pay, the subscription runs on CentraPoint and the legacy instruction is cancelled (DeleteSubscription with the legacy reference, using the service key of providerId), or left for you to cancel with cancelLegacy: manual. Track progress with GET /api/v1/subscriptions/migrations (status link_sent - authorised - legacy_cancelled) and act with POST /api/v1/subscriptions/migrations/:id/send-link | cancel-legacy | mark-legacy-cancelled | cancel. Idempotent on legacyReference. The same tool is under Subscriptions > Migrations in the dashboard (with CSV import).
Importing subscriptions (mirror mode)#
When your platform keeps billing its customers itself but every subscription should also be visible in CentraPoint (one customer view, reporting, a later switch to CentraPoint billing), import them with collectionMethod: "external". CentraPoint records the subscription and never charges, invoices, emails, reminds, duns or expires it: your platform stays the billing system and tells CentraPoint about each of its renewals. Available from API version 1.16.0.
Before you import#
- Call GET /api/v1/me and stop unless
apiVersionis1.16.0or later andcapabilities.externalSubscriptionsistrue. Older versions ignore the fields below and would start a subscription that CentraPoint bills.capabilities.externalSubscriptionsisfalsewhile CentraPoint is being updated and not every part of it that could bill a subscription (the API with its scheduled jobs, the dashboard and the customer portal) runs a version that leaves imported subscriptions alone. Until then a create withcollectionMethod: "external", a dry run included, answers409 external_imports_unavailableand writes nothing: try again later. Checktenant.statustoo: a read-only account answers writes with403 account_restricted. - The rate limit (120 requests per minute) is per API key and shared with your live traffic. Import one request at a time at no more than 100 per minute, or use a separate API key for the import and deactivate it afterwards.
- Always send
sendEmail: false, and keep your import report to ids and counts. - Before the first import, update everything that reads
GET /api/v1/customer-subscriptionsto total income or expected renewals: unfiltered lists (for example?status=active) include the imported records, with your renewal dates innextBillingAt. Leave outcollectedByCentraPoint: false, or filter oncollectionMethod, and page throughnextCursor.
Customers, package and plans#
- Customers:
POST /api/v1/customerswith your ownexternalReference(it creates or updates the customer with that reference). - One package for the import, for example
{ "name": "Acme Hosting (imported)", "enabled": false }. Keep it disabled: it is then never offered at checkout or in the customer portal. External subscriptions may use disabled plans and packages. - One plan per platform plan and billing cycle, each with a
codeof your own (for exampleacmehosting:pro:monthly). Look it up first withGET /api/v1/plans?code=acmehosting:pro:monthlyand create it withPOST /api/v1/packages/{id}/plansonly when it is missing; a second plan with the same code gets409 plan_code_taken. Amounts are price-list amounts in your tax basis, like any plan price.
Create the record#
/api/v1/customer-subscriptions{
"customer": {
"externalReference": "acmehosting:user:1042"
},
"plan": "acmehosting:pro:monthly",
"collectionMethod": "external",
"status": "active",
"currentPeriodStart": "2026-09-30",
"paidThrough": "2026-10-31",
"items": [
{
"code": "website_alias",
"description": "Website alias",
"unitAmount": 250,
"quantity": 2
}
],
"externalReference": "acmehosting-sub:1042",
"metadata": {
"importedFrom": "Acme Hosting",
"platformSubscriptionId": "1042",
"mode": "mirror"
},
"sendEmail": false
}| Field | Type | Description |
|---|---|---|
plan or planIdrequired | string | plan takes the plan's id or its code. |
externalReferencerequired | string | Your subscription id, for example acmehosting-sub:1042. Unique per organisation (guaranteed by the database). It is the record's import key: it cannot be changed or removed later (PATCH answers 409 external_subscription). |
statusoptional | string | active (default), trialing or past_due. |
paidThroughrequired | string | End of your current period (YYYY-MM-DD or ISO date-time): in the future for active, may be in the past for past_due. For trialing it is the trial end (future), and trialEnd may be sent instead of it or with it (the same day). |
currentPeriodStartoptional | string | Start of your current period, not in the future. Default: one billing cycle before paidThrough (now when paid further ahead). |
trialEndoptional | string | With status: trialing: when your trial ends, in the future. Optional when paidThrough carries it; when you send both they must be on the same calendar day, in South Africa or in UTC (the time of trialEnd is used). Sending trialEnd without status imports the row as trialing. |
cancelAtPeriodEndoptional | boolean | true when your platform will not renew it: the record ends, silently, at the period end. |
itemsoptional | array | Recurring add-ons only (kind: addon). |
metadataoptional | object | importedFrom (your platform's name, shown as "Billed by ..."), platformSubscriptionId, mode: "mirror" and anything else you need. |
sendEmailoptional | boolean | Send false. External subscriptions never email, whatever you send. |
dryRunoptional | boolean | Validate and preview only (dry run). |
providerId, mandateId, coupons, startDate, billingAnchorDay, trial and trialDays are refused with 400: import a discounted price as its own plan. The response is 201 with created: true, or 200 with created: false and the existing subscription when that externalReference was imported before (also for two requests at the same time), so a re-run of your import is safe. When the externalReference belongs to a subscription CentraPoint bills itself, or to another customer's, the create answers 409 external_reference_conflict instead: that row is not mirrored. The same code answers a create with another collectionMethod for a mirrored externalReference. To let CentraPoint bill one of them later, cancel the record (when: now, sendEmail: false) and create the new subscription with its own externalReference and paidThrough set to the end of the period your platform was paid for. payment.due is always false. The subscription has collectionMethod: "external", collectedByCentraPoint: false and billedBy (your metadata.importedFrom). nextBillingAt is your next renewal date, for information only.
Dry run#
With dryRun: true the request is validated exactly like a create and answers 200 without writing anything: no subscription, event, email or webhook, and no stored Idempotency-Key. It works for every collection method. The customer and plan must already exist: a dry run of a row whose customer or plan code you have not created yet answers the create's 400 (Customer not found / Plan not found), so create the customers and plans first (they are safe to repeat) and then dry-run the subscriptions. Other errors are the same 400s and 409s as a create.
{
"dryRun": true,
"action": "create",
"subscription": {
"id": null,
"object": "customer_subscription",
"status": "active",
"customer": {
"id": "cmg2c0s7t0003cust0001abcd",
"externalReference": "acmehosting:user:1042",
"email": "[email protected]"
},
"plan": {
"id": "cmg4p1a2b0002pln0001abcd",
"name": "Pro (monthly)",
"packageId": "cmg4p1a2b0001pkg0001abcd",
"packageName": "Acme Hosting (imported)"
},
"collectionMethod": "external",
"collectedByCentraPoint": false,
"billedBy": "Acme Hosting",
"amount": 1000,
"currentPeriodStart": "2026-09-30T04:00:00.000Z",
"currentPeriodEnd": "2026-10-31T04:00:00.000Z",
"nextBillingAt": "2026-10-31T04:00:00.000Z",
"externalReference": "acmehosting-sub:1042",
"createdAt": null
}
}action is create (with a preview: no id or timestamps yet) or exists (with the subscription that already has the externalReference).
Keep it in sync#
/api/v1/customer-subscriptions/{id}/sync-period{
"currentPeriodStart": "2026-10-31",
"currentPeriodEnd": "2026-11-30",
"status": "active"
}Call it after each renewal or status change on your platform. External subscriptions only (409 not_external for the others). A period that starts where the recorded one ended counts as a renewal (cyclesBilled + 1); sending the recorded period and status again changes nothing. status (default: unchanged) is active (period end in the future), past_due (while you wait for payment; the period may have ended) or trialing (the period end is the trial end). A scheduled cancellation moves to the new period end. A cancelled or expired record returns 409 invalid_state: reactivate it first. A period that starts and ends before the recorded one with the same status returns 409 stale_period and changes nothing (an older update that arrived late, e.g. a retry); to correct the current period, keep its start or send the status with it. Two identical calls at the same time record one renewal; a call that keeps losing to other updates of the subscription at the same time answers 409 concurrent_update and changes nothing (send it again). Nothing is charged, emailed or sent.
| On your platform | Call |
|---|---|
| Renewed, or the period changed | POST .../sync-period |
| Payment overdue / paid again | POST .../sync-period {status: past_due | active} |
| Plan, quantity or add-ons changed | POST .../change-plan (always recorded without proration: no charge) |
| Will not renew | POST .../cancel {when: period_end, sendEmail: false} |
| Ended now | POST .../cancel {when: now, sendEmail: false} |
| Cancellation withdrawn, or subscribed again | POST .../reactivate (silent; chargeNow is refused), then sync-period |
What CentraPoint never does for them#
- No charges, invoices, payment links, card or debit order collections, so nothing reaches your accounting integration either.
- No customer emails: no activation, renewal, reminder, dunning, cancellation or invoice email.
- No scheduled changes: no renewals, trial ends, past_due, dunning, expiry or renewal reminders. Only a cancellation you scheduled is carried out, at its end date.
- No
subscription.*webhooks. Webhook handlers usually match events to a payment bydata.reference; these records have none, and a handler that rejected them would make CentraPoint retry. Read them withGET /api/v1/customer-subscriptions?collectionMethod=externalinstead. - Refused with
409 external_subscription: pause, resume, retry payment, credit, discount, one-time and metered items, moving billing dates (PATCHnextBillingAt/billingAnchorDay), a debit order mandate for it (POST /api/v1/mandateswith itssubscriptionId), changing or removing theexternalReference, and changes from the customer portal (which shows them read-only, as billed by your platform).
For income CentraPoint collects, leave these out: collectedByCentraPoint is false, or filter with ?collectionMethod=gateway,debit_order,invoice,manual. When you later let CentraPoint bill, payments carry sandbox (see Transactions): never settle a real invoice with a sandbox payment, and keep each gateway's CentraPoint sandbox setting the same as its own test mode.