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 | How |
|---|---|
| Hosted checkout pages | The payer enters the code in the Coupon code field; the order summary updates before they pay. |
| Checkout sessions | couponCode on POST /api/v1/checkout-sessions pre-applies the code on the page (the payer can remove it). |
| Customer subscriptions | couponCode 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#
| Field | Type | Description |
|---|---|---|
coderequired | string | 3–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. |
descriptionoptional | string | null | Internal note, up to 500 characters. |
typerequired | string | percent or fixed. |
valuerequired | number | Greater than 0: a percentage up to 100, or an amount up to 1 000 000. Rounded to 2 decimals. |
currencyoptional | string | null | 3-letter code, required for fixed discounts (only purchases in this currency qualify). Ignored and stored as null for percentages. |
appliesTooptional | string | any (default, products and subscriptions), product or subscription. |
productIdsoptional | string[] | Up to 200 of your product IDs; empty = every product. Ignored when appliesTo is subscription. |
planIdsoptional | string[] | Up to 200 of your plan IDs; empty = every plan. Ignored when appliesTo is product. |
durationoptional | string | For subscriptions: once (default, first charge only), repeating or forever (every charge). See below. |
durationCyclesoptional | integer | null | 1–120 billing cycles; required for repeating, stored as null otherwise. |
validFromoptional | string (date-time) | null | Start of validity; default now. The dashboard uses the start of the day (South African time). |
validTooptional | string (date-time) | null | End of validity, after validFrom; null = no end. The dashboard uses the end of the chosen day, so the day is included. |
maxRedemptionsoptional | integer | null | 1–10 000 000 uses in total; null = unlimited. |
maxPerCustomeroptional | integer | null | 1–10 000 uses per customer; null = unlimited. |
minAmountoptional | number | null | 0–100 000 000: the minimum price before discount and tax. |
enabledoptional | boolean | Default 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#
| duration | Discounted charges |
|---|---|
once | The first charge only. |
repeating | The first durationCycles charges (including the first). |
forever | Every 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:
| reason | message | When |
|---|---|---|
not_found | This coupon code is not valid | No coupon with this code. |
disabled | This coupon is no longer active | enabled is false. |
not_started | This coupon is not valid yet | Before validFrom. |
expired | This coupon has expired | After validTo. |
wrong_kind | This coupon cannot be used for this purchase | A product-only coupon on a subscription, or the other way round. |
not_applicable | This coupon does not apply to this item | The product or plan is not in productIds / planIds. |
recurring_duration | This coupon cannot be used for recurring products | A recurring product and a coupon that isn't forever. |
currency_mismatch | This coupon is not valid for this currency | A fixed coupon in another currency. |
below_minimum | The order amount is below this coupon's minimum | Price before discount and tax is below minAmount. |
exhausted | This coupon has been fully redeemed | maxRedemptions reached. |
customer_limit | You have already used this coupon | maxPerCustomer reached for this customer. |
no_discount | This coupon gives no discount on this item | The 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:
| Status | Meaning |
|---|---|
pending | A hosted checkout using the coupon has started and its payment has not settled. |
redeemed | The payment completed, or the subscription became active. |
void | The 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).
maxRedemptionsandmaxPerCustomercount 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
maxPerCustomeracross every customer record with the email address the payer enters (the validate endpoint does the same withcustomerEmail). 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#
{
"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"
}| Field | Type | Description |
|---|---|---|
idrequired | string | Coupon ID. |
objectrequired | string | coupon |
code, description, type, value, currencyrequired | As in Coupon settings. | |
appliesTo, productIds, planIdsrequired | string, string[], string[] | Restrictions; empty arrays = no restriction. |
duration, durationCyclesrequired | string, integer | null | |
validFrom, validTorequired | string, string | null | |
maxRedemptions, maxPerCustomer, minAmountrequired | integer | null, integer | null, number | null | |
enabledrequired | boolean | |
redemptionsrequired | object | { redeemed, pending }: redeemed count and pending redemptions currently holding a slot. Always this object, in every response that returns a coupon. |
recentRedemptionsoptional | object[] | Only on Get a coupon with ?include=redemptions: the latest 100 redemptions. |
createdAt, updatedAtrequired | string |
Create a coupon#
/api/v1/couponsTakes 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#
/api/v1/coupons| Field | Type | Description |
|---|---|---|
codeoptional | string | Exact code (case-insensitive). |
enabledoptional | string | true or false; other values are ignored. |
limitoptional | integer | 1–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#
/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):
| Field | Type | Description |
|---|---|---|
idrequired | string | Redemption ID. |
statusrequired | string | pending, redeemed or void. |
discountAmountrequired | number | Discount given (per charge for subscriptions). |
customerId, subscriptionIdrequired | string | null | |
transactionReferencerequired | string | null | Reference of the payment, when there is one. |
createdAtrequired | string | When 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#
/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#
/api/v1/coupons/validateChecks 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.
| Field | Type | Description |
|---|---|---|
coderequired | string | 1–50 characters, case-insensitive. |
productId or planIdrequired | string | Exactly one of your products or plans. |
amountoptional | number | Price 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. |
currencyoptional | string | 3-letter code; default the product or plan currency. |
customerIdoptional | string | Checks maxPerCustomer for this customer. |
customerEmailoptional | string | Checks 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": true,
"discount": 49.9,
"amount": 499,
"total": 449.1,
"currency": "ZAR",
"coupon": {
"id": "cmg5c8u2p0001cpn0001abcd",
"object": "coupon",
"code": "WELCOME10",
"…": "…"
}
}{
"valid": false,
"reason": "customer_limit",
"message": "You have already used this coupon"
}reason is one of the values in Validation rules.
Errors#
| Status | error | When |
|---|---|---|
| 400 | invalid_request | Validation 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." |
| 403 | plan_restricted | No REST API, or no Coupons, in your plan. |
| 403 | account_restricted | The account is read-only (POST and PATCH, including validate). |
| 404 | not_found | Coupon not found; on validate, Product not found or Plan not found. |
| 409 | idempotency_conflict | Idempotency-Key reused with a different body (POST /api/v1/coupons). |
| 401 / 429 / 500 | unauthorized / rate_limited / internal_error | See Errors |