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
| Header | Type | Description | Required |
|---|---|---|---|
X-Client-Id | String | Your client ID | ✅ Yes |
X-Client-Secret | String | Your client secret | ✅ Yes |
Content-Type | String | application/json | ✅ Yes |
Request Body
| Field | Type | Description | Required |
|---|---|---|---|
code | String | The code the customer presented | ✅ Yes |
order | Object | The order the discount is computed against | ✅ Yes |
referenceId | String | Your own reference — an order ID or similar | ✅ Yes |
idempotencyKey | String | Makes this call safe to retry | ✅ Yes |
Order Object
| Field | Type | Description |
|---|---|---|
amount | Number | Order total |
items | Array | Order line items |
items[].productId | String | Product identifier |
items[].categoryId | String | Category identifier |
items[].quantity | Integer | Units of this line |
items[].unitPrice | Number | Price 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
| Field | Type | Description |
|---|---|---|
couponId | String | The coupon the code resolved to |
result.discountAmount | Number | Money off the order — apply this figure |
result.freeShipping | Boolean | Present for FREE_SHIPPING coupons |
result.description | String | Human-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
}
| Field | Description |
|---|---|
kind | FIXED for a flat amount, PERCENTAGE for a proportion |
value | The amount, or the percentage (0–100) |
maxDiscount | Cap on a percentage discount. 0 means uncapped; not applicable to FIXED |
minOrderValue | Order 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
}
| Field | Description |
|---|---|
buyProductIds | Products that qualify the customer |
buyQuantity | How many must be bought to qualify one set |
getProductIds | Products given free |
getQuantity | How many are free per qualifying set |
maxSets | Cap 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 code | HTTP | Cause |
|---|---|---|
BAD_REQUEST | 400 | Missing field, malformed order, or an unknown field |
INSTRUMENT_NOT_FOUND | 404 | Coupon lookups by ID; not returned by this endpoint |
TEMPLATE_NOT_FOUND | 404 | The coupon's template no longer exists |
PROGRAM_NOT_FOUND | 404 | The coupon's campaign no longer exists |
IDEMPOTENCY_CONFLICT | 409 | Rare race on a duplicate key — retryable |
CV_INVALID | 422 | No coupon matches this code |
CV_ALREADY_USED | 422 | The coupon is not ACTIVE — already used, voided, or lost the redemption race |
CV_EXPIRED | 422 | Past its expiry date |
CV_USAGE_LIMIT_REACHED | 422 | The usage limit is exhausted |
CV_NOT_APPLICABLE | 422 | The discount could not be computed — see below |
PROGRAM_INACTIVE | 422 | The campaign is not ACTIVE |
TEMPLATE_LIMIT_EXCEEDED | 422 | The template's value cap is reached |
CAMPAIGN_LIMIT_EXCEEDED | 422 | The campaign's value budget is exhausted |
INTERNAL_SERVER_ERROR | 500 | Platform 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.
Related Endpoints
- Validate Coupon — dry run before committing
- Issue Coupon
- How Redemption Works