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_TIMEcharge or aRECURRINGcadence. 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
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Not blank, max 255 chars. |
description | string | no | Max 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.
| Field | Type | Notes |
|---|---|---|
id | number | Product id. |
name | string | |
description | string | null | Omitted when null. |
status | enum | DRAFT | ACTIVE | ARCHIVED. |
mode | enum | TEST | LIVE, from your credential. |
prices | PriceDto[] | The product's prices (see below). |
benefits | BenefitDto[] | The product's attached benefits. |
createdAt / updatedAt | datetime |
Product lifecycle
A product moves DRAFT → ACTIVE → ARCHIVED, one direction only.
| State | Sellable? | Edit rules |
|---|---|---|
DRAFT | No | Freely editable. Not exposed to customers; its prices are not purchasable. |
ACTIVE | Yes | Can still be renamed / re-described. Its active prices are purchasable. |
ARCHIVED | No | Frozen. No new subscriptions or purchases; existing subscriptions keep billing at their captured terms. Never hard-deleted. |
- Publish —
POST /products/{id}/publishmovesDRAFT → ACTIVE. Publishing a product that is alreadyACTIVEorARCHIVEDis rejected with422INVALID_OPERATION_STATE— only a draft can be published. - Archive —
POST /products/{id}/archivemovesDRAFTorACTIVE→ARCHIVED. Archiving an already-archived product is rejected with422. - Edit —
PATCH /products/{id}edits the descriptive fields. Editing anARCHIVEDproduct is rejected with422(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)
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | no | Max 255. null or blank leaves the name unchanged (the name is required). |
description | string | no | Max 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. Nointerval,intervalCountortrialDays. Reuses the v1 invoice + payment flow and creates no subscription.RECURRING— billed on a cadence. Requires anintervaland anintervalCount ≥ 1; combine them for cadences likeMONTH × 3(quarterly). Optionally addtrialDays.
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
| Field | Type | Required | Notes |
|---|---|---|---|
type | enum | yes | ONE_TIME | RECURRING. |
amountMinor | number (long) | yes | Positive integer minor units (paisa). Must be > 0. |
currency | string | no | Max 3 chars. Defaults to BDT; any other code → 400. |
interval | enum | conditional | DAY | WEEK | MONTH | YEAR. Required for RECURRING; forbidden for ONE_TIME (else 400). |
intervalCount | number (int) | conditional | Required and ≥ 1 for RECURRING; forbidden for ONE_TIME (else 400). |
trialDays | number (int) | no | Optional 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.
| Field | Type | Notes |
|---|---|---|
id | number | Price id. |
productId | number | Owning product. |
type | enum | ONE_TIME | RECURRING. |
amountMinor | number (long) | Integer minor units. |
currency | string | BDT in v1. |
interval | enum | Recurring only; omitted for one-time. |
intervalCount | number | Recurring only; omitted for one-time. |
trialDays | number | Recurring only; omitted for one-time. |
active | boolean | false once archived. |
createdAt | datetime |
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
| Field | Type | Required | Notes |
|---|---|---|---|
lookupKey | string | yes | Not blank, max 80. Unique per app + mode and immutable — re-keying is a new benefit. A duplicate key → 400 VALIDATION_ERROR. |
name | string | yes | Display name, not blank, max 255. Changeable later without affecting gating. |
metadata | string (raw JSON) | no | Optional 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.
| Field | Type | Notes |
|---|---|---|
id | number | Benefit id. |
lookupKey | string | Stable gating key; immutable. |
name | string | Display name. |
metadata | string | null | Raw JSON string; omitted when absent. |
active | boolean | |
mode | enum | TEST | LIVE. |
createdAt / updatedAt | datetime |
Read, edit, and toggle
GET /benefits— list, optionally?active=true, paginated.GET /benefits/{id}— read one (404if out of scope).PATCH /benefits/{id}— edit display name and/or metadata.POST /benefits/{id}/activateandPOST /benefits/{id}/deactivate— toggle the active flag.
Edit — UpdateBenefitRequest (PATCH semantics)
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | no | Max 255. null or blank leaves the display name unchanged. |
metadata | string (raw JSON) | no | A 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 }'| Field | Type | Required | Notes |
|---|---|---|---|
benefitId | number | yes | Must 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}/publish → ACTIVE, 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 /productsPOST /products/{id}/pricesPOST /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
| HTTP | errorCode | When |
|---|---|---|
400 | VALIDATION_ERROR | Missing/blank required field; RECURRING price missing interval/intervalCount; ONE_TIME price carrying interval fields; unsupported currency; duplicate benefit lookupKey; malformed metadata JSON. |
404 | RESOURCE_NOT_FOUND | Unknown — or out-of-scope (another app or mode) — product, price or benefit. App isolation returns 404, never 403. |
422 | INVALID_OPERATION_STATE | Publishing 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
applicationIdandmodeas required query params instead of deriving them from a credential. - The admin create path does not accept an
Idempotency-Key.
Look up customers
Read one of your customers by your own external id, and list that customer's payments — both scoped to your app (and, for payments, your credential's mode).
Subscriptions
Create and manage recurring subscriptions billed against a recurring price — trials, lifecycle, cancellation, pause/resume, dunning, and coupons.