OI Payments Docs
Guides

Products, prices & benefits

Build your catalogue with the App API — sellable products, immutable prices, and the benefit entries that feed subscriptions, purchases and entitlements.

Your catalogue is three small building blocks:

  • A product is a sellable offering (≈ a Stripe Product). It carries no price by itself.
  • A price is one specific way to buy a product — a ONE_TIME charge or a RECURRING cadence. A product can have several active prices at once.
  • A benefit is a per-app entry in your benefit catalogue (≈ a Stripe entitlement feature). You attach benefits to a product so that buying it grants the customer something your app can gate on.

These three feed the rest of the platform: a subscription references a recurring price, a one-time purchase references a one-time price, and a customer's active entitlements are derived from the benefits attached to the products they have paid for. See Catalog & subscriptions for the bigger picture.

Authentication & scope

Every endpoint on this page is part of the App API: authenticate with your API key and secret.

-H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret"

The mode (TEST or LIVE) is derived from the API credential that authenticated the request — never from the request body. A oi_test_… key writes TEST catalogue rows; a oi_live_… key writes LIVE rows. See Test & live modes.

Every product, price and benefit carries an app_id and a mode, and every read and write is scoped to them. One app never sees another app's — or another mode's — catalogue. A lookup for something outside your scope returns 404, never 403, so the existence of another app's data is never revealed. See App isolation.

All responses use the standard envelope: { "data": …, "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "…" }, "pagination": … }. The examples below show just the data payload unless an error is being shown.

Create a product

POST /products creates a product. It always starts in DRAFT — the app and mode come from your credential, never the body.

curl -X POST http://localhost:8080/api/v1/products \
  -H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9f1c2e44-7b3a-4c1d-8e2f-1a2b3c4d5e6f" \
  -d '{ "name": "Pro Plan", "description": "Everything in Starter, plus priority support" }'

Request body — CreateProductRequest

FieldTypeRequiredNotes
namestringyesNot blank, max 255 chars.
descriptionstringnoMax 2048 chars. A blank value is stored as null.

Response — 201 Created, a ProductDto

{
  "id": 5001,
  "name": "Pro Plan",
  "description": "Everything in Starter, plus priority support",
  "status": "DRAFT",
  "mode": "TEST",
  "prices": [],
  "benefits": [],
  "createdAt": "2026-06-30T12:00:00",
  "updatedAt": "2026-06-30T12:00:00"
}

A ProductDto always carries its prices and attached benefits. A fresh product has both empty — you add prices and attach benefits next.

FieldTypeNotes
idnumberProduct id.
namestring
descriptionstring | nullOmitted when null.
statusenumDRAFT | ACTIVE | ARCHIVED.
modeenumTEST | LIVE, from your credential.
pricesPriceDto[]The product's prices (see below).
benefitsBenefitDto[]The product's attached benefits.
createdAt / updatedAtdatetime

Product lifecycle

A product moves DRAFT → ACTIVE → ARCHIVED, one direction only.

StateSellable?Edit rules
DRAFTNoFreely editable. Not exposed to customers; its prices are not purchasable.
ACTIVEYesCan still be renamed / re-described. Its active prices are purchasable.
ARCHIVEDNoFrozen. No new subscriptions or purchases; existing subscriptions keep billing at their captured terms. Never hard-deleted.
  • PublishPOST /products/{id}/publish moves DRAFT → ACTIVE. Publishing a product that is already ACTIVE or ARCHIVED is rejected with 422 INVALID_OPERATION_STATE — only a draft can be published.
  • ArchivePOST /products/{id}/archive moves DRAFT or ACTIVEARCHIVED. Archiving an already-archived product is rejected with 422.
  • EditPATCH /products/{id} edits the descriptive fields. Editing an ARCHIVED product is rejected with 422 (it is frozen).
# Publish a draft product
curl -X POST http://localhost:8080/api/v1/products/5001/publish \
  -H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret"

Edit — UpdateProductRequest (PATCH semantics)

FieldTypeRequiredNotes
namestringnoMax 255. null or blank leaves the name unchanged (the name is required).
descriptionstringnoMax 2048. null leaves it unchanged; a blank value clears it to null.

Add prices

POST /products/{id}/prices adds a price. The two shapes are driven by type:

  • ONE_TIME — a single up-front charge. No interval, intervalCount or trialDays. Reuses the v1 invoice + payment flow and creates no subscription.
  • RECURRING — billed on a cadence. Requires an interval and an intervalCount ≥ 1; combine them for cadences like MONTH × 3 (quarterly). Optionally add trialDays.

All amounts are integer minor units (paisa): 150000 means 1,500.00 BDT. Never use floating point. Currency is BDT-only in v1 — currency defaults to BDT when omitted, and any other code is rejected with 400. See Money & amounts.

# A recurring price: 1,500.00 BDT / month with a 14-day trial
curl -X POST http://localhost:8080/api/v1/products/5001/prices \
  -H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: a1b2c3d4-0001-4aaa-9bbb-ccccddddeeee" \
  -d '{
        "type": "RECURRING",
        "amountMinor": 150000,
        "interval": "MONTH",
        "intervalCount": 1,
        "trialDays": 14
      }'
# A one-time price: 4,999.00 BDT, no interval fields
curl -X POST http://localhost:8080/api/v1/products/5001/prices \
  -H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret" \
  -H "Content-Type: application/json" \
  -d '{ "type": "ONE_TIME", "amountMinor": 499900 }'

Request body — CreatePriceRequest

FieldTypeRequiredNotes
typeenumyesONE_TIME | RECURRING.
amountMinornumber (long)yesPositive integer minor units (paisa). Must be > 0.
currencystringnoMax 3 chars. Defaults to BDT; any other code → 400.
intervalenumconditionalDAY | WEEK | MONTH | YEAR. Required for RECURRING; forbidden for ONE_TIME (else 400).
intervalCountnumber (int)conditionalRequired and ≥ 1 for RECURRING; forbidden for ONE_TIME (else 400).
trialDaysnumber (int)noOptional for RECURRING and must be ≥ 0; forbidden for ONE_TIME (else 400).

The per-type shape rules are business rules, validated in the use case. A RECURRING price missing its interval, or a ONE_TIME price that carries one, is rejected with 400 VALIDATION_ERROR.

Response — 201 Created, a PriceDto

{
  "id": 7001,
  "productId": 5001,
  "type": "RECURRING",
  "amountMinor": 150000,
  "currency": "BDT",
  "interval": "MONTH",
  "intervalCount": 1,
  "trialDays": 14,
  "active": true,
  "createdAt": "2026-06-30T12:05:00"
}

For a ONE_TIME price the interval, intervalCount and trialDays fields are omitted entirely.

FieldTypeNotes
idnumberPrice id.
productIdnumberOwning product.
typeenumONE_TIME | RECURRING.
amountMinornumber (long)Integer minor units.
currencystringBDT in v1.
intervalenumRecurring only; omitted for one-time.
intervalCountnumberRecurring only; omitted for one-time.
trialDaysnumberRecurring only; omitted for one-time.
activebooleanfalse once archived.
createdAtdatetime

Prices are immutable — reprice by adding a new one

A price's amount and cadence are fixed once created. There is no update endpoint — to change what something costs, add a new price and archive the old one. Live subscribers on the old price keep billing at their captured terms; they are never silently repriced.

List & archive prices

GET /products/{id}/prices returns the product's prices as a PriceDto[].

curl http://localhost:8080/api/v1/products/5001/prices \
  -H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret"

POST /products/{id}/prices/{priceId}/archive flips a price's active flag to false. It is no longer purchasable, but existing subscriptions on it keep billing. The price must belong to the given product, or you get 404.

curl -X POST http://localhost:8080/api/v1/products/5001/prices/7001/archive \
  -H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret"

Benefits catalogue

A benefit is a per-app entry that answers "what does buying a product grant the customer?". Your gating code keys on its stable lookupKey, so gating survives repricing, renaming and replacement.

POST /benefits creates a benefit; it starts active.

curl -X POST http://localhost:8080/api/v1/benefits \
  -H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: b0b0b0b0-1111-4222-8333-444455556666" \
  -d '{ "lookupKey": "pro_seats", "name": "Pro seats", "metadata": "{\"seats\": 10}" }'

Request body — CreateBenefitRequest

FieldTypeRequiredNotes
lookupKeystringyesNot blank, max 80. Unique per app + mode and immutable — re-keying is a new benefit. A duplicate key → 400 VALIDATION_ERROR.
namestringyesDisplay name, not blank, max 255. Changeable later without affecting gating.
metadatastring (raw JSON)noOptional structured value sent as a raw JSON string, e.g. "{\"seats\": 10}". Must be well-formed JSON or it is rejected with 400 before reaching storage.

Response — 201 Created, a BenefitDto

{
  "id": 9001,
  "lookupKey": "pro_seats",
  "name": "Pro seats",
  "metadata": "{\"seats\": 10}",
  "active": true,
  "mode": "TEST",
  "createdAt": "2026-06-30T12:10:00",
  "updatedAt": "2026-06-30T12:10:00"
}

metadata is stored as jsonb but the API field is a string: it is echoed back as a JSON-encoded string (e.g. "{\"seats\": 10}"), not as a nested JSON object. Parse it client-side if you need the structure.

FieldTypeNotes
idnumberBenefit id.
lookupKeystringStable gating key; immutable.
namestringDisplay name.
metadatastring | nullRaw JSON string; omitted when absent.
activeboolean
modeenumTEST | LIVE.
createdAt / updatedAtdatetime

Read, edit, and toggle

  • GET /benefits — list, optionally ?active=true, paginated.
  • GET /benefits/{id} — read one (404 if out of scope).
  • PATCH /benefits/{id} — edit display name and/or metadata.
  • POST /benefits/{id}/activate and POST /benefits/{id}/deactivate — toggle the active flag.

Edit — UpdateBenefitRequest (PATCH semantics)

FieldTypeRequiredNotes
namestringnoMax 255. null or blank leaves the display name unchanged.
metadatastring (raw JSON)noA non-null value replaces the stored value; a blank string ("") clears it to null. Omitting it — or sending JSON null — leaves the stored value unchanged (the two are indistinguishable). A non-blank value must be well-formed JSON.

The lookupKey is not editable — it is immutable, so your gating is never broken by an edit.

Attach benefits to a product

Benefits attach to the product, not to a price, so they survive repricing.

POST /products/{id}/benefits attaches a benefit you already created. It is idempotent: attaching the same benefit twice is a no-op (guarded by the unique uq_product_benefit pair), and the call returns the full product either way. The benefit must exist in the same app and mode, or you get 404.

curl -X POST http://localhost:8080/api/v1/products/5001/benefits \
  -H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret" \
  -H "Content-Type: application/json" \
  -d '{ "benefitId": 9001 }'
FieldTypeRequiredNotes
benefitIdnumberyesMust reference a benefit in the same app + mode.

The response is the ProductDto, now carrying the benefit in its benefits list.

  • GET /products/{id}/benefits — list a product's attached benefits.
  • DELETE /products/{id}/benefits/{benefitId} — detach. A detach of a benefit that is not attached is a no-op that still succeeds. Detaching revokes the benefit from that product's holders at their next entitlement read — there is no retroactive sweep. See Entitlements.

From draft to subscription

The end-to-end path that turns a catalogue entry into recurring revenue:

Create the product

POST /products → starts DRAFT.

Add a recurring price

POST /products/{id}/prices with type: RECURRING.

Publish

POST /products/{id}/publishACTIVE, so its prices become purchasable.

Subscribe

Create a subscription against the recurring price.

Idempotency

The create-style mutations on this page accept an Idempotency-Key header:

  • POST /products
  • POST /products/{id}/prices
  • POST /products/{id}/benefits (attach)
  • POST /benefits

A repeat with the same key and the same body replays the first response, so a retry never creates a duplicate. Lifecycle toggles (publish, archive, activate, deactivate) and DELETE (detach) are naturally idempotent and take no key. See Idempotency.

Error reference

HTTPerrorCodeWhen
400VALIDATION_ERRORMissing/blank required field; RECURRING price missing interval/intervalCount; ONE_TIME price carrying interval fields; unsupported currency; duplicate benefit lookupKey; malformed metadata JSON.
404RESOURCE_NOT_FOUNDUnknown — or out-of-scope (another app or mode) — product, price or benefit. App isolation returns 404, never 403.
422INVALID_OPERATION_STATEPublishing a non-DRAFT product; archiving an already-ARCHIVED product; editing an ARCHIVED (frozen) product.

Example — 422, publishing a non-draft product

{
  "data": null,
  "meta": {
    "success": false,
    "message": "Only a draft product can be published; product 5001 is ACTIVE",
    "errorCode": "INVALID_OPERATION_STATE",
    "timestamp": "2026-06-30T12:20:00Z"
  },
  "pagination": null
}

Example — 400, recurring price missing its interval

{
  "data": null,
  "meta": {
    "success": false,
    "message": "A recurring price requires an interval",
    "errorCode": "VALIDATION_ERROR",
    "timestamp": "2026-06-30T12:21:00Z"
  },
  "pagination": null
}

Authoring from the dashboard

Operators can author the same catalogue from the admin dashboard — there is no separate product type; the admin path drives the same engine. Two differences for the admin Products and admin Benefits endpoints under /api/v1/admin/**:

  • An admin session has no api-key binding, so a cross-app create takes applicationId and mode as required query params instead of deriving them from a credential.
  • The admin create path does not accept an Idempotency-Key.

On this page