OI Payments Docs
Core concepts

Catalog & subscription data model

How products, prices, benefits, coupons, subscriptions, one-time purchases, and entitlements relate — every entity scoped to an app and mode.

The catalog is what your app sells and what a purchase grants. It is a small set of entities that build on each other:

  • A product is a sellable offering (≈ Stripe Product).
  • A price is one way to buy a product — RECURRING (a subscription cadence) or ONE_TIME (a single up-front charge).
  • A benefit is a feature your app gates on; it attaches to the product.
  • A coupon is a reusable discount applied to a subscription's cycles.
  • A subscription bills a customer on a recurring price.
  • A one-time purchase is an invoice with a one-time price pinned.
  • Entitlements are derived — the union of benefits a customer currently holds.

Every catalog entity carries an applicationId and a mode. The mode (TEST or LIVE) is derived from the API credential that authenticated the request — never from the request body. One app never sees another app's catalog, and a test credential never sees live rows. See Test & live modes and App isolation.

All amounts below are integer minor units (paisa): 150000 means 1,500.00 BDT. Currency is BDT-only in v1. See Money & amounts.

How the entities relate

The "one-time purchase" is not its own table — it is an invoice with the bought price_id pinned. Everything else is a real table.

Each entity, its table, and its scoping columns

EntityDB tableScoping columnsNotable immutable columns
Productproductapplication_id, mode— (status moves DRAFT → ACTIVE → ARCHIVED)
Pricepriceapplication_id, modetype, amount, billing_interval, interval_count, trial_days
Benefitbenefitapplication_id, modelookup_key (unique per app + mode)
ProductBenefitproduct_benefitapplication_id, modeproduct_id, benefit_id (unique pair uq_product_benefit)
Subscriptionsubscriptionapplication_id, modeproduct_id, price_id, customer_ref_id, start_date
Couponcouponapplication_id, modecode, type, value, duration, duration_cycles
SubscriptionCouponsubscription_couponinherited via its subscription_idsubscription_id, coupon_id, applied_at
One-time purchaseno own table — an invoice with price_id pinnedapplication_id, mode (on the invoice)the pinned price_id

SubscriptionCoupon deliberately stores subscription_id and coupon_id as plain id columns (not entity associations) — it carries no application_id/mode of its own and is scoped through its parent subscription. The database foreign keys are declared in the migration.

Products & prices

A product is created in DRAFT (freely editable, not sellable), published to ACTIVE (sellable), and eventually ARCHIVED (accepts no new subscriptions/purchases; existing subscriptions keep billing at their captured terms). A product is sellable only while ACTIVE — and only its active prices are purchasable.

A product holds one or more prices and may expose several active prices at once. A price is typed:

PriceTypeMeaning
ONE_TIMEA single up-front purchase — no interval, no trial. Reuses the v1 invoice + payment flow; creates no subscription.
RECURRINGBilled on a cadence (interval × intervalCount, e.g. MONTH × 3 = quarterly) with an optional trialDays. A subscription references a recurring price.

PriceInterval is one of DAY, WEEK, MONTH, YEAR (null for a one-time price).

Prices are immutable by design. A price's amount, type, interval, intervalCount, and trialDays are set once and never updated. You do not reprice a live price — you add a new price and archive the old one. Archiving a price (active = false) stops new sales but lets existing subscriptions keep billing at their captured terms.

PriceDto

FieldTypeRequiredNotes
idnumberalwaysThe price id.
productIdnumberalwaysThe owning product.
typestringalwaysONE_TIME or RECURRING.
amountMinornumberalwaysInteger minor units (paisa).
currencystringalwaysISO-4217; BDT in v1.
intervalstringrecurring onlyDAY/WEEK/MONTH/YEAR; omitted for one-time.
intervalCountnumberrecurring onlyUnits per cycle; omitted for one-time.
trialDaysnumberoptionalFree-trial length in days; omitted when absent / one-time.
activebooleanalwaysfalse once archived.
createdAtstringalwaysISO-8601 timestamp.

See the Products & prices guide for the endpoints that create and manage them.

Benefits & entitlements

A benefit is a per-app catalogue entry answering "what does buying a product grant the customer?" Each benefit has a stable lookupKey — the app's own gating identifier — that your client code keys on.

Benefits attach to the product, not the price. That is why entitlements survive repricing: when you add a new price and archive the old one, the product still grants the same benefits, so subscribers keep their access. The display name can change freely; the lookupKey is immutable (re-keying is treated as a new benefit), so gating never breaks.

The attachment is the product_benefit join (one benefit may attach to many products; the pair is unique). Detaching a benefit revokes it from that product's holders at the next entitlement read.

