Coupons
Author a per-app coupon catalogue, redeem a coupon by code when creating a subscription, and let the billing engine apply the discount per cycle.
A coupon is a reusable discount your app authors once and then redeems by its
code when creating a subscription. The catalogue is
per-app and per-mode: every coupon carries an app_id and a mode, and one app
never sees another app's — or another mode's — coupons. The discount is not applied
at create time; it is re-evaluated and applied per cycle by the billing engine
when it generates each cycle invoice.
The mode (TEST or LIVE) is derived from the API credential that authenticated
the request — never from the request body. A coupon created with a oi_test_…
key can only be redeemed by a test subscription. See
test & live modes and
app isolation.
All endpoints below are app-authenticated with your API key and secret. Money is
an integer count of minor units (paisa): 50000 means 500.00 BDT, never a float.
See money & amounts. Every response is wrapped in the
standard envelope:
{
"data": { "...": "payload or null" },
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}The rest of this page shows just the data payload unless an error is being
illustrated.
Create a coupon
POST /coupons creates a catalogue entry. It starts active. The create accepts
an Idempotency-Key header — a retry with the same key and body replays the first
response instead of creating a duplicate (see idempotency).
Request body — CreateCouponRequest
| Field | Type | Required | Notes |
|---|---|---|---|
code | string | yes | The app's stable redemption handle. Non-blank, ≤ 64 chars. Unique per app + mode and immutable once created. Trimmed server-side. |
name | string | yes | Human label. Non-blank, ≤ 255 chars. |
type | enum | yes | PERCENT or FIXED. See types & math. |
value | integer | yes | Must be positive. For PERCENT it is a 1..100 percentage; for FIXED it is an amount in minor units (paisa). |
duration | enum | yes | ONCE, REPEATING, or FOREVER. See durations. |
durationCycles | integer | conditional | Required (≥ 1) when duration is REPEATING; must be omitted for ONCE/FOREVER. Sending it the wrong way is a 400. |
expiresAt | datetime | no | Optional expiry instant (YYYY-MM-DDTHH:mm:ss). After it, the coupon is no longer redeemable. Omit for no expiry. |
code, value, type, duration, and durationCycles are immutable after
creation — there is no update endpoint. To change terms, create a new coupon and
deactivate the old one. Only the active flag changes, via deactivate.
A PERCENT coupon that repeats for three cycles:
curl -X POST http://localhost:8080/api/v1/coupons \
-H "X-Api-Key: oi_test_8a1f2c" -H "X-Api-Secret: sk_test_4d9e7b" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2e44-1b7a-4c0e-9a2d-3e5f6a7b8c90" \
-d '{
"code": "SAVE20",
"name": "20% off for 3 cycles",
"type": "PERCENT",
"value": 20,
"duration": "REPEATING",
"durationCycles": 3
}'Response (201 Created, data shown — the CouponDto):
{
"id": 4021,
"code": "SAVE20",
"name": "20% off for 3 cycles",
"type": "PERCENT",
"value": 20,
"duration": "REPEATING",
"durationCycles": 3,
"active": true,
"mode": "TEST",
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:00:00"
}A FIXED coupon worth 500.00 BDT off the first cycle only:
curl -X POST http://localhost:8080/api/v1/coupons \
-H "X-Api-Key: oi_test_8a1f2c" -H "X-Api-Secret: sk_test_4d9e7b" \
-H "Content-Type: application/json" \
-d '{
"code": "WELCOME500",
"name": "500 BDT off your first cycle",
"type": "FIXED",
"value": 50000,
"duration": "ONCE"
}'Response data:
{
"id": 4022,
"code": "WELCOME500",
"name": "500 BDT off your first cycle",
"type": "FIXED",
"value": 50000,
"duration": "ONCE",
"active": true,
"mode": "TEST",
"createdAt": "2026-06-30T12:05:00",
"updatedAt": "2026-06-30T12:05:00"
}Null optional fields are omitted from the response. durationCycles appears only
for a REPEATING coupon, and expiresAt only when an expiry was set.
Reusing a code is a 409
code is unique within an app and mode. Reusing one returns 409 Conflict:
{
"data": null,
"meta": {
"success": false,
"message": "Coupon already exists with code: SAVE20",
"errorCode": "RESOURCE_ALREADY_EXISTS",
"timestamp": "2026-06-30T12:06:00Z"
},
"pagination": null
}The same code can exist independently in TEST and in LIVE, and in a different
app — uniqueness is scoped to app_id + mode.
Discount types and math
The discount per cycle is derived from type and value against the cycle's
gross amount (the recurring price's amount, in paisa):
| Type | value means | Discount per cycle |
|---|---|---|
PERCENT | a 1..100 percentage | gross * value / 100, integer floor |
FIXED | an amount in minor units | min(value, gross) — capped so it never exceeds the gross |
The net billed for the cycle is always clamped at zero:
discount = PERCENT ? floor(gross * value / 100) : min(value, gross)
net = max(0, gross - discount)A PERCENT value outside 1..100 is rejected with 400 VALIDATION_ERROR. A
FIXED value must be > 0 (enforced as a positive integer on the request).
Worked examples
A PERCENT coupon with value: 20 against a gross of 100000 paisa
(1,000.00 BDT):
discount = floor(100000 * 20 / 100) = 20000 (200.00 BDT)
net = max(0, 100000 - 20000) = 80000 (800.00 BDT)The cycle invoice is issued for 80000.
A FIXED coupon with value: 50000 (500.00 BDT) against a smaller gross of
30000 paisa (300.00 BDT):
discount = min(50000, 30000) = 30000 (300.00 BDT)
net = max(0, 30000 - 30000) = 0net is 0: the discount fully covers the cycle. A net-zero, fully-covered cycle
skips invoice issuance entirely — nothing is billed and no payment is collected for
that cycle. The coupon is still consumed (see below), so a ONCE coupon is now
exhausted.
Durations
duration controls how many cycles a redeemed coupon keeps discounting:
| Duration | durationCycles | Cycles discounted |
|---|---|---|
ONCE | must be omitted | only the first billed cycle |
REPEATING | required, ≥ 1 | the next durationCycles cycles, then exhausted |
FOREVER | must be omitted | every cycle for the life of the subscription |
Getting the durationCycles shape wrong is a 400 VALIDATION_ERROR:
REPEATINGwith a missing or< 1durationCycles→"A REPEATING coupon requires durationCycles >= 1".ONCE/FOREVERthat includesdurationCycles→"durationCycles is only valid for a REPEATING coupon".
Redeem a coupon on a subscription
A subscription redeems a coupon by passing its code as couponCode when you
create the subscription. There is no separate
"apply coupon" call.
curl -X POST http://localhost:8080/api/v1/subscriptions \
-H "X-Api-Key: oi_test_8a1f2c" -H "X-Api-Secret: sk_test_4d9e7b" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2c7b1a09-5e34-4f81-bc6a-90d12e3f4a5b" \
-d '{
"priceId": 7100,
"customerReference": "cust_ab12cd34",
"couponCode": "SAVE20"
}'| Field | Type | Required | Notes |
|---|---|---|---|
couponCode | string | no | The redemption code of a coupon in this app + mode. Absent or blank means no coupon. An unknown or non-redeemable code rejects and rolls back the entire create. |
The attach runs inside the subscription-create transaction, so a bad code never leaves a half-created subscription:
Rules enforced at redemption time:
- Unknown code →
404 RESOURCE_NOT_FOUND, the whole create is rolled back. - Not redeemable (deactivated, or
expiresAthas passed) →400 VALIDATION_ERROR, rolled back. - One active coupon per subscription — attaching a coupon to a subscription that already has an active one is a
400 VALIDATION_ERROR.
Unknown code (the create is rolled back — no subscription is created):
{
"data": null,
"meta": {
"success": false,
"message": "Coupon not found with id: BLACKFRIDAY",
"errorCode": "RESOURCE_NOT_FOUND",
"timestamp": "2026-06-30T12:10:00Z"
},
"pagination": null
}A code that resolves but is deactivated or past its expiresAt:
{
"data": null,
"meta": {
"success": false,
"message": "Coupon 'SAVE20' is not redeemable (it is inactive or has expired)",
"errorCode": "VALIDATION_ERROR",
"timestamp": "2026-06-30T12:11:00Z"
},
"pagination": null
}Per-cycle application and consumption
At attach time the link records how many discounted cycles remain
(remainingCycles), initialised from the coupon's duration: ONCE → 1,
REPEATING → durationCycles, FOREVER → null (unlimited). Every time the billing
engine bills a cycle it re-evaluates the link and the coupon afresh:
Key behaviours:
- Re-checked every cycle. The coupon's redeemability is evaluated at each billing run, not just at attach. If it was deactivated or expired since attach, the link is deactivated on the spot and that cycle bills the full gross.
- Consumption. A bounded link (
ONCE/REPEATING) decrementsremainingCycleseach cycle and auto-deactivates when it reaches zero. AFOREVERlink (remainingCycles = null) never decrements and stays active for the subscription's life. - Net-zero skips issuance. When the discount fully covers the gross,
netis0and no invoice is issued for that cycle — but the cycle is still consumed.
Link lifecycle
The per-subscription coupon link is a small state machine: it is active from the
moment the coupon is attached, and flips to inactive once it is exhausted, the
underlying coupon is deactivated/expired, or the coupon can no longer be found.
Once a link is inactive, subsequent cycles bill the full gross with no discount.
Lifecycle of a coupon
A coupon is active from creation. It stays redeemable until either it is
deactivated or its expiresAt passes. Deactivation is permanent — there is no
re-activate endpoint.
Create
POST /coupons — the coupon starts active: true.
Redeem
Pass its code as couponCode on POST /subscriptions to attach it.
Expire or deactivate
It stops being redeemable once expiresAt passes, or when you deactivate it.
Already-attached subscriptions stop discounting at their next cycle.
List coupons
GET /coupons returns the app's catalogue as paginated CouponSummaryDto rows.
Pass active=true to return only active coupons; omit it to return all.
curl "http://localhost:8080/api/v1/coupons?active=true&page=0&size=20" \
-H "X-Api-Key: oi_test_8a1f2c" -H "X-Api-Secret: sk_test_4d9e7b"Response (data + pagination):
{
"data": [
{ "id": 4021, "code": "SAVE20", "name": "20% off for 3 cycles", "type": "PERCENT", "value": 20, "duration": "REPEATING", "active": true },
{ "id": 4022, "code": "WELCOME500", "name": "500 BDT off your first cycle", "type": "FIXED", "value": 50000, "duration": "ONCE", "active": true }
],
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:20:00Z" },
"pagination": { "page": 0, "size": 20, "totalElements": 2, "totalPages": 1 }
}See pagination & filtering for the
page, size, and sort parameters.
Retrieve a coupon
GET /coupons/{id} returns the full CouponDto. It is app- and mode-scoped, so a
coupon belonging to another app or mode returns 404 RESOURCE_NOT_FOUND.
curl http://localhost:8080/api/v1/coupons/4021 \
-H "X-Api-Key: oi_test_8a1f2c" -H "X-Api-Secret: sk_test_4d9e7b"Deactivate a coupon
POST /coupons/{id}/deactivate flips active to false. New subscriptions can no
longer redeem it; subscriptions that already attached it stop discounting at their
next cycle (the link is deactivated on re-evaluation).
curl -X POST http://localhost:8080/api/v1/coupons/4021/deactivate \
-H "X-Api-Key: oi_test_8a1f2c" -H "X-Api-Secret: sk_test_4d9e7b"Response data (the updated CouponDto, now active: false):
{
"id": 4021,
"code": "SAVE20",
"name": "20% off for 3 cycles",
"type": "PERCENT",
"value": 20,
"duration": "REPEATING",
"durationCycles": 3,
"active": false,
"mode": "TEST",
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:30:00"
}Field reference
CouponDto (detail)
| Field | Type | Notes |
|---|---|---|
id | integer | Coupon id. |
code | string | The immutable redemption handle. |
name | string | Human label. |
type | enum | PERCENT or FIXED. |
value | integer | 1..100 for PERCENT; minor units for FIXED. |
duration | enum | ONCE, REPEATING, or FOREVER. |
durationCycles | integer | Present only for REPEATING; omitted otherwise. |
active | boolean | false once deactivated. |
expiresAt | datetime | Present only when an expiry was set. |
mode | enum | TEST or LIVE, from the authenticating credential. |
createdAt | datetime | Set on create. |
updatedAt | datetime | Bumped on deactivate. |
CouponSummaryDto (list row)
| Field | Type | Notes |
|---|---|---|
id | integer | Coupon id. |
code | string | Redemption handle. |
name | string | Human label. |
type | enum | PERCENT or FIXED. |
value | integer | Percentage or minor-units amount. |
duration | enum | ONCE, REPEATING, or FOREVER. |
active | boolean | Whether it is still redeemable by new subscriptions. |
Related
Subscriptions
Create recurring subscriptions and redeem a coupon by code.
Products & prices
Define the recurring price a subscription bills against.
Invoicing
How each discounted cycle invoice is issued (or skipped).
Catalog & subscriptions
The model behind products, prices, subscriptions, and coupons.
Idempotency
Safely retry the create call with an Idempotency-Key.
Money & amounts
Why every value is integer paisa, never a float.
Subscriptions
Create and manage recurring subscriptions billed against a recurring price — trials, lifecycle, cancellation, pause/resume, dunning, and coupons.
Sell a one-time product
Charge once for a product through a ONE_TIME price — it issues an invoice with the bought price pinned and a payable link settled by the v1 payment flow, then grants entitlements when paid.