Skip to main content

Redeem Coupon

Consume a coupon against an order and receive the computed discount. This is the coupon redemption endpoint — it commits, and it counts against the coupon's usage limit.

Endpoint

POST /v1/coupons/redeem

Headers

HeaderTypeDescriptionRequired
X-Client-IdStringYour client ID✅ Yes
X-Client-SecretStringYour client secret✅ Yes
Content-TypeStringapplication/json✅ Yes

Request Body

FieldTypeDescriptionRequired
codeStringThe code the customer presented✅ Yes
orderObjectThe order the discount is computed against✅ Yes
referenceIdStringYour own reference — an order ID or similar✅ Yes
idempotencyKeyStringMakes this call safe to retry✅ Yes

Order Object

FieldTypeDescription
amountNumberOrder total
itemsArrayOrder line items
items[].productIdStringProduct identifier
items[].categoryIdStringCategory identifier
items[].quantityIntegerUnits of this line
items[].unitPriceNumberPrice per unit

The coupon is identified by code, never by internal ID — the ID is for reads and admin tooling.

Example Request

curl --location 'https://stage-platform-exlr8-incentives.exlr8now.com/v1/coupons/redeem' \
--header 'X-Client-Id: YOUR_CLIENT_ID' \
--header 'X-Client-Secret: YOUR_CLIENT_SECRET' \
--header 'Content-Type: application/json' \
--data '{
"code": "SAVE20-K4M9XQ",
"order": {
"amount": 4500,
"items": [
{
"productId": "sku-9981",
"categoryId": "electronics",
"quantity": 1,
"unitPrice": 4500
}
]
},
"referenceId": "order-20260825-0042",
"idempotencyKey": "order-20260825-0042-coupon-01"
}'

Response

Successful Response — 200 OK

{
"success": true,
"data": {
"couponId": "7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"result": {
"discountAmount": 900,
"description": "20% off (capped at 2000)"
}
}
}

Response Fields

FieldTypeDescription
couponIdStringThe coupon the code resolved to
result.discountAmountNumberMoney off the order — apply this figure
result.freeShippingBooleanPresent for FREE_SHIPPING coupons
result.descriptionStringHuman-readable summary, suitable for a receipt line

Apply result.discountAmount, not a figure carried over from validation. The discount is recomputed here from the order you sent, and this is the value that was committed.

Idempotency

idempotencyKey is required, and a repeated call with the same key replays the original result instead of consuming the coupon again.

{
"success": true,
"data": {
"couponId": "7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"result": {
"discountAmount": 900,
"description": "replayed (idempotent retry)"
}
}
}

:::info A replay is recognisable discountAmount and freeShipping are the committed values, but description is replaced with "replayed (idempotent retry)" — the original wording is not preserved. Use that string to tell a replay from a first redemption; never display description to a customer without checking it. :::

:::danger A reused key silently ignores the new parameters The key alone identifies the redemption. Reusing a key with a different coupon or a different order does not raise a conflict — the original result is returned and the new order is never evaluated. An incorrectly-derived key therefore fails silently, applying a stale discount to a different basket.

Derive the key deterministically from the business action:

{orderId}-coupon-{sequence} e.g. order-20260825-0042-coupon-01

Never generate it inline at call time — a retry would produce a new key and consume a second coupon use. :::

IDEMPOTENCY_CONFLICT appears only in a narrow race where the duplicate record cannot be re-read. It is rare and safe to retry.

Redemption commits under optimistic concurrency, so two simultaneous redemptions of a single-use coupon cannot both succeed — one commits, the other receives CV_ALREADY_USED.

Budgets are consumed at redemption

Unlike a gift card, whose value is committed when the card is issued, a coupon's cost is only realised when someone redeems it. The discount amount is charged against two budgets at this point:

  • The template's value cap → TEMPLATE_LIMIT_EXCEEDED
  • The campaign's value budget → CAMPAIGN_LIMIT_EXCEEDED