BenefitDto

FieldTypeRequiredNotes
idnumberalwaysThe benefit id.
lookupKeystringalwaysStable gating identifier (≤80 chars), unique per app + mode, immutable.
namestringalwaysDisplay name; may be renamed without affecting the key.
metadatastringoptionalStructured value as a JSON string (see below); omitted when absent.
activebooleanalwaysOnly the benefit's active flag gates entitlement.
modestringalwaysTEST or LIVE.
createdAtstringalwaysISO-8601 timestamp.
updatedAtstringalwaysISO-8601 timestamp.

metadata serializes as a JSON string, not a nested JSON object. The benefit stores it as jsonb, but the DTO field is a raw string, so on the wire you receive "metadata": "{\"seats\": 10}" — parse it yourself if you need the inner object. The same applies to the metadata on an entitlement view.

Entitlements are derived, never stored

There is no entitlement table. A customer's active entitlements are computed on the fly as the union of:

  1. the benefits granted by the customer's entitling subscriptions (status TRIALING or ACTIVE — a paused, past-due, or canceled subscription does not entitle), and
  2. the benefits granted by the customer's paid one-time purchases (a paid purchase grants durable entitlements; the price's current active flag is irrelevant to it),

then deduplicated by lookupKey (first wins) and returned in stable lookupKey order.

Two reads expose this:

  • GET /entitlements?customerReference=… lists the active entitlements (EntitlementView[]). An unknown customer yields an empty list, not a 404.
  • GET /entitlements/check?customerReference=…&lookupKey=… is the gate (EntitlementCheckDto). It runs the full derivation and tests membership; an unknown customer reads entitled: false, never an error.

/entitlements/check re-derives the customer's entitlements every call — it is a correctness-first membership test, not a cached or short-circuiting lookup. Don't rely on it being cheaper than the list read.

EntitlementView

FieldTypeRequiredNotes
lookupKeystringalwaysThe benefit's stable gating identifier.
namestringalwaysDisplay name.
metadatastringoptionalJSON string (same as BenefitDto.metadata); null when absent.

EntitlementCheckDto

FieldTypeRequiredNotes
entitledbooleanalwaysWhether the customer holds the entitlement.
lookupKeystringalwaysEchoes the gating identifier asked about.
customerReferencestringalwaysEchoes the external customer id asked about.
curl "http://localhost:8080/api/v1/entitlements/check?customerReference=cus_84217&lookupKey=seats" \
  -H "X-Api-Key: oi_test_2f9d8c1a7b6e4f30" \
  -H "X-Api-Secret: sk_test_a1b2c3d4e5f6"
{
  "data": {
    "entitled": true,
    "lookupKey": "seats",
    "customerReference": "cus_84217"
  },
  "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
  "pagination": null
}

See the Benefits and Entitlements guide for more.

Subscriptions

A subscription bills a customer on a recurring price of an ACTIVE product. It captures the chosen priceId at create time so a later reprice never silently moves a live subscriber, and it tracks the billing calendar (startDate, currentPeriodStart, nextBillingDate, and the optional trialEndDate).

Whether a subscription trials is decided by the price's trialDays, not the request:

  • trial price → created TRIALING; the first invoice falls at trial end,
  • no trial → created ACTIVE; billed from startDate.

A TRIALING or ACTIVE subscription is entitling; PAUSED, PAST_DUE, and CANCELED are not. CANCELED is terminal. The billing sweep generates each cycle's invoice, advances the calendar, and drives the dunning transitions.

CreateSubscriptionRequest

FieldTypeRequiredNotes
priceIdnumberyes (@NotNull)A RECURRING price of an ACTIVE product.
customerReferencestringyes (@NotBlank)The app's own external customer id (find-or-created).
customerNamestringnoOptional display name; refreshes the customer reference when supplied.
customerEmailstringnoOptional contact email.
customerPhonestringnoOptional contact phone.
startDatestring (date)noWhen billing begins; defaults to today when absent.
couponCodestringnoRedemption code of a coupon to apply; an unknown/non-redeemable code rolls back the create.

The mode is fixed by the authenticating credential, never the body. Validation: the owning product must be ACTIVE (else 422) and the price must be RECURRING (else 400).

curl -X POST http://localhost:8080/api/v1/subscriptions \
  -H "X-Api-Key: oi_test_2f9d8c1a7b6e4f30" \
  -H "X-Api-Secret: sk_test_a1b2c3d4e5f6" \
  -H "Idempotency-Key: 9f1c2e44-7b3a-4f2e-9c1d-2a6b8e0f4d11" \
  -H "Content-Type: application/json" \
  -d '{
    "priceId": 1001,
    "customerReference": "cus_84217",
    "customerEmail": "[email protected]",
    "couponCode": "WELCOME20"
  }'

