Subscriptions (operations)
Drive the subscription billing sweeps, search and inspect subscriptions across applications, and cancel, pause, or resume them from the admin dashboard.
The admin subscription surface is for operators, not integrating apps. It does three things: it lets you trigger the recurring-billing sweeps on demand (the same ones the schedulers run), it gives you a cross-app read (search + detail) over every subscription your grants cover, and it lets you cancel, pause, or resume an individual subscription through the same ops engine the app API uses.
App-facing subscription creation and the customer-managed lifecycle live in the Subscriptions guide and the Catalog & subscriptions concept. This page is the operator's view.
All endpoints below are admin, session-authenticated — send your session token:
-H "Authorization: Bearer SESSION_TOKEN"Every response uses the standard envelope:
{ "data": …, "meta": { "success", "message", "errorCode", "timestamp" }, "pagination": … }.
The examples show just the data payload after the first one. See
Responses & errors for the full shape.
Money and mode
Subscription cycle invoices are billed in integer minor units (paisa) — 150000
is 1,500.00 BDT — and currency is BDT-only in v1. See Money.
Every subscription carries an app_id and a mode (TEST/LIVE); one app never
sees another app's or another mode's data. See App isolation
and Modes.
The subscription lifecycle
A subscription moves through five states. A subscription created against a trial
price starts TRIALING (no charge yet; the first invoice falls at trial end);
without a trial it starts ACTIVE (billed from its start date). A TRIALING or
ACTIVE subscription is entitling — see Entitlements.
| Status | Meaning |
|---|---|
TRIALING | In a trial; no charge yet. The first invoice falls at trial end. Entitling. |
ACTIVE | Billed each cycle from the start date. Entitling. |
PAST_DUE | A renewal payment failed; the subscription is in the dunning window. |
PAUSED | The app (or an operator) paused billing. No cycles are billed until resumed. |
CANCELED | Terminal. Reached by an at-period-end or immediate cancel, or by dunning exhaustion. |
Permissions and scope
Reads gate on subscription:read; writes gate on subscription:write. The two
behave differently with respect to app scope.
| Operation | Endpoint | Authority | App scope |
|---|---|---|---|
| Billing sweep | POST /admin/subscriptions/billing/run | subscription:write | None — system-wide |
| Trial-reminder sweep | POST /admin/subscriptions/trial-reminders | subscription:write | None — system-wide |
| Dunning sweep | POST /admin/subscriptions/dunning/run | subscription:write | None — system-wide |
| Search | GET /admin/subscriptions | subscription:read | Narrowed to your grants |
| Detail | GET /admin/subscriptions/{id} | subscription:read | Narrowed to your grants |
| Cancel | POST /admin/subscriptions/{id}/cancel | subscription:write | Per-app, on the owning app |
| Pause | POST /admin/subscriptions/{id}/pause | subscription:write | Per-app, on the owning app |
| Resume | POST /admin/subscriptions/{id}/resume | subscription:write | Per-app, on the owning app |
| Metrics | GET /admin/subscriptions/metrics | subscription:read | Per-app (zeros if out of scope) |
The three sweeps are system-wide
The billing, trial-reminder, and dunning sweeps gate only on subscription:write
and carry no app scope — they process every app's subscriptions in both modes,
exactly as the schedulers do. Treat subscription:write for sweep triggers as a
privileged, ops-level grant.
Out-of-scope behaviour differs by verb. A read of a subscription outside your
grants is indistinguishable from a missing one — GET /admin/subscriptions/{id}
returns 404 so the dashboard never leaks that another app's subscription exists,
and a search simply omits it. A write resolves the subscription's owning app first:
a non-existent id is 404, while an id that exists but sits outside your grants is
403. Metrics for an app you are not scoped to returns all-zero counts, not an
error. App scope (RBAC-4) is enforced inside each use case — see RBAC.
Mode is never supplied by the operator
The mode (TEST or LIVE) of a subscription is a property of the subscription —
derived from the app credential that originally created it, never from request input.
For cancel, pause, and resume, the server resolves the subscription's owning
app_id + mode cross-app and scopes the op exactly as the app path would; the
client never states — and could never misstate — a subscription's mode. The one
place mode is an explicit input is the metrics query parameter (see below),
mirroring the dashboard analytics endpoints.
On-demand sweeps
The recurring-billing machinery runs on schedulers by default. These three POST
endpoints fire the same use cases on demand — useful for testing, for catching
up after downtime, or for an operator-driven run. Each returns a per-run tally and
each is resilient: a single bad subscription is logged and skipped so it never
aborts the rest of the run, and per-row pessimistic locking makes a concurrent
sweep safe (a candidate that is no longer eligible under the lock is counted as
skipped). The sweeps are not audited; cancel/pause/resume are.
Configuration
Each sweep has an enable flag, an interval, and a tuning knob. The interval and tuning values default as shown; override them with the env-mapped properties.
| Sweep | Enable flag (default) | Interval property (default) | Tuning property (default) |
|---|---|---|---|
| Billing | app.subscription.billing-sweep.enabled (true) | app.subscription.billing-sweep.interval-ms (3600000 — 1h) | — |
| Dunning | shares app.subscription.billing-sweep.enabled (true) | app.subscription.dunning.interval-ms (3600000 — 1h) | app.subscription.dunning.grace-days (7) |
| Trial reminder | app.subscription.trial-reminder.enabled (true) | app.subscription.trial-reminder.interval-ms (86400000 — 24h) | app.subscription.trial-reminder.lead-days (3) |
Behavioural rules these knobs drive:
- Billing bills every due
TRIALING/ACTIVEcycle and matures any subscription whose at-period-end cancel has come due (it is canceled with no further invoice). - Trial reminder fires on the exact lead day: it selects
TRIALINGsubscriptions whosetrialEndDateequals today +lead-days(not<=), and emits onesubscription.trial_will_endnotification each. Running once a day therefore notifies each subscription exactly once, with no "reminder sent" flag. - Dunning cancels every subscription stuck
PAST_DUEwhosepast_due_sinceis on or beforenow − grace-days(i.e. past the grace window). A subscription whose payment recovers before the grace window expires returns toACTIVEand is never touched by this sweep.
Run a sweep
curl -X POST http://localhost:8080/api/v1/admin/subscriptions/billing/run \
-H "Authorization: Bearer SESSION_TOKEN"Full envelope:
{
"data": { "candidates": 12, "billed": 9, "canceled": 2, "skipped": 1, "failed": 0 },
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}curl -X POST http://localhost:8080/api/v1/admin/subscriptions/trial-reminders \
-H "Authorization: Bearer SESSION_TOKEN"{ "candidates": 4, "notified": 4, "failed": 0 }curl -X POST http://localhost:8080/api/v1/admin/subscriptions/dunning/run \
-H "Authorization: Bearer SESSION_TOKEN"{ "candidates": 3, "canceled": 2, "skipped": 1, "failed": 0 }Run-summary fields
BillingRunSummary — POST /admin/subscriptions/billing/run
| Field | Type | Notes |
|---|---|---|
candidates | int | Due candidate subscriptions the sweep examined. |
billed | int | Cycles successfully billed (invoice issued + calendar advanced). |
canceled | int | Subscriptions canceled at their scheduled period end (no invoice). |
skipped | int | Candidates no longer due under the lock; left untouched. |
failed | int | Candidates whose billing threw; logged and skipped, run continued. |
TrialReminderRunSummary — POST /admin/subscriptions/trial-reminders
| Field | Type | Notes |
|---|---|---|
candidates | int | Trialing subscriptions whose trial ends on the lead day. |
notified | int | Reminders emitted (subscription.trial_will_end). |
failed | int | Rows that threw while processing; skipped. |
DunningCancelRunSummary — POST /admin/subscriptions/dunning/run
| Field | Type | Notes |
|---|---|---|
candidates | int | PAST_DUE subscriptions selected as beyond the grace window. |
canceled | int | Subscriptions actually canceled (PAST_DUE → CANCELED). |
skipped | int | Rows no longer eligible when re-checked under the lock. |
failed | int | Rows that threw while processing; skipped. |
The subscription.trial_will_end event is delivered to the owning app like any
other webhook — see Webhooks and
Webhooks & logs.
Cross-app search and detail
GET /admin/subscriptions returns a paginated,
filterable list of subscription summaries across every app your subscription:read
grants cover (a Super Admin or holder of a global grant is unrestricted). Results
are automatically narrowed to your scope.
Query parameters
| Field | Type | Required | Notes |
|---|---|---|---|
page | int | No | Zero-based page index. Default 0. |
size | int | No | Page size. Default 20. |
sortBy | string | No | Sort field. Default createdAt. |
order | ASC | DESC | No | Sort direction. Default DESC. |
paginate | boolean | No | false returns the full set unpaged. Default true. |
applicationId | Long | No | Restrict to one app within your scope. |
mode | TEST | LIVE | No | Filter by mode. |
status | enum | No | TRIALING, ACTIVE, PAST_DUE, PAUSED, or CANCELED. |
customerReference | string | No | The app's external customer id. Requires applicationId to resolve — see below. |
customerReference requires applicationId
customerReference is the app's external customer id, which is only unique
within an app. The server resolves it to an internal id keyed by applicationId,
so a customerReference filter without an applicationId cannot resolve and
yields an empty page — as does a reference the app has never seen. Always pair
the two.
curl -G http://localhost:8080/api/v1/admin/subscriptions \
-H "Authorization: Bearer SESSION_TOKEN" \
--data-urlencode "applicationId=42" \
--data-urlencode "mode=LIVE" \
--data-urlencode "status=PAST_DUE"{
"data": [
{
"id": 9007,
"status": "PAST_DUE",
"productId": 310,
"priceId": 540,
"startDate": "2026-01-15",
"nextBillingDate": "2026-06-15",
"cancelAtPeriodEnd": false,
"mode": "LIVE",
"createdAt": "2026-01-15T09:30:00Z"
}
],
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": { "page": 0, "size": 20, "totalElements": 1, "totalPages": 1 }
}SubscriptionSummaryDto (list row)
| Field | Type | Notes |
|---|---|---|
id | Long | Subscription id. |
status | enum | TRIALING | ACTIVE | PAST_DUE | PAUSED | CANCELED. |
productId | Long | The product the subscription bills against. |
priceId | Long | The recurring price. |
startDate | date | When billing started (yyyy-MM-dd). |
nextBillingDate | date | Next scheduled cycle date. |
cancelAtPeriodEnd | boolean | A cancel is scheduled for the current period end. |
mode | TEST | LIVE | The subscription's mode. |
createdAt | datetime | Creation timestamp. |
Detail
GET /admin/subscriptions/{id} returns the full read model for one subscription,
including the resolved external customerReference. A subscription outside your
scope reads as 404, exactly like a missing id.
curl http://localhost:8080/api/v1/admin/subscriptions/9007 \
-H "Authorization: Bearer SESSION_TOKEN"{
"id": 9007,
"status": "PAST_DUE",
"productId": 310,
"priceId": 540,
"customerReference": "cust_a1b2c3",
"startDate": "2026-01-15",
"currentPeriodStart": "2026-05-15",
"nextBillingDate": "2026-06-15",
"cancelAtPeriodEnd": false,
"mode": "LIVE",
"createdAt": "2026-01-15T09:30:00Z",
"updatedAt": "2026-06-16T02:10:00Z"
}SubscriptionDto (detail)
| Field | Type | Notes |
|---|---|---|
id | Long | Subscription id. |
status | enum | TRIALING | ACTIVE | PAST_DUE | PAUSED | CANCELED. |
productId | Long | The product the subscription bills against. |
priceId | Long | The recurring price. |
customerReference | string | The app's external customer id; attached by the use case, not stored on the entity. |
startDate | date | When billing started. |
currentPeriodStart | date | Start of the current billing period. |
nextBillingDate | date | Next scheduled cycle date. |
trialEndDate | date | Trial end; omitted when there is no trial (NON_NULL). |
cancelAtPeriodEnd | boolean | A cancel is scheduled for the current period end. |
canceledAt | datetime | When the subscription was canceled; omitted until canceled. |
mode | TEST | LIVE | The subscription's mode. |
createdAt | datetime | Creation timestamp. |
updatedAt | datetime | Last update timestamp. |
Cancel, pause, and resume
These three writes drive the same ops engine as the app API — there is no
parallel admin path. Each resolves the subscription's owning app_id + mode
cross-app, enforces the per-app subscription:write scope (403 if you lack it on
that app), runs the op, and writes an append-only audit
entry atomically with the change. A non-existent id is 404.
| Op | Endpoint | Audit action | Body |
|---|---|---|---|
| Cancel | POST /admin/subscriptions/{id}/cancel | SUBSCRIPTION_CANCELED | CancelSubscriptionRequest (optional) |
| Pause | POST /admin/subscriptions/{id}/pause | SUBSCRIPTION_PAUSED | none |
| Resume | POST /admin/subscriptions/{id}/resume | SUBSCRIPTION_RESUMED | none |
CancelSubscriptionRequest
| Field | Type | Required | Notes |
|---|---|---|---|
atPeriodEnd | Boolean | No | true (the default) keeps billing through the paid period, then cancels; false cancels immediately. A null field — or an entirely absent body — is treated as true (cancel at period end). |
curl -X POST http://localhost:8080/api/v1/admin/subscriptions/9007/cancel \
-H "Authorization: Bearer SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "atPeriodEnd": true }'{
"id": 9007,
"status": "PAST_DUE",
"productId": 310,
"priceId": 540,
"customerReference": "cust_a1b2c3",
"startDate": "2026-01-15",
"currentPeriodStart": "2026-05-15",
"nextBillingDate": "2026-06-15",
"cancelAtPeriodEnd": true,
"mode": "LIVE",
"createdAt": "2026-01-15T09:30:00Z",
"updatedAt": "2026-06-30T12:00:00Z"
}With atPeriodEnd: true the subscription keeps its status and nextBillingDate
but flips cancelAtPeriodEnd to true; the next billing sweep matures the cancel
when the period ends. With atPeriodEnd: false the subscription transitions to
CANCELED immediately. Pause and resume take no body:
curl -X POST http://localhost:8080/api/v1/admin/subscriptions/9007/pause \
-H "Authorization: Bearer SESSION_TOKEN"
curl -X POST http://localhost:8080/api/v1/admin/subscriptions/9007/resume \
-H "Authorization: Bearer SESSION_TOKEN"Both return the updated SubscriptionDto.
Metrics
GET /admin/subscriptions/metrics returns the per-app, mode-aware subscription
counts for the dashboard metrics row. Both applicationId and mode are
required query parameters.
Mode is a required parameter here
Unlike the app API — where the mode is derived from the credential, never the
request — this metrics endpoint takes mode as a required
query parameter, the same pattern as the dashboard analytics
endpoints. If you are not scoped to the requested applicationId, the response is
all zeros (RBAC-4), never an error.
| Field | Type | Required | Notes |
|---|---|---|---|
applicationId | Long | Yes | The app to report on. |
mode | TEST | LIVE | Yes | Which mode's counts to return. |
from | date | No | Window start, inclusive (yyyy-MM-dd). Defaults to an open lower bound. |
to | date | No | Window end, inclusive by day (the window is [from, to + 1 day)). Defaults to an open upper bound. |
curl -G http://localhost:8080/api/v1/admin/subscriptions/metrics \
-H "Authorization: Bearer SESSION_TOKEN" \
--data-urlencode "applicationId=42" \
--data-urlencode "mode=LIVE" \
--data-urlencode "from=2026-06-01" \
--data-urlencode "to=2026-06-30"{
"totalCount": 128,
"activeCount": 96,
"trialingCount": 12,
"pastDueCount": 7,
"canceledCount": 13
}SubscriptionMetricsDto
| Field | Type | Notes |
|---|---|---|
totalCount | Long | All subscriptions created in the [from, to) window. Always a concrete number, never null. |
activeCount | Long | Of those window subscriptions, the count whose current status is ACTIVE. |
trialingCount | Long | Of those window subscriptions, the count whose current status is TRIALING. |
pastDueCount | Long | Of those window subscriptions, the count whose current status is PAST_DUE. |
canceledCount | Long | Of those window subscriptions, the count whose current status is CANCELED. |
Related
Subscriptions guide
The app-facing create-and-manage flow.
Catalog & subscriptions
How products, prices, and subscriptions fit together.
Products (admin)
Author the products and recurring prices subscriptions bill against.
Entitlements
How TRIALING and ACTIVE subscriptions grant access.
RBAC
Grants, app scope, and the read/write authority split.
Dashboard analytics
The other mode-as-query-parameter metrics endpoints.