CentraPoint

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#

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.

NeedCall
Create (with add-ons, trial, coupon, start date, anchor day)POST /customer-subscriptions
Find by your customer id / status / plan / next billingGET /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 / resumePOST .../pause, .../resume
Cancel now or at period endPOST .../cancel {when, reason}
ReactivatePOST .../reactivate
Set next renewal date, anchor day, metadataPATCH /customer-subscriptions/:id
Credit / one-off discountPOST .../credit, .../discount
Invoices and payments of a subscriptionGET .../invoices, .../payments
Import subscriptions your platform keeps billing (mirror mode)collectionMethod: external, POST .../sync-period

Create a subscription#

POST/api/v1/customer-subscriptions
Request
{
  "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"
  }
}
New fields
FieldTypeDescription
collectionMethodrequiredstringgateway, 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).
planoptionalstringInstead of planId: the plan's id or its code.
dryRunoptionalbooleanValidate and preview only; nothing is written (dry run).
itemsoptionalarrayAdd-ons billed every cycle, metered lines and one-time lines on the first charge. See items.
startDateoptionalstringFuture start (YYYY-MM-DD or ISO date-time): the subscription is scheduled until then, then starts its trial or first charge.
paidThroughoptionalstringMoving an existing customer who already paid: active now, nothing charged, first renewal on this date.
billingAnchorDayoptionalintegerRenewals fall on this day of the month (clamped to the month end); the first period is shortened to it and pro-rated.
couponId / couponCodeoptionalstringCoupon 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#

kindBilledExample
addonEvery cycle: unitAmount x quantity. Included in the subscription amount.Website alias x3 at R 250 / month
meteredEach renewal: unitAmount x usage recorded that period (POST .../items/:itemId/usage), then reset.SMS at R 0.35 each
one_timeOn 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).

POST/api/v1/customer-subscriptions/{id}/items/{itemId}/usage
json
{
  "quantity": 120,
  "action": "increment"
}

Find subscriptions#

GET/api/v1/customer-subscriptions

Filters: 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#

POST/api/v1/customer-subscriptions/{id}/change-plan
json
{
  "quantity": 5,
  "items": [
    {
      "code": "website_alias",
      "description": "Website alias",
      "unitAmount": 250,
      "quantity": 3
    }
  ],
  "proration": "now"
}
prorationEffect
noneDefault (unchanged behaviour): switch now, the new price applies from the next renewal.
nowSwitch 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_renewalNothing 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#

POST/api/v1/customer-subscriptions/{id}/preview-change

Same body; nothing is changed.

Response
{
  "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#

CallNotes
POST .../pauseStops 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#

POST/api/v1/customer-subscriptions/{id}/credit
json
{
  "amount": 150,
  "description": "Downtime credit"
}
POST/api/v1/customer-subscriptions/{id}/discount
json
{
  "percent": 20,
  "description": "Loyalty"
}

Credit left over after a charge carries forward to the next one.

Invoices and payments#

GET/api/v1/customer-subscriptions/{id}/invoices
GET/api/v1/customer-subscriptions/{id}/payments

Invoices 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:

CallNotes
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/retryRe-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/chargesFirst payment, renewals, retries.

Migrating legacy Netcash subscriptions#

POST/api/v1/subscriptions/migrations
json
{
  "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 apiVersion is 1.16.0 or later and capabilities.externalSubscriptions is true. Older versions ignore the fields below and would start a subscription that CentraPoint bills. capabilities.externalSubscriptions is false while 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 with collectionMethod: "external", a dry run included, answers 409 external_imports_unavailable and writes nothing: try again later. Check tenant.status too: a read-only account answers writes with 403 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-subscriptions to total income or expected renewals: unfiltered lists (for example ?status=active) include the imported records, with your renewal dates in nextBillingAt. Leave out collectedByCentraPoint: false, or filter on collectionMethod, and page through nextCursor.

Customers, package and plans#

  1. Customers: POST /api/v1/customers with your own externalReference (it creates or updates the customer with that reference).
  2. 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.
  3. One plan per platform plan and billing cycle, each with a code of your own (for example acmehosting:pro:monthly). Look it up first with GET /api/v1/plans?code=acmehosting:pro:monthly and create it with POST /api/v1/packages/{id}/plans only when it is missing; a second plan with the same code gets 409 plan_code_taken. Amounts are price-list amounts in your tax basis, like any plan price.

Create the record#

POST/api/v1/customer-subscriptions
Request
{
  "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
}
Fields for collectionMethod external
FieldTypeDescription
plan or planIdrequiredstringplan takes the plan's id or its code.
externalReferencerequiredstringYour 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).
statusoptionalstringactive (default), trialing or past_due.
paidThroughrequiredstringEnd 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).
currentPeriodStartoptionalstringStart of your current period, not in the future. Default: one billing cycle before paidThrough (now when paid further ahead).
trialEndoptionalstringWith 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.
cancelAtPeriodEndoptionalbooleantrue when your platform will not renew it: the record ends, silently, at the period end.
itemsoptionalarrayRecurring add-ons only (kind: addon).
metadataoptionalobjectimportedFrom (your platform's name, shown as "Billed by ..."), platformSubscriptionId, mode: "mirror" and anything else you need.
sendEmailoptionalbooleanSend false. External subscriptions never email, whatever you send.
dryRunoptionalbooleanValidate 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.

200 response
{
  "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#

POST/api/v1/customer-subscriptions/{id}/sync-period
json
{
  "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 platformCall
Renewed, or the period changedPOST .../sync-period
Payment overdue / paid againPOST .../sync-period {status: past_due | active}
Plan, quantity or add-ons changedPOST .../change-plan (always recorded without proration: no charge)
Will not renewPOST .../cancel {when: period_end, sendEmail: false}
Ended nowPOST .../cancel {when: now, sendEmail: false}
Cancellation withdrawn, or subscribed againPOST .../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 by data.reference; these records have none, and a handler that rejected them would make CentraPoint retry. Read them with GET /api/v1/customer-subscriptions?collectionMethod=external instead.
  • Refused with 409 external_subscription: pause, resume, retry payment, credit, discount, one-time and metered items, moving billing dates (PATCH nextBillingAt / billingAnchorDay), a debit order mandate for it (POST /api/v1/mandates with its subscriptionId), changing or removing the externalReference, 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.