OI Payments Docs
Admin dashboard

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.

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.

PropertyRule
RequiredlookupKey is mandatory on create (@NotBlank).
LengthMaximum 80 characters (@Size(max = 80)).
ImmutableIt cannot be edited. Re-keying is treated as a new benefit — there is no lookupKey field on the edit endpoint.
UniqueUnique 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

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

FieldTypeRequiredNotes
lookupKeystringYesStable gating key. Max 80 chars, immutable, unique per app + mode. Trimmed before storage.
namestringYesDisplay name. Max 255 chars.
metadatastring (raw JSON)NoOptional 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.

FieldTypeRequiredNotes
idnumberYesServer-assigned identifier.
lookupKeystringYesThe immutable gating key.
namestringYesDisplay name.
metadatastring (raw JSON)NoThe raw jsonb value, echoed as a JSON-encoded string (escaped), not a nested object — see the note below. Omitted entirely when absent (NON_NULL).
activebooleanYesWhether the benefit currently grants entitlements.
modeenumYesTEST or LIVE.
createdAtstring (date-time)YesCreation timestamp.
updatedAtstring (date-time)YesLast-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 paramTypeDefaultNotes
pagenumber0Zero-based page index.
sizenumber20Page size.
sortBystringcreatedAtSort field.
orderenumDESCASC or DESC.
paginatebooleantrueSet false to return the full unpaged list.
applicationIdnumberOptional. Restrict to one app.
modeenumOptional. TEST or LIVE.
activebooleanOptional. Filter by active flag.
searchstringOptional. 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.

FieldTypeRequiredNotes
namestringNoMax 255. A null/blank value leaves the display name unchanged.
metadatastring (raw JSON)NoWhen 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 send null) → 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 — set active = true.
  • POST /admin/benefits/{id}/deactivate — set active = 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 } (benefitId is 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 }'
FieldTypeRequiredNotes
benefitIdnumberYesA 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 paramTypeRequiredNotes
applicationIdnumberYesThe app to report on.
modeenumYesTEST or LIVE — this row honours your test/live toggle.
fromstring (date)NoISO date, inclusive start of the [from, to) window.
tostring (date)NoISO 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:

FieldTypeRequiredNotes
totalCountnumberYesBenefits created in the window. Always a concrete number (never null).
activeCountnumberYesHow 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.

On this page