Every endpoint returns the standard { data, meta, pagination } envelope; the rest of this page shows just data. Because the price carries a 14-day trial, the subscription is created TRIALING and returns 201:

{
  "data": {
    "id": 5001,
    "status": "TRIALING",
    "productId": 42,
    "priceId": 1001,
    "customerReference": "cus_84217",
    "startDate": "2026-06-30",
    "currentPeriodStart": "2026-06-30",
    "nextBillingDate": "2026-07-14",
    "trialEndDate": "2026-07-14",
    "cancelAtPeriodEnd": false,
    "mode": "TEST",
    "createdAt": "2026-06-30T12:00:00Z",
    "updatedAt": "2026-06-30T12:00:00Z"
  },
  "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
  "pagination": null
}

SubscriptionDto

FieldTypeRequiredNotes
idnumberalwaysThe subscription id.
statusstringalwaysTRIALING/ACTIVE/PAST_DUE/PAUSED/CANCELED.
productIdnumberalwaysThe subscribed product.
priceIdnumberalwaysThe captured recurring price (fixed for the lifetime).
customerReferencestringalwaysThe app's external customer id (resolved by the use case).
startDatestring (date)alwaysWhen the subscription begins.
currentPeriodStartstring (date)alwaysStart of the current billing period.
nextBillingDatestring (date)alwaysWhen the next invoice is due.
trialEndDatestring (date)optionalEnd of the free trial; omitted when no trial.
cancelAtPeriodEndbooleanalwaysA cancel scheduled for the period end.
canceledAtstringoptionalWhen canceled (immediate cancel); omitted while not canceled.
modestringalwaysTEST or LIVE.
createdAtstringalwaysISO-8601 timestamp.
updatedAtstringalwaysISO-8601 timestamp.

The create accepts an Idempotency-Key; a repeat with the same key and body replays the first response. See the Subscriptions guide for the cancel/pause/resume endpoints.

Coupons

A coupon is a reusable discount an app authors once and attaches to a subscription by its code at create time (see the couponCode field above). The discount is applied to each generated cycle invoice by the billing engine.

DimensionValues
CouponTypePERCENT (a 1..100 percentage) or FIXED (an amount in minor units).
CouponDurationONCE (first cycle), REPEATING (the next durationCycles cycles), or FOREVER (every cycle).

The discount math (integer arithmetic, never floating point):

  • PERCENTdiscount = grossMinor * value / 100 (integer floor).
  • FIXEDdiscount = min(value, grossMinor) — it never exceeds the cycle amount.

Consumption is tracked per cycle on the subscription_coupon link via its remainingCycles (initialised ONCE → 1, REPEATING → durationCycles, FOREVER → null/unlimited). The billing engine decrements it one cycle at a time; when it reaches zero the link is deactivated. Coupon redeemability is also checked per cycle (it must be active and not past expiresAt).

A fully-covering coupon that brings the cycle to net zero (e.g. a 100% PERCENT) skips invoice issuance for that cycle — nothing is owed, so no invoice is created. The subscription still advances its billing calendar.

CreateCouponRequest

FieldTypeRequiredNotes
codestringyes (@NotBlank, ≤64)Stable redemption handle, unique per app + mode, immutable.
namestringyes (@NotBlank, ≤255)Display name.
typestringyes (@NotNull)PERCENT or FIXED.
valuenumberyes (@NotNull, @Positive)PERCENT must be 1..100; FIXED is minor units > 0 (business-validated).
durationstringyes (@NotNull)ONCE/REPEATING/FOREVER.
durationCyclesnumberconditionalRequired (≥1) for REPEATING; must be absent for ONCE/FOREVER (business-validated).
expiresAtstringnoOptional expiry instant; after it the coupon is no longer redeemable.

CouponDto echoes these fields plus id, active, mode, createdAt, and updatedAt. See the Coupons guide.

One-time purchases

A one-time purchase sells a product through one of its ONE_TIME prices. It owns no table of its own — it is an invoice with the bought price_id pinned, plus a payable link the customer settles through the existing v1 hosted-checkout flow. No subscription is created and there is no billing calendar.

The pinned price is what entitlement derivation reads later: once the invoice is paid, the customer durably holds the benefits of the price's product.

CreateOneTimePurchaseRequest

FieldTypeRequiredNotes
priceIdnumberyes (@NotNull)A ONE_TIME price.
customerReferencestringyes (@NotBlank)The app's own external customer id (find-or-created).
customerNamestringnoOptional display name.
customerEmailstringnoOptional contact email.
customerPhonestringnoOptional contact phone.

There is no start date and no coupon — a one-time purchase has no billing calendar. The mode is fixed by the authenticating credential, never the body.

curl -X POST http://localhost:8080/api/v1/purchases \
  -H "X-Api-Key: oi_test_2f9d8c1a7b6e4f30" \
  -H "X-Api-Secret: sk_test_a1b2c3d4e5f6" \
  -H "Idempotency-Key: 7c2a9e10-4d6b-4a1f-8e22-b1f0c3d4e5a6" \
  -H "Content-Type: application/json" \
  -d '{
    "priceId": 1002,
    "customerReference": "cus_84217",
    "customerEmail": "[email protected]"
  }'
{
  "data": {
    "invoiceId": 9100,
    "invoiceNumber": "INV-2026-000042",
    "status": "ISSUED",
    "totalMinor": 500000,
    "currency": "BDT",
    "priceId": 1002,
    "productId": 42,
    "payableLink": {
      "payableUrl": "http://localhost:8080/checkout/pl_7c2a9e104d6b"
    }
  },
  "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
  "pagination": null
}

OneTimePurchaseDto

FieldTypeRequiredNotes
invoiceIdnumberalwaysThe issued invoice's internal id.
invoiceNumberstringalwaysThe issued invoice's per-app number.
statusstringalwaysISSUED on creation.
totalMinornumberalwaysThe purchase total in minor units.
currencystringalwaysISO-4217; BDT in v1.
priceIdnumberalwaysThe bought ONE_TIME price (pinned on the invoice).
productIdnumberalwaysThe owning product.
payableLinkobjectalways{ payableUrl } — the hosted-checkout link the customer settles.

See the One-time purchases guide.

A fully-assembled product

A product read returns the product with its prices and attached benefits inlined (both lists are assembled by the use case after mapping). This ProductDto has one RECURRING price with a trial, one ONE_TIME price, and one benefit:

{
  "id": 42,
  "name": "Pro plan",
  "description": "Full access for growing teams",
  "status": "ACTIVE",
  "mode": "TEST",
  "prices": [
    {
      "id": 1001,
      "productId": 42,
      "type": "RECURRING",
      "amountMinor": 150000,
      "currency": "BDT",
      "interval": "MONTH",
      "intervalCount": 1,
      "trialDays": 14,
      "active": true,
      "createdAt": "2026-06-30T10:00:00Z"
    },
    {
      "id": 1002,
      "productId": 42,
      "type": "ONE_TIME",
      "amountMinor": 500000,
      "currency": "BDT",
      "active": true,
      "createdAt": "2026-06-30T10:05:00Z"
    }
  ],
  "benefits": [
    {
      "id": 7,
      "lookupKey": "seats",
      "name": "Team seats",
      "metadata": "{\"seats\": 10}",
      "active": true,
      "mode": "TEST",
      "createdAt": "2026-06-29T09:00:00Z",
      "updatedAt": "2026-06-29T09:00:00Z"
    }
  ],
  "createdAt": "2026-06-30T10:00:00Z",
  "updatedAt": "2026-06-30T10:05:00Z"
}

Notice the one-time price omits interval, intervalCount, and trialDays (they are null and dropped from the response), and the benefit's metadata is a JSON string ("{\"seats\": 10}"), not a nested object.

ProductDto

FieldTypeRequiredNotes
idnumberalwaysThe product id.
namestringalwaysProduct name.
descriptionstringoptionalOmitted when absent.
statusstringalwaysDRAFT/ACTIVE/ARCHIVED.
modestringalwaysTEST or LIVE.
pricesPriceDto[]alwaysThe product's prices (assembled by the use case).
benefitsBenefitDto[]alwaysThe product's attached benefits (assembled by the use case).
createdAtstringalwaysISO-8601 timestamp.
updatedAtstringalwaysISO-8601 timestamp.

How it ties back to payments, invoices & webhooks

The catalog reuses the v1 money machinery rather than inventing parallel flows:

  • A subscription cycle and a one-time purchase both produce an invoice with a payable link, settled through the same hosted-checkout payment flow.
  • Settlement, refunds, and the double-entry ledger behave exactly as for any other payment — there are no new webhook event types; you react to the existing payment.* and invoice.* webhooks.
  • A paid one-time purchase and an entitling subscription both feed entitlement derivation, so your app gates features off lookupKey regardless of how the customer paid.

On this page