CentraPoint

API

Coupons

Create discount codes for hosted checkout and customer subscriptions, and manage and validate them through the API.

On this page

Overview#

Coupons are discount codes your customers enter at checkout, or that you apply when you subscribe a customer. A coupon gives a percentage or fixed discount, can be limited to products or subscriptions (or to specific ones), and can have a validity window, a minimum amount and redemption limits. Manage them in the dashboard under Coupons or with the API on this page.

Requirements#

Coupons need the Coupons feature in your plan. Without it the Coupons page is not in the menu and shows an upgrade notice, hosted checkout shows no coupon field and a coupon code is rejected with "Coupons are not included in your plan". The API endpoints also need the REST API; without Coupons they return 403 plan_restricted ("Coupons are not included in your plan"). In the dashboard, viewing coupons needs coupons:read and creating or changing them coupons:write (see Roles).

Where coupons apply#

Where a coupon code can be used
WhereHow
Hosted checkout pagesThe payer enters the code in the Coupon code field; the order summary updates before they pay.
Checkout sessionscouponCode on POST /api/v1/checkout-sessions pre-applies the code on the page (the payer can remove it).
Customer subscriptionscouponCode on POST /api/v1/customer-subscriptions, or the Coupon code field when you add a subscription in the dashboard.

Coupons are not used on invoices, payment links or in the customer portal. When a coupon is used on a subscription, the subscription keeps its couponId and the coupon keeps applying per its duration, even if the coupon is later disabled or expires.

Coupon settings#

Coupon settings (dashboard and API)
FieldTypeDescription
coderequiredstring3–50 letters, digits, - or _. Stored and matched in upper case, unique in your organisation. It can only be changed while the coupon has never been used.
descriptionoptionalstring | nullInternal note, up to 500 characters.
typerequiredstringpercent or fixed.
valuerequirednumberGreater than 0: a percentage up to 100, or an amount up to 1 000 000. Rounded to 2 decimals.
currencyoptionalstring | null3-letter code, required for fixed discounts (only purchases in this currency qualify). Ignored and stored as null for percentages.
appliesTooptionalstringany (default, products and subscriptions), product or subscription.
productIdsoptionalstring[]Up to 200 of your product IDs; empty = every product. Ignored when appliesTo is subscription.
planIdsoptionalstring[]Up to 200 of your plan IDs; empty = every plan. Ignored when appliesTo is product.
durationoptionalstringFor subscriptions: once (default, first charge only), repeating or forever (every charge). See below.
durationCyclesoptionalinteger | null1–120 billing cycles; required for repeating, stored as null otherwise.
validFromoptionalstring (date-time) | nullStart of validity; default now. The dashboard uses the start of the day (South African time).
validTooptionalstring (date-time) | nullEnd of validity, after validFrom; null = no end. The dashboard uses the end of the chosen day, so the day is included.
maxRedemptionsoptionalinteger | null1–10 000 000 uses in total; null = unlimited.
maxPerCustomeroptionalinteger | null1–10 000 uses per customer; null = unlimited.
minAmountoptionalnumber | null0–100 000 000: the minimum price before discount and tax.
enabledoptionalbooleanDefault true. A disabled coupon can't be used.

How discounts are calculated#

  • The discount is taken from the price before discount and tax: the product amount, or the plan price × quantity for subscriptions.
  • percent: price × value / 100, rounded to the nearest cent. fixed: the value. Either way the discount is never more than the price and never negative.
  • Products: tax is calculated on the discounted price (added for exclusive prices, contained for inclusive prices).
  • Subscriptions: a setup fee is not discounted. The first payment is setup fee + price − discount, with tax added on top when your prices exclude tax.
  • A coupon that works out to no discount is rejected ("This coupon gives no discount on this item").

Example: WELCOME10 (10%) on a R 499,00 per month plan gives a discount of R 49,90, so the first payment is R 449,10 (plus tax if your prices exclude it).

Subscriptions: once, repeating or forever#

Coupon duration
durationDiscounted charges
onceThe first charge only.
repeatingThe first durationCycles charges (including the first).
foreverEvery charge. The subscription's amount is stored with the discount already taken off.

Recurring products are billed by the gateway at a fixed amount, so only forever coupons can be used on them ("This coupon cannot be used for recurring products"). duration has no effect on once-off products.

Validation rules#

A code is checked when it is entered at checkout, when the checkout is submitted, when a subscription is created with it, and by POST /api/v1/coupons/validate. The rules are checked in this order; the first that fails is returned with a customer-safe message:

Coupon failure reasons
reasonmessageWhen
not_foundThis coupon code is not validNo coupon with this code.
disabledThis coupon is no longer activeenabled is false.
not_startedThis coupon is not valid yetBefore validFrom.
expiredThis coupon has expiredAfter validTo.
wrong_kindThis coupon cannot be used for this purchaseA product-only coupon on a subscription, or the other way round.
not_applicableThis coupon does not apply to this itemThe product or plan is not in productIds / planIds.
recurring_durationThis coupon cannot be used for recurring productsA recurring product and a coupon that isn't forever.
currency_mismatchThis coupon is not valid for this currencyA fixed coupon in another currency.
below_minimumThe order amount is below this coupon's minimumPrice before discount and tax is below minAmount.
exhaustedThis coupon has been fully redeemedmaxRedemptions reached.
customer_limitYou have already used this couponmaxPerCustomer reached for this customer.
no_discountThis coupon gives no discount on this itemThe discount works out to zero.

When a customer subscription is created with a code that doesn't exist or is disabled, the error is "Coupon not found or no longer active"; the other reasons use the messages above.

Redemptions and limits#

Each use of a coupon is recorded as a redemption with a status:

Redemption statuses
StatusMeaning
pendingA hosted checkout using the coupon has started and its payment has not settled.
redeemedThe payment completed, or the subscription became active.
voidThe payment failed, was cancelled or was abandoned. If the payment later completes after all, the redemption becomes redeemed again.
  • Hosted checkout: a pending redemption is recorded when the payer is sent to the gateway, and settled when the payment completes, fails or is cancelled. For a package checkout the redemption belongs to the subscription and is marked redeemed when the subscription activates.
  • Trials and subscriptions created through the API or dashboard: no pending redemption is recorded; a redeemed one is recorded when the subscription activates (its first charge is paid).
  • maxRedemptions and maxPerCustomer count redeemed redemptions plus pending ones less than 2 hours old, so an abandoned checkout frees its slot after 2 hours. The daily housekeeping job (01:00 South African time) voids stale pending redemptions (after 2 hours once the payment has failed or been cancelled, or after 24 hours while it is still pending).
  • Hosted checkout counts maxPerCustomer across every customer record with the email address the payer enters (the validate endpoint does the same with customerEmail). A subscription created through the API or dashboard counts that customer's own redemptions.

The coupon's page in the dashboard lists its redemptions (customer, payment, subscription, discount, status and date). Through the API, use GET /api/v1/coupons/{id}?include=redemptions.

The coupon object#

Coupon
{
  "id": "cmg5c8u2p0001cpn0001abcd",
  "object": "coupon",
  "code": "WELCOME10",
  "description": "Launch campaign",
  "type": "percent",
  "value": 10,
  "currency": null,
  "appliesTo": "subscription",
  "productIds": [],
  "planIds": [
    "cmg4p1a2b0002pln0001abcd"
  ],
  "duration": "repeating",
  "durationCycles": 3,
  "validFrom": "2026-09-28T08:00:00.000Z",
  "validTo": "2026-12-31T21:59:59.000Z",
  "maxRedemptions": 500,
  "maxPerCustomer": 1,
  "minAmount": null,
  "enabled": true,
  "redemptions": {
    "redeemed": 42,
    "pending": 2
  },
  "createdAt": "2026-09-28T08:00:00.000Z",
  "updatedAt": "2026-09-28T08:00:00.000Z"
}
Coupon fields
FieldTypeDescription
idrequiredstringCoupon ID.
objectrequiredstringcoupon
code, description, type, value, currencyrequiredAs in Coupon settings.
appliesTo, productIds, planIdsrequiredstring, string[], string[]Restrictions; empty arrays = no restriction.
duration, durationCyclesrequiredstring, integer | null
validFrom, validTorequiredstring, string | null
maxRedemptions, maxPerCustomer, minAmountrequiredinteger | null, integer | null, number | null
enabledrequiredboolean
redemptionsrequiredobject{ redeemed, pending }: redeemed count and pending redemptions currently holding a slot. Always this object, in every response that returns a coupon.
recentRedemptionsoptionalobject[]Only on Get a coupon with ?include=redemptions: the latest 100 redemptions.
createdAt, updatedAtrequiredstring

Create a coupon#

POST/api/v1/coupons

Takes the fields in Coupon settings; only code, type and value are required. Numbers may be sent as numbers or numeric strings. Unknown fields are ignored.

curl -X POST "https://app.centrapoint.co.za/api/v1/coupons" \
  -H "Authorization: Bearer $CENTRAPOINT_API_KEY" \
  -H "Idempotency-Key: 7a4c1e9b-2d3f-4b6a-8e0c-9f1d2a3b4c5e" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "welcome10",
    "description": "Launch campaign",
    "type": "percent",
    "value": 10,
    "appliesTo": "subscription",
    "planIds": [
      "cmg4p1a2b0002pln0001abcd"
    ],
    "duration": "repeating",
    "durationCycles": 3,
    "validTo": "2026-12-31T23:59:59+02:00",
    "maxRedemptions": 500,
    "maxPerCustomer": 1
  }'

Returns 201 with the coupon (redemptions is { redeemed: 0, pending: 0 }). Supports the Idempotency-Key header.

List coupons#

GET/api/v1/coupons
List query parameters
FieldTypeDescription
codeoptionalstringExact code (case-insensitive).
enabledoptionalstringtrue or false; other values are ignored.
limitoptionalinteger1–100, default 20.

Returns { "data": [ … ] }, newest first, each with its redemptions counts.

curl -X GET "https://app.centrapoint.co.za/api/v1/coupons?code=WELCOME10" \
  -H "Authorization: Bearer $CENTRAPOINT_API_KEY"

Get a coupon#

GET/api/v1/coupons/{id}

Returns the coupon with its counts. With ?include=redemptions it also has recentRedemptions, a list of the latest 100 redemptions, newest first (redemptions stays the counts object):

Redemption fields
FieldTypeDescription
idrequiredstringRedemption ID.
statusrequiredstringpending, redeemed or void.
discountAmountrequirednumberDiscount given (per charge for subscriptions).
customerId, subscriptionIdrequiredstring | null
transactionReferencerequiredstring | nullReference of the payment, when there is one.
createdAtrequiredstringWhen it was recorded, or when it was redeemed.
curl -X GET "https://app.centrapoint.co.za/api/v1/coupons/cmg5c8u2p0001cpn0001abcd?include=redemptions" \
  -H "Authorization: Bearer $CENTRAPOINT_API_KEY"

Update or disable a coupon#

PATCH/api/v1/coupons/{id}

Send any of the create fields. They are merged with the stored coupon and the result is validated as a whole (for example, switching to fixed needs a currency); an invalid result returns 400 invalid_request with the zod issues. null clears validTo, the limits and minAmount; validFrom: null keeps the current start. The code can't change once the coupon has been used. Unknown fields are ignored. The Idempotency-Key header is not used.

curl -X PATCH "https://app.centrapoint.co.za/api/v1/coupons/cmg5c8u2p0001cpn0001abcd" \
  -H "Authorization: Bearer $CENTRAPOINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": false
  }'

Validate a coupon#

POST/api/v1/coupons/validate

Checks whether a code would apply to a product or plan, for example to show the discount in your own checkout. Nothing is reserved or redeemed. The Idempotency-Key header is not used.

Validate fields
FieldTypeDescription
coderequiredstring1–50 characters, case-insensitive.
productId or planIdrequiredstringExactly one of your products or plans.
amountoptionalnumberPrice before discount and tax (greater than 0, up to 100 000 000); default the product or plan price. For a quantity above 1, send price × quantity.
currencyoptionalstring3-letter code; default the product or plan currency.
customerIdoptionalstringChecks maxPerCustomer for this customer.
customerEmailoptionalstringChecks maxPerCustomer for every customer with this email.
curl -X POST "https://app.centrapoint.co.za/api/v1/coupons/validate" \
  -H "Authorization: Bearer $CENTRAPOINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "welcome10",
    "planId": "cmg4p1a2b0002pln0001abcd",
    "customerEmail": "[email protected]"
  }'

Always returns 200 when the product or plan exists. total is amount − discount (before tax and setup fees); coupon is the coupon object, with its redemptions counts.

Valid
{
  "valid": true,
  "discount": 49.9,
  "amount": 499,
  "total": 449.1,
  "currency": "ZAR",
  "coupon": {
    "id": "cmg5c8u2p0001cpn0001abcd",
    "object": "coupon",
    "code": "WELCOME10",
    "…": "…"
  }
}
Not valid
{
  "valid": false,
  "reason": "customer_limit",
  "message": "You have already used this coupon"
}

reason is one of the values in Validation rules.

Errors#

Coupon errors
StatuserrorWhen
400invalid_requestValidation failed (Invalid body with issues, e.g. "Code must be 3-50 letters, digits, - or _", "Value must be greater than zero", "A percentage cannot be more than 100", "A fixed discount needs a currency", "Currency must be a 3-letter code", "Enter how many billing cycles the discount repeats for", "The end date must be after the start date", "Send exactly one of productId or planId"), or "A coupon with the code … already exists", "One or more products were not found", "One or more plans were not found", "The code of a coupon that has been used cannot be changed. Create a new coupon instead."
403plan_restrictedNo REST API, or no Coupons, in your plan.
403account_restrictedThe account is read-only (POST and PATCH, including validate).
404not_foundCoupon not found; on validate, Product not found or Plan not found.
409idempotency_conflictIdempotency-Key reused with a different body (POST /api/v1/coupons).
401 / 429 / 500unauthorized / rate_limited / internal_errorSee Errors