OI Payments Docs
Guides

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

FieldTypeRequiredNotes
codestringyesThe app's stable redemption handle. Non-blank, ≤ 64 chars. Unique per app + mode and immutable once created. Trimmed server-side.
namestringyesHuman label. Non-blank, ≤ 255 chars.
typeenumyesPERCENT or FIXED. See types & math.
valueintegeryesMust be positive. For PERCENT it is a 1..100 percentage; for FIXED it is an amount in minor units (paisa).
durationenumyesONCE, REPEATING, or FOREVER. See durations.
durationCyclesintegerconditionalRequired (≥ 1) when duration is REPEATING; must be omitted for ONCE/FOREVER. Sending it the wrong way is a 400.
expiresAtdatetimenoOptional 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):

Typevalue meansDiscount per cycle
PERCENTa 1..100 percentagegross * value / 100, integer floor
FIXEDan amount in minor unitsmin(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) = 0

net 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:

DurationdurationCyclesCycles discounted
ONCEmust be omittedonly the first billed cycle
REPEATINGrequired, ≥ 1the next durationCycles cycles, then exhausted
FOREVERmust be omittedevery cycle for the life of the subscription

Getting the durationCycles shape wrong is a 400 VALIDATION_ERROR:

  • REPEATING with a missing or < 1 durationCycles"A REPEATING coupon requires durationCycles >= 1".
  • ONCE/FOREVER that includes durationCycles"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"
      }'
FieldTypeRequiredNotes
couponCodestringnoThe 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 code404 RESOURCE_NOT_FOUND, the whole create is rolled back.
  • Not redeemable (deactivated, or expiresAt has 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) decrements remainingCycles each cycle and auto-deactivates when it reaches zero. A FOREVER link (remainingCycles = null) never decrements and stays active for the subscription's life.
  • Net-zero skips issuance. When the discount fully covers the gross, net is 0 and no invoice is issued for that cycle — but the cycle is still consumed.

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)

FieldTypeNotes
idintegerCoupon id.
codestringThe immutable redemption handle.
namestringHuman label.
typeenumPERCENT or FIXED.
valueinteger1..100 for PERCENT; minor units for FIXED.
durationenumONCE, REPEATING, or FOREVER.
durationCyclesintegerPresent only for REPEATING; omitted otherwise.
activebooleanfalse once deactivated.
expiresAtdatetimePresent only when an expiry was set.
modeenumTEST or LIVE, from the authenticating credential.
createdAtdatetimeSet on create.
updatedAtdatetimeBumped on deactivate.

CouponSummaryDto (list row)

FieldTypeNotes
idintegerCoupon id.
codestringRedemption handle.
namestringHuman label.
typeenumPERCENT or FIXED.
valueintegerPercentage or minor-units amount.
durationenumONCE, REPEATING, or FOREVER.
activebooleanWhether it is still redeemable by new subscriptions.

On this page