Both commit inside the same transaction as the redemption, so a breach rolls the whole redemption back and the coupon stays unconsumed. A coupon that issued successfully can still fail to redeem months later because the campaign has since exhausted its budget. Neither error is retryable; an administrator must raise the cap.

Discount behaviour by coupon type

The maths depends on the template's type and its config. You do not send any of this — it is shown so you can predict the figures.

SAVE_X — an amount off

{
"kind": "PERCENTAGE",
"value": 20,
"maxDiscount": 2000,
"minOrderValue": 1000
}
FieldDescription
kindFIXED for a flat amount, PERCENTAGE for a proportion
valueThe amount, or the percentage (0–100)
maxDiscountCap on a percentage discount. 0 means uncapped; not applicable to FIXED
minOrderValueOrder total below which the coupon does not apply

An order under minOrderValue is rejected with CV_NOT_APPLICABLE (see the note in Error Responses — the minimum-order failure is not given its own code).

BUY_X_GET_Y — free units

{
"buyProductIds": ["sku-1001"],
"buyQuantity": 2,
"getProductIds": ["sku-2002"],
"getQuantity": 1,
"maxSets": 3
}
FieldDescription
buyProductIdsProducts that qualify the customer
buyQuantityHow many must be bought to qualify one set
getProductIdsProducts given free
getQuantityHow many are free per qualifying set
maxSetsCap on qualifying sets. 0 means unlimited

The discount is the value of the free units, derived from the order's line prices.

FREE_SHIPPING — waive shipping

{
"minOrderValue": 500
}

Returns freeShipping: true once the order meets the threshold. discountAmount is 0 — shipping is not part of the order total the platform sees, so you waive the shipping charge on your side when this flag is set.

Applicability

When the template is scoped to CATEGORIES or PRODUCTS, only matching order lines form the discount base, and an order with no matching line is rejected with CV_NOT_APPLICABLE.

Sending a summary total instead of line items therefore produces a wrong discount or a spurious rejection. Send the real basket.

Error Responses

Error codeHTTPCause
BAD_REQUEST400Missing field, malformed order, or an unknown field
INSTRUMENT_NOT_FOUND404Coupon lookups by ID; not returned by this endpoint
TEMPLATE_NOT_FOUND404The coupon's template no longer exists
PROGRAM_NOT_FOUND404The coupon's campaign no longer exists
IDEMPOTENCY_CONFLICT409Rare race on a duplicate key — retryable
CV_INVALID422No coupon matches this code
CV_ALREADY_USED422The coupon is not ACTIVE — already used, voided, or lost the redemption race
CV_EXPIRED422Past its expiry date
CV_USAGE_LIMIT_REACHED422The usage limit is exhausted
CV_NOT_APPLICABLE422The discount could not be computed — see below
PROGRAM_INACTIVE422The campaign is not ACTIVE
TEMPLATE_LIMIT_EXCEEDED422The template's value cap is reached
CAMPAIGN_LIMIT_EXCEEDED422The campaign's value budget is exhausted
INTERNAL_SERVER_ERROR500Platform fault

:::caution CV_NOT_APPLICABLE is broader than its name suggests Every failure from the discount calculation collapses into this one code — not only "no order line is in scope", but also an order below the coupon's minimum value, and any other rejection the coupon type raises. The distinct codes you might expect for those cases are not returned.

The message field carries the underlying reason, so read it when you need to tell a customer why. Do not branch on it. :::

{
"success": false,
"errCode": "CV_ALREADY_USED",
"message": "Coupon has already been used: coupon not active"
}

Retry policy

Timeout or 5xx → retry the identical call, same key
409 IDEMPOTENCY_CONFLICT → do not retry; the key is wrong
4xx business error → do not retry; the coupon was rejected

There is no reversal operation for a coupon redemption. If you need to undo one, issue a replacement coupon from the same template.