OI Payments Docs
Admin dashboard

Products & prices

Author the product catalogue from the admin dashboard — create products for any app+mode, add immutable prices, attach benefits, and read the catalogue metrics row.

Operators author the same catalogue an app builds through the App API — there is no parallel product type; the admin endpoints under /api/v1/admin/** drive the same engine. The differences are all about identity and audit:

  • An admin session carries no api-key binding, so a create takes applicationId and mode as required query params instead of deriving them from a credential.
  • The admin path does not accept an Idempotency-Key, and every write is audited as the acting operator.
  • Reads gate on product:read; writes on product:write; attaching and detaching benefits gate on benefit:write. Each gate is narrowed per app to the apps your grants cover.

Authentication & scope

Every endpoint on this page is part of the Admin API: authenticate with your session token.

-H "Authorization: Bearer SESSION_TOKEN"

Beyond the coarse @PreAuthorize gate, each call is narrowed to the apps your grants cover (RBAC-4). What an out-of-scope app or product yields depends on the call:

CallOut-of-scope result
GET /{id} detail, by-id reads404 — a product in an app outside your scope reads exactly like a missing id, so the dashboard never leaks another app's data.
By-id writes (PATCH, publish, archive, prices, benefits)404 if the product does not exist for any app; 403 FORBIDDEN if it exists but is outside your grants.
POST create403 FORBIDDEN if you lack product:write for the target applicationId.
GET /metricsZeroed counts (200).
GET /topEmpty list (200).

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 shown.

How mode is determined on the admin path

The App-API rule is that the mode (TEST or LIVE) is derived from the API credential that authenticated the request — never from the request body. An admin session has no such credential, so the admin path resolves mode differently:

  • Create takes applicationId + mode as required query params — the operator chooses which app and which mode to author into.
  • By-id writes resolve the owning app_id + mode internally from the stored product (the *ForAdmin use cases), so a client never has to know — or could misstate — the mode of an existing product. There is no mode field in any admin request body.
  • The dashboard read endpoints (/metrics, /top) take mode as a required query param, consistent with the other dashboard analytics endpoints.

See Test & live modes for the underlying isolation guarantee and App isolation for why out-of-scope reads return 404.

Product lifecycle

A product moves DRAFT → ACTIVE → ARCHIVED, one direction only. It is created in DRAFT and never hard-deleted.

StateSellable?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.

Create a product

POST /admin/products creates a product in DRAFT. The applicationId and mode are required query params — not body fields — because the admin session has no api-key binding. Gated by product:write for the target app.

curl -X POST "http://localhost:8080/api/v1/admin/products?applicationId=42&mode=TEST" \
  -H "Authorization: Bearer SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Pro Plan", "description": "Everything in Starter, plus priority support" }'

Query params

ParamTypeRequiredNotes
applicationIdnumberyesThe app to author the product into. Must be within your product:write scope, or 403.
modeenumyesTEST or LIVE. The product is created in this mode; it is immutable thereafter.

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 fresh product has empty prices and benefits — you add prices and attach benefits next.

Search & detail

Search — GET /admin/products

Returns a paginated list of ProductSummaryDto rows across every app you are scoped to. Gated by product:read.

curl "http://localhost:8080/api/v1/admin/products?applicationId=42&mode=TEST&status=ACTIVE&search=pro&page=0&size=20" \
  -H "Authorization: Bearer SESSION_TOKEN"
FilterTypeNotes
applicationIdnumberA specific app within your scope. Optional.
modeenumTEST or LIVE. Optional here — a filter, not required (unlike create and the dashboard reads).
statusenumDRAFT | ACTIVE | ARCHIVED. Optional.
searchstringFree-text match on name. Optional.
page / sizenumberDefaults 0 / 20.
sortBy / orderstring / enumDefaults createdAt / DESC.
paginatebooleanDefaults true; set false to return the unpaged set.

Each row is a compact ProductSummaryDto (no prices):

FieldTypeNotes
idnumberProduct id.
namestring
statusenumDRAFT | ACTIVE | ARCHIVED.
modeenumTEST | LIVE.
createdAtdatetime

Detail — GET /admin/products/{id}

Returns one product's full read model — the product plus its prices and its attached benefits — assembled by the same logic the app path uses, so admin and app reads are identical. A product in an app outside your scope reads as 404.

curl http://localhost:8080/api/v1/admin/products/5001 \
  -H "Authorization: Bearer SESSION_TOKEN"
{
  "id": 5001,
  "name": "Pro Plan",
  "description": "Everything in Starter, plus priority support",
  "status": "ACTIVE",
  "mode": "TEST",
  "prices": [
    {
      "id": 7001,
      "productId": 5001,
      "type": "RECURRING",
      "amountMinor": 150000,
      "currency": "BDT",
      "interval": "MONTH",
      "intervalCount": 1,
      "trialDays": 14,
      "active": true,
      "createdAt": "2026-06-30T12:05:00"
    }
  ],
  "benefits": [
    {
      "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"
    }
  ],
  "createdAt": "2026-06-30T12:00:00",
  "updatedAt": "2026-06-30T12:30:00"
}

ProductDto

FieldTypeRequiredNotes
idnumberyesProduct id.
namestringyes
descriptionstring | nullnoOmitted when null.
statusenumyesDRAFT | ACTIVE | ARCHIVED.
modeenumyesTEST | LIVE.
pricesPriceDto[]yesAttached by the use case; empty for a fresh product.
benefitsBenefitDto[]yesThe product's attached benefits; empty if none.
createdAt / updatedAtdatetimeyes

A nested BenefitDto.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 a nested JSON object. Parse it client-side if you need the structure. See admin Benefits.

Edit, publish & archive

All three are by-id writes gated by product:write; each resolves the product's owning app+mode internally and writes an audit entry.

# Publish a draft product: DRAFT -> ACTIVE
curl -X POST http://localhost:8080/api/v1/admin/products/5001/publish \
  -H "Authorization: Bearer SESSION_TOKEN"

# Archive: DRAFT or ACTIVE -> ARCHIVED
curl -X POST http://localhost:8080/api/v1/admin/products/5001/archive \
  -H "Authorization: Bearer SESSION_TOKEN"
  • PublishPOST /admin/products/{id}/publish moves DRAFT → ACTIVE. Publishing a non-DRAFT product is rejected with 422 INVALID_OPERATION_STATE.
  • ArchivePOST /admin/products/{id}/archive moves DRAFT/ACTIVE → ARCHIVED. Archiving an already-archived product is rejected with 422.
  • EditPATCH /admin/products/{id} edits the descriptive fields. Editing an ARCHIVED (frozen) product is rejected with 422.

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 & archive prices

POST /admin/products/{id}/prices adds a price (gated by product:write). 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.

The two shapes are driven by type:

  • RECURRING — billed on a cadence. Requires an interval and an intervalCount ≥ 1; combine them for cadences like MONTH × 3 (quarterly). Optionally add trialDays.
  • ONE_TIME — a single up-front charge. No interval, intervalCount, or trialDays.
# A recurring price: 1,500.00 BDT / month with a 14-day trial
curl -X POST http://localhost:8080/api/v1/admin/products/5001/prices \
  -H "Authorization: Bearer SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "type": "RECURRING",
        "amountMinor": 150000,
        "interval": "MONTH",
        "intervalCount": 1,
        "trialDays": 14
      }'

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.
intervalCountnumber (int)conditionalRequired and ≥ 1 for RECURRING; forbidden for ONE_TIME.
trialDaysnumber (int)noOptional for RECURRING; forbidden for ONE_TIME.

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.

FieldTypeRequiredNotes
idnumberyesPrice id.
productIdnumberyesOwning product.
typeenumyesONE_TIME | RECURRING.
amountMinornumber (long)yesInteger minor units.
currencystringyesBDT in v1.
intervalenumnoRecurring only; omitted for one-time.
intervalCountnumbernoRecurring only; omitted for one-time.
trialDaysnumbernoRecurring only; omitted for one-time.
activebooleanyesfalse once archived.
createdAtdatetimeyes

Prices are immutable. A price's amount and cadence are fixed once created — there is no update endpoint. To reprice, 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.

POST /admin/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/admin/products/5001/prices/7001/archive \
  -H "Authorization: Bearer SESSION_TOKEN"

Attach & detach benefits

Benefits attach to the product, not to a price, so they survive repricing. These two endpoints are gated by benefit:writenot product:write — since they mutate the benefit↔product relationship.

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

curl -X POST http://localhost:8080/api/v1/admin/products/5001/benefits \
  -H "Authorization: Bearer SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "benefitId": 9001 }'

Request body — AttachBenefitRequest

FieldTypeRequiredNotes
benefitIdnumberyesMust reference a benefit in the same app + mode as the product.

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

DELETE /admin/products/{id}/benefits/{benefitId} detaches. Detaching 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.

curl -X DELETE http://localhost:8080/api/v1/admin/products/5001/benefits/9001 \
  -H "Authorization: Bearer SESSION_TOKEN"

End-to-end: author a sellable product

Create the product

POST /admin/products?applicationId=&mode= → starts DRAFT. applicationId and mode are required query params.

Add a recurring price

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

Attach a benefit

POST /admin/products/{id}/benefits (gated by benefit:write).

Publish

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

Dashboard metrics

Two product:read read endpoints feed the App-Selector dashboard. Both take applicationId and mode as required query params — the single dashboard-analytics exception to the rule that mode is never a request parameter. from/to (ISO dates) bound an optional [from, to) window.

Catalogue metrics — GET /admin/products/metrics

curl "http://localhost:8080/api/v1/admin/products/metrics?applicationId=42&mode=LIVE&from=2026-06-01&to=2026-06-30" \
  -H "Authorization: Bearer SESSION_TOKEN"
{
  "totalCount": 12,
  "activeCount": 8,
  "draftCount": 3,
  "archivedCount": 1
}

A ProductMetricsDto is counts only — products carry no money (prices live on a separate entity). It always returns concrete zeros (never null) for an empty window, and zeroed counts if you are not scoped to the requested app.

FieldTypeNotes
totalCountnumberProducts created in the window.
activeCountnumberHow many are ACTIVE.
draftCountnumberHow many are DRAFT.
archivedCountnumberHow many are ARCHIVED.

Top products by revenue — GET /admin/products/top

curl "http://localhost:8080/api/v1/admin/products/top?applicationId=42&mode=LIVE&from=2026-06-01&to=2026-06-30&limit=10" \
  -H "Authorization: Bearer SESSION_TOKEN"
[
  { "productId": 5001, "name": "Pro Plan", "grossSucceededMinor": 1350000, "succeededCount": 9 },
  { "productId": 5002, "name": "Starter", "grossSucceededMinor": 250000, "succeededCount": 5 }
]
ParamTypeRequiredNotes
applicationIdnumberyesThe app to rank.
modeenumyesTEST or LIVE.
from / todatenoISO dates bounding the window.
limitnumbernoDefaults 10.
Field (TopProductDto)TypeNotes
productIdnumberThe catalogue product.
namestringProduct name.
grossSucceededMinornumber (long)Gross succeeded revenue in minor units within the window.
succeededCountnumber (long)How many succeeded payments contributed.

Revenue is only attributable through price-pinned invoices (payment → invoice.price_id → price.product_id → product). Standalone payments (no invoice_id) and ad-hoc invoices (no price_id) carry no product linkage and contribute nothing — so this is "revenue from catalogue products," not all revenue. An out-of-scope app yields an empty list.

Error reference

HTTPerrorCodeWhen
400VALIDATION_ERRORMissing/blank required field; RECURRING price missing interval/intervalCount; ONE_TIME price carrying interval fields; unsupported currency.
403FORBIDDENYou lack the app-scoped permission (product:write for create / by-id writes; benefit:write to attach/detach) for the target app. The coarse @PreAuthorize gate returns 403 too when the authority is missing entirely.
404RESOURCE_NOT_FOUNDUnknown — or out-of-scope on a read — product, price, or benefit. App isolation returns 404, never 403, on a read.
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 — 403, creating into an app outside your scope

{
  "data": null,
  "meta": {
    "success": false,
    "message": "Operator lacks product:write for app 99",
    "errorCode": "FORBIDDEN",
    "timestamp": "2026-06-30T12:21:00Z"
  },
  "pagination": null
}

The admin create and price/benefit mutations do not accept an Idempotency-Key — that header is an App API feature. Every admin write is recorded in the append-only audit log as the acting operator instead. See Idempotency for the app path.

On this page