Benefits catalogue
Author the benefit catalogue from the admin console — immutable lookup keys, raw JSON metadata, and the entitlements they power.
A benefit is a per-app catalogue entry that answers "what does buying a product
grant the customer?" — Stripe's entitlements.feature. Each benefit has a stable
lookupKey that your gating code keys on; attaching it to a product is what turns
a paid subscription or one-time purchase into a live entitlement.
This page covers the admin console surface (/api/v1/admin/benefits,
session-authenticated, RBAC-guarded). It drives the same engine as the
app-API-key benefits guide — there is no parallel
benefit type, only a second audience.
Entitlements guide
The app-facing API: create benefits, derive a customer's active entitlements.
Products (admin)
Attach benefits to products and manage prices.
Catalog & subscriptions
How products, prices, subscriptions and benefits fit together.
All responses use the standard envelope:
{
"data": { "...": "payload or null" },
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}Below we show only the data payload unless the envelope itself is the point.
The lookupKey gating model
The lookupKey is the app's own stable gating identifier. Your client code keys
on it — never on the numeric id or the display name — so gating survives
repricing, renaming, or replacing the benefit.
| Property | Rule |
|---|---|
| Required | lookupKey is mandatory on create (@NotBlank). |
| Length | Maximum 80 characters (@Size(max = 80)). |
| Immutable | It cannot be edited. Re-keying is treated as a new benefit — there is no lookupKey field on the edit endpoint. |
| Unique | Unique per app + mode. Creating a second benefit with the same key in the same app and mode is rejected with 400. |
Every benefit also carries an app_id and a mode (TEST/LIVE). A benefit
from another app or another mode is never returned — see
app isolation and modes.
Mode follows the modes rule, with one wrinkle for the
admin console. On the app-facing API the mode is derived from the API
credential — never from input. The admin console is session-authenticated
and carries no key binding, so the create and metrics endpoints take
applicationId and mode as explicit query parameters. By-id writes
(edit / activate / deactivate / attach) resolve the owning app + mode from the
stored record, so you never restate — or could misstate — the mode of an
existing benefit.
Permissions
| Action | Authority |
|---|---|
| Read (search, detail, metrics) | benefit:read |
| Write (create, edit, activate, deactivate, attach/detach) | benefit:write |
Both authorities are seeded to Super Admin only by default (migration V23).
Every other role is opt-in — grant the authority through role
management to delegate catalogue work. The coarse gate is
checked on the controller; a finer per-app scope check (RBAC-4) runs inside
each use case, and every write is audited.
Create, search, read, edit
All examples use the base URL http://localhost:8080/api/v1 and an admin session
token.
Create a benefit
POST /admin/benefits?applicationId={appId}&mode={TEST|LIVE} — both query params
are required. The benefit starts active.
| Field | Type | Required | Notes |
|---|---|---|---|
lookupKey | string | Yes | Stable gating key. Max 80 chars, immutable, unique per app + mode. Trimmed before storage. |
name | string | Yes | Display name. Max 255 chars. |
metadata | string (raw JSON) | No | Optional structured value, e.g. {"seats": 10}. Validated as well-formed JSON — see metadata. |
curl -X POST "http://localhost:8080/api/v1/admin/benefits?applicationId=42&mode=TEST" \
-H "Authorization: Bearer SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"lookupKey": "pro_seats",
"name": "Pro seats",
"metadata": "{\"seats\": 10}"
}'{
"lookupKey": "pro_seats",
"name": "Pro seats",
"metadata": "{\"seats\": 10}"
}Returns 201 Created with the new benefit:
{
"data": {
"id": 4012,
"lookupKey": "pro_seats",
"name": "Pro seats",
"metadata": "{\"seats\": 10}",
"active": true,
"mode": "TEST",
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:00:00"
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}Unlike the app-facing create, the admin authoring
path is not idempotency-keyed — sending an Idempotency-Key header here has
no effect. The unique-key guard (one lookupKey per app + mode) is what protects
you from accidental duplicates.
BenefitDto
The read model returned by create, detail, search rows, edit, and the activate/deactivate toggles.
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | Yes | Server-assigned identifier. |
lookupKey | string | Yes | The immutable gating key. |
name | string | Yes | Display name. |
metadata | string (raw JSON) | No | The raw jsonb value, echoed as a JSON-encoded string (escaped), not a nested object — see the note below. Omitted entirely when absent (NON_NULL). |
active | boolean | Yes | Whether the benefit currently grants entitlements. |
mode | enum | Yes | TEST or LIVE. |
createdAt | string (date-time) | Yes | Creation timestamp. |
updatedAt | string (date-time) | Yes | Last-modified timestamp. |
metadata is a string, not an object. The DTO field is a plain string, so
Jackson serializes the stored jsonb as an escaped JSON string
("metadata": "{\"seats\": 10}"), not a nested JSON object. Consumers must
JSON.parse it to read the structured value.
Search benefits
GET /admin/benefits — paginated, cross-app, narrowed to the apps your
benefit:read grants cover (out-of-scope rows are simply not returned).
| Query param | Type | Default | Notes |
|---|---|---|---|
page | number | 0 | Zero-based page index. |
size | number | 20 | Page size. |
sortBy | string | createdAt | Sort field. |
order | enum | DESC | ASC or DESC. |
paginate | boolean | true | Set false to return the full unpaged list. |
applicationId | number | — | Optional. Restrict to one app. |
mode | enum | — | Optional. TEST or LIVE. |
active | boolean | — | Optional. Filter by active flag. |
search | string | — | Optional. Case-insensitive LIKE over name or lookupKey. |
curl "http://localhost:8080/api/v1/admin/benefits?applicationId=42&mode=TEST&active=true&search=seats" \
-H "Authorization: Bearer SESSION_TOKEN"{
"data": [
{
"id": 4012,
"lookupKey": "pro_seats",
"name": "Pro seats",
"metadata": "{\"seats\": 10}",
"active": true,
"mode": "TEST",
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:00:00"
}
],
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": { "page": 0, "size": 20, "totalElements": 1, "totalPages": 1 }
}Read one benefit
GET /admin/benefits/{id}. A benefit outside your app scope reads as 404 —
identical to a missing id, so the console never leaks the existence of another
app's benefit.
curl "http://localhost:8080/api/v1/admin/benefits/4012" \
-H "Authorization: Bearer SESSION_TOKEN"Edit a benefit
PATCH /admin/benefits/{id} — PATCH semantics. The lookupKey is immutable and
is therefore not editable here.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | No | Max 255. A null/blank value leaves the display name unchanged. |
metadata | string (raw JSON) | No | When present, replaces the stored value (validated). See clearing semantics below. |
metadata clearing rules (it is a string field, so JSON null and an omitted
field are indistinguishable):
- Omit
metadata(or sendnull) → the stored value is left unchanged. - Send
""(blank/whitespace) → the stored value is cleared to null. - Send valid JSON → replaces the stored value.
- Send malformed JSON → rejected with
400.
curl -X PATCH "http://localhost:8080/api/v1/admin/benefits/4012" \
-H "Authorization: Bearer SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Pro seats (10)", "metadata": "{\"seats\": 12}" }'Activate & deactivate
A benefit toggles between active and inactive. Deactivating it stops it
granting entitlements without deleting it or breaking the lookupKey your gating
code relies on.
POST /admin/benefits/{id}/activate— setactive = true.POST /admin/benefits/{id}/deactivate— setactive = false.
Both require benefit:write plus the per-app scope check, are audited, and resolve
the benefit's owning app + mode from the stored record (no mode param).
curl -X POST "http://localhost:8080/api/v1/admin/benefits/4012/deactivate" \
-H "Authorization: Bearer SESSION_TOKEN"metadata as raw JSON
metadata is an optional, free-form JSON value stored as-is in a jsonb
column — it carries structured limits such as {"seats": 10} or
{"tier": "gold", "apiCallsPerMonth": 100000}. The service does not interpret it;
your app reads it back and decides what it means.
On both create and edit, a supplied value is parsed for well-formedness before it reaches the column. A malformed value is rejected up front:
{
"data": null,
"meta": {
"success": false,
"message": "metadata must be a valid JSON object",
"errorCode": "VALIDATION_ERROR",
"timestamp": "2026-06-30T12:00:00Z"
},
"pagination": null
}A blank value normalises to null (clears it). Because the DTO field is a string,
the value is echoed back on reads as a JSON-encoded string — JSON.parse it.
How benefits power entitlements
A benefit only matters once it is attached to a product. When a customer holds
an active entitling subscription or a paid one-time purchase of that
product, the product's attached benefits become the customer's live
entitlements, deduplicated by lookupKey.
Attach a benefit to a product
Product↔benefit attachment lives on the products controller, but is gated by
benefit:write (not product:write):
POST /admin/products/{id}/benefits— attach. Body:{ "benefitId": 4012 }(benefitIdis required). Both the product and the benefit must belong to the same app + mode (resolved from the product); attaching the same pair twice is a no-op (idempotent). Returns the product with its prices and attached benefits.DELETE /admin/products/{id}/benefits/{benefitId}— detach a single attachment.
curl -X POST "http://localhost:8080/api/v1/admin/products/900/benefits" \
-H "Authorization: Bearer SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "benefitId": 4012 }'| Field | Type | Required | Notes |
|---|---|---|---|
benefitId | number | Yes | A benefit in the same app + mode as the product. |
Reading the result
Entitlement derivation is an app-facing read. Your integration calls
GET /api/v1/entitlements?customerReference=... to list a customer's active
entitlements, or GET /api/v1/entitlements/check to test a single lookupKey.
GET /entitlements/check is a full-derivation membership test — it
recomputes the customer's entitlements from their subscriptions and purchases on
each call. Treat it as correct, not as a fast-path cache; do not assume any
short-circuit. See the entitlements guide.
Dashboard metrics
GET /admin/benefits/metrics powers the App-Selector dashboard metrics row.
| Query param | Type | Required | Notes |
|---|---|---|---|
applicationId | number | Yes | The app to report on. |
mode | enum | Yes | TEST or LIVE — this row honours your test/live toggle. |
from | string (date) | No | ISO date, inclusive start of the [from, to) window. |
to | string (date) | No | ISO date, exclusive end. |
curl "http://localhost:8080/api/v1/admin/benefits/metrics?applicationId=42&mode=TEST&from=2026-06-01&to=2026-06-30" \
-H "Authorization: Bearer SESSION_TOKEN"Returns BenefitMetricsDto:
| Field | Type | Required | Notes |
|---|---|---|---|
totalCount | number | Yes | Benefits created in the window. Always a concrete number (never null). |
activeCount | number | Yes | How many of those are active. |
{
"data": { "totalCount": 7, "activeCount": 6 },
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}If the acting admin is not scoped to applicationId, the metrics come back zeroed
({ "totalCount": 0, "activeCount": 0 }) rather than 403, mirroring how an
out-of-scope search returns no rows. This mode-as-query-parameter shape matches
the wider dashboard analytics endpoints — the
one place mode is a request input rather than a credential property.
Related
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.
Subscriptions (operations)
Drive the subscription billing sweeps, search and inspect subscriptions across applications, and cancel, pause, or resume them from the admin dashboard.