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
applicationIdandmodeas 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 onproduct:write; attaching and detaching benefits gate onbenefit: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:
| Call | Out-of-scope result |
|---|---|
GET /{id} detail, by-id reads | 404 — 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 create | 403 FORBIDDEN if you lack product:write for the target applicationId. |
GET /metrics | Zeroed counts (200). |
GET /top | Empty 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+modeas required query params — the operator chooses which app and which mode to author into. - By-id writes resolve the owning
app_id+modeinternally from the stored product (the*ForAdminuse 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) takemodeas 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.
| State | Sellable? | 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. |
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
| Param | Type | Required | Notes |
|---|---|---|---|
applicationId | number | yes | The app to author the product into. Must be within your product:write scope, or 403. |
mode | enum | yes | TEST or LIVE. The product is created in this mode; it is immutable thereafter. |
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 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"| Filter | Type | Notes |
|---|---|---|
applicationId | number | A specific app within your scope. Optional. |
mode | enum | TEST or LIVE. Optional here — a filter, not required (unlike create and the dashboard reads). |
status | enum | DRAFT | ACTIVE | ARCHIVED. Optional. |
search | string | Free-text match on name. Optional. |
page / size | number | Defaults 0 / 20. |
sortBy / order | string / enum | Defaults createdAt / DESC. |
paginate | boolean | Defaults true; set false to return the unpaged set. |
Each row is a compact ProductSummaryDto (no prices):
| Field | Type | Notes |
|---|---|---|
id | number | Product id. |
name | string | |
status | enum | DRAFT | ACTIVE | ARCHIVED. |
mode | enum | TEST | LIVE. |
createdAt | datetime |
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
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | yes | Product id. |
name | string | yes | |
description | string | null | no | Omitted when null. |
status | enum | yes | DRAFT | ACTIVE | ARCHIVED. |
mode | enum | yes | TEST | LIVE. |
prices | PriceDto[] | yes | Attached by the use case; empty for a fresh product. |
benefits | BenefitDto[] | yes | The product's attached benefits; empty if none. |
createdAt / updatedAt | datetime | yes |
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"- Publish —
POST /admin/products/{id}/publishmovesDRAFT → ACTIVE. Publishing a non-DRAFTproduct is rejected with422INVALID_OPERATION_STATE. - Archive —
POST /admin/products/{id}/archivemovesDRAFT/ACTIVE → ARCHIVED. Archiving an already-archived product is rejected with422. - Edit —
PATCH /admin/products/{id}edits the descriptive fields. Editing anARCHIVED(frozen) product is rejected with422.
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 & 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 anintervaland anintervalCount ≥ 1; combine them for cadences likeMONTH × 3(quarterly). Optionally addtrialDays.ONE_TIME— a single up-front charge. Nointerval,intervalCount, ortrialDays.
# 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
| 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. |
intervalCount | number (int) | conditional | Required and ≥ 1 for RECURRING; forbidden for ONE_TIME. |
trialDays | number (int) | no | Optional 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.
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | yes | Price id. |
productId | number | yes | Owning product. |
type | enum | yes | ONE_TIME | RECURRING. |
amountMinor | number (long) | yes | Integer minor units. |
currency | string | yes | BDT in v1. |
interval | enum | no | Recurring only; omitted for one-time. |
intervalCount | number | no | Recurring only; omitted for one-time. |
trialDays | number | no | Recurring only; omitted for one-time. |
active | boolean | yes | false once archived. |
createdAt | datetime | yes |
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:write — not 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
| Field | Type | Required | Notes |
|---|---|---|---|
benefitId | number | yes | Must 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}/publish → ACTIVE, 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.
| Field | Type | Notes |
|---|---|---|
totalCount | number | Products created in the window. |
activeCount | number | How many are ACTIVE. |
draftCount | number | How many are DRAFT. |
archivedCount | number | How 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 }
]| Param | Type | Required | Notes |
|---|---|---|---|
applicationId | number | yes | The app to rank. |
mode | enum | yes | TEST or LIVE. |
from / to | date | no | ISO dates bounding the window. |
limit | number | no | Defaults 10. |
Field (TopProductDto) | Type | Notes |
|---|---|---|
productId | number | The catalogue product. |
name | string | Product name. |
grossSucceededMinor | number (long) | Gross succeeded revenue in minor units within the window. |
succeededCount | number (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
| HTTP | errorCode | When |
|---|---|---|
400 | VALIDATION_ERROR | Missing/blank required field; RECURRING price missing interval/intervalCount; ONE_TIME price carrying interval fields; unsupported currency. |
403 | FORBIDDEN | You 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. |
404 | RESOURCE_NOT_FOUND | Unknown — or out-of-scope on a read — product, price, or benefit. App isolation returns 404, never 403, on a read. |
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 — 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.