Subscriptions
Create and manage recurring subscriptions billed against a recurring price — trials, lifecycle, cancellation, pause/resume, dunning, and coupons.
A subscription bills a customer on a recurring cadence for one product, against a single recurring price. The price is captured at create time and fixed for the subscription's lifetime, so a later reprice never silently moves a live subscriber — the subscription keeps billing on the terms it was created with.
You point a subscription at a priceId and your own customerReference; the
service finds-or-creates the customer, computes the
billing calendar, and from then on a background sweep issues each cycle's invoice.
Money is always integer minor units (paisa) — 150000
means 1,500.00 BDT — and the amount is read from the captured price, never sent in
the request.
The mode (TEST or LIVE) is derived from the API credential that authenticated
the request — never from the request body. A subscription created with a
oi_test_… key lives entirely in TEST and is invisible to LIVE. See
test & live modes and
app isolation.
All endpoints on this page are App-API endpoints under /api/v1/subscriptions,
authenticated with your API key and secret.
Create a subscription
POST /subscriptions creates a subscription against a recurring price of an
active product. The response is the full SubscriptionDto with a 201 Created.
Pick a recurring price
The priceId must reference a RECURRING, still-active price whose owning
product is ACTIVE. The sellability checks are strict:
| Condition | Result |
|---|---|
| Price not visible in this app + mode | 404 RESOURCE_NOT_FOUND |
Price is ONE_TIME (not recurring) | 400 VALIDATION_ERROR |
Owning product is not ACTIVE | 422 INVALID_OPERATION_STATE |
Price is archived (active = false) | 422 INVALID_OPERATION_STATE |
For a one-time charge, use one-time purchases instead.
Send the request
Identify the customer with your own customerReference. The optional contact
fields (customerName / customerEmail / customerPhone) are refreshed onto that
customer reference when supplied. Send an
Idempotency-Key so a retry can't double-create — a
repeat with the same key and same body replays the first response.
curl -X POST http://localhost:8080/api/v1/subscriptions \
-H "X-Api-Key: oi_test_3kf9d2" \
-H "X-Api-Secret: sk_test_a91b7c4e2f" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2e44-6b8a-4d2c-9f7e-1a2b3c4d5e6f" \
-d '{
"priceId": 4012,
"customerReference": "cust_8821",
"customerEmail": "[email protected]",
"couponCode": "LAUNCH20"
}'Request body:
{
"priceId": 4012,
"customerReference": "cust_8821",
"customerEmail": "[email protected]",
"couponCode": "LAUNCH20"
}Read the response
Every endpoint returns the standard envelope: { "data": …, "meta": … }. After
this section we show only the data payload. A trial price comes back
TRIALING; a non-trial price comes back ACTIVE.
{
"data": {
"id": 5567,
"status": "ACTIVE",
"productId": 3001,
"priceId": 4012,
"customerReference": "cust_8821",
"startDate": "2026-06-30",
"currentPeriodStart": "2026-06-30",
"nextBillingDate": "2026-06-30",
"cancelAtPeriodEnd": false,
"mode": "TEST",
"createdAt": "2026-06-30T12:00:00Z",
"updatedAt": "2026-06-30T12:00:00Z"
},
"meta": {
"success": true,
"message": null,
"errorCode": null,
"timestamp": "2026-06-30T12:00:00Z"
},
"pagination": null
}No invoice is created at create time. The create call only persists the
subscription and computes its nextBillingDate. The first cycle's invoice is
issued later by the B6 billing sweep when nextBillingDate matures — see
Billing cycle & renewals below.
Rejected create (400 / 404 / 422)
A non-recurring price is rejected before anything is persisted:
{
"data": null,
"meta": {
"success": false,
"message": "A subscription requires a recurring price",
"errorCode": "VALIDATION_ERROR",
"timestamp": "2026-06-30T12:00:00Z"
},
"pagination": null
}Trials vs. immediate billing
Whether a subscription trials is decided by the price's trialDays — not by
anything in the create request. There is no trial flag on CreateSubscriptionRequest.
| Price | Initial status | trialEndDate | nextBillingDate |
|---|---|---|---|
trialDays > 0 | TRIALING | startDate + trialDays | trialEndDate (first invoice at trial end) |
| no trial | ACTIVE | omitted (null) | startDate (billed from the start date) |
startDate defaults to today when the request omits it. currentPeriodStart
is set to startDate for both cases. In neither case is an invoice created at
this point — the B6 sweep issues the first cycle invoice when nextBillingDate
arrives, and a trial that reaches its end is moved TRIALING → ACTIVE at that
moment (emitting subscription.activated).
Lifecycle
A subscription has exactly five statuses. State changes only ever happen through
the intent-revealing transitions below; an illegal transition is rejected with
422 INVALID_OPERATION_STATE.
| Status | Meaning | Entitles? |
|---|---|---|
TRIALING | Created on a trial price; no charge yet, first invoice falls at trial end. | Yes |
ACTIVE | Billing normally from the start date (or recovered/activated). | Yes |
PAST_DUE | A renewal payment failed (B7 dunning); recoverable or auto-canceled after a grace window. | No |
PAUSED | Billing suspended by the app (SUB-7); resumable. | No |
CANCELED | Terminal — no further billing, never re-activated. | No |
Only TRIALING and ACTIVE subscriptions entitle — that is, grant their
product's benefits. PAUSED, PAST_DUE, and CANCELED do not. See
entitlements.
Cancel
POST /subscriptions/{id}/cancel cancels a subscription. The body is optional and
controls when the cancel takes effect.
atPeriodEnd | Behaviour | Webhook |
|---|---|---|
true (default, or absent body) | Keeps billing through the paid period; flips cancelAtPeriodEnd = true. Status is unchanged now. | None now. The B6 sweep performs the actual → CANCELED (and emits subscription.canceled) when the period ends. |
false | Cancels immediately: → CANCELED, stamps canceledAt. | subscription.canceled emitted now. |
Cancelling an already-CANCELED subscription is rejected with 422.
curl -X POST http://localhost:8080/api/v1/subscriptions/5567/cancel \
-H "X-Api-Key: oi_test_3kf9d2" \
-H "X-Api-Secret: sk_test_a91b7c4e2f" \
-H "Content-Type: application/json" \
-d '{ "atPeriodEnd": false }'{
"data": {
"id": 5567,
"status": "CANCELED",
"productId": 3001,
"priceId": 4012,
"customerReference": "cust_8821",
"startDate": "2026-06-30",
"currentPeriodStart": "2026-06-30",
"nextBillingDate": "2026-07-30",
"cancelAtPeriodEnd": false,
"canceledAt": "2026-06-30T12:30:00Z",
"mode": "TEST",
"createdAt": "2026-06-30T12:00:00Z",
"updatedAt": "2026-06-30T12:30:00Z"
}
}An entirely absent body cancels at the period end (the default):
curl -X POST http://localhost:8080/api/v1/subscriptions/5567/cancel \
-H "X-Api-Key: oi_test_3kf9d2" \
-H "X-Api-Secret: sk_test_a91b7c4e2f"The status stays as it was; only cancelAtPeriodEnd flips. No subscription.canceled
webhook fires until the sweep matures the cancel at the period boundary.
{
"data": {
"id": 5567,
"status": "ACTIVE",
"productId": 3001,
"priceId": 4012,
"customerReference": "cust_8821",
"startDate": "2026-06-30",
"currentPeriodStart": "2026-06-30",
"nextBillingDate": "2026-07-30",
"cancelAtPeriodEnd": true,
"mode": "TEST",
"createdAt": "2026-06-30T12:00:00Z",
"updatedAt": "2026-06-30T12:35:00Z"
}
}Pause & resume
POST /subscriptions/{id}/pause suspends billing; POST /subscriptions/{id}/resume
restarts it. The allowed transitions are narrow:
| Action | Allowed from | Result | Webhook |
|---|---|---|---|
| Pause | ACTIVE or TRIALING | → PAUSED (stamps pausedAt) | subscription.paused |
| Resume | PAUSED | → ACTIVE (clears pausedAt) | subscription.resumed |
Anything outside those transitions is rejected with 422 INVALID_OPERATION_STATE —
for example, pausing a CANCELED subscription, or resuming one that is already
ACTIVE. A paused subscription does not entitle and is not billed until resumed.
# Pause
curl -X POST http://localhost:8080/api/v1/subscriptions/5567/pause \
-H "X-Api-Key: oi_test_3kf9d2" -H "X-Api-Secret: sk_test_a91b7c4e2f"
# Resume
curl -X POST http://localhost:8080/api/v1/subscriptions/5567/resume \
-H "X-Api-Key: oi_test_3kf9d2" -H "X-Api-Secret: sk_test_a91b7c4e2f"Pausing a canceled subscription:
{
"data": null,
"meta": {
"success": false,
"message": "Only an active or trialing subscription can be paused; subscription 5567 is CANCELED",
"errorCode": "INVALID_OPERATION_STATE",
"timestamp": "2026-06-30T12:40:00Z"
},
"pagination": null
}Billing cycle & renewals
One billing cycle is interval × intervalCount of the captured price —
MONTH × 1 is monthly, MONTH × 3 is quarterly, DAY × 14 is a fortnight,
WEEK × 1 is weekly. Month and year additions are calendar-aware: they land on
the same day-of-month where possible and clamp to the last valid day otherwise
(Jan 31 + 1 month → Feb 28/29).
The B6 billing sweep runs on a schedule (hourly by default) and is the only
thing that issues subscription invoices. For each subscription whose
nextBillingDate has matured, under a row lock it:
- Skips anything no longer billable (already advanced, paused, or canceled).
- If a cancel was scheduled for the period end, matures it:
→ CANCELED, emitssubscription.canceled, issues no invoice. - Otherwise applies any active coupon, issues the
cycle invoice for the net amount, advances the calendar one cycle, and —
only when a trial just ended — activates the subscription (
TRIALING → ACTIVE,subscription.activated).
A renewal of an already-ACTIVE subscription is not a status change, so the
only webhook B6 emits for it is the cycle invoice's
invoice.issued. The subscription.renewed / payment-outcome events come from B7
when that invoice settles or goes overdue.
A fully-covering coupon (net 0) skips invoice issuance for that cycle — nothing is owed — but still advances the calendar and activates on trial end.
Dunning & recovery
When a cycle invoice goes overdue, B7 dunning moves an ACTIVE subscription
→ PAST_DUE (stamping pastDueSince) and emits subscription.past_due plus
subscription.payment_failed. Only an ACTIVE subscription is dunnable — a second
overdue cycle, or a paused/canceled one, is left as-is.
From PAST_DUE there are two outcomes:
- Recovery — the renewal finally settles (
invoice.paid). The subscription returns→ ACTIVE, clearspastDueSince, and emitssubscription.activated(recovery reuses the activated event) along withsubscription.payment_succeededandsubscription.renewed. - Exhaustion — the subscription stays past due beyond a grace window
(default 7 days from
pastDueSince). The dunning-exhaustion sweep auto-cancels it (→ CANCELED,subscription.canceled).
Apply a coupon at create
Pass a couponCode on the create request to attach a coupon
to the subscription (SUB-9). The coupon is resolved by (code, app, mode) and
must be currently redeemable:
| Condition | Result |
|---|---|
| Unknown code | 404 RESOURCE_NOT_FOUND — the whole create rolls back |
| Inactive / expired code | 400 VALIDATION_ERROR — the whole create rolls back |
| Subscription already has an active coupon | 400 VALIDATION_ERROR |
A blank or absent couponCode simply means "no coupon". A bad code rolls back the
entire create, so a subscription is never created against a coupon that cannot
apply.
The discount is not applied at create time — it is consumed per cycle by the
B6 sweep on each generated invoice. How many cycles it applies to depends on the
coupon's duration (ONCE → 1, REPEATING → durationCycles, FOREVER → unlimited). The per-cycle discount math (PERCENT vs. FIXED, clamped so the net
never goes negative) lives in the coupons guide.
List & retrieve
GET /subscriptions lists the app's subscriptions (compact rows), with optional
status and customerReference filters and pagination.
GET /subscriptions/{id} returns one subscription's full detail; an id outside this
app + mode returns 404.
curl "http://localhost:8080/api/v1/subscriptions?status=ACTIVE&customerReference=cust_8821&page=0&size=20" \
-H "X-Api-Key: oi_test_3kf9d2" -H "X-Api-Secret: sk_test_a91b7c4e2f"{
"data": [
{
"id": 5567,
"status": "ACTIVE",
"productId": 3001,
"priceId": 4012,
"startDate": "2026-06-30",
"nextBillingDate": "2026-07-30",
"cancelAtPeriodEnd": false,
"mode": "TEST",
"createdAt": "2026-06-30T12:00:00Z"
}
],
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:45:00Z" },
"pagination": { "page": 0, "size": 20, "totalElements": 1, "totalPages": 1 }
}The list rows are SubscriptionSummaryDto and carry no resolved customerReference
— fetch a single subscription for that.
Webhooks emitted
Subscriptions drive two families of webhook events. Verify the signature and treat them as the source of truth.
| Event | Fired when |
|---|---|
subscription.created | On create. |
subscription.activated | Trial ends and the first cycle bills (TRIALING → ACTIVE); also on PAST_DUE → ACTIVE recovery. |
subscription.paused | Pause (→ PAUSED). |
subscription.resumed | Resume (→ ACTIVE). |
subscription.past_due | Renewal invoice overdue (ACTIVE → PAST_DUE). |
subscription.canceled | Immediate cancel, a scheduled cancel maturing at period end, or dunning auto-cancel. |
subscription.payment_succeeded | A cycle invoice settles. |
subscription.payment_failed | A cycle invoice goes overdue. |
subscription.renewed | A settled cycle confirms the renewed period. |
subscription.trial_will_end | Trial-ending reminder ahead of the first charge. |
Delivered envelopes also carry the mode, so a consumer that handles both test and
live can branch on it.
Field reference
CreateSubscriptionRequest
| Field | Type | Required | Notes |
|---|---|---|---|
priceId | number (long) | Yes | A RECURRING, active price of an ACTIVE product. 404 if not visible, 400 if one-time, 422 if archived / product inactive. |
customerReference | string | Yes | Your own external customer id; find-or-created in this app + mode. |
customerName | string | No | Optional display name; refreshed on the customer reference when supplied. |
customerEmail | string | No | Optional contact email; refreshed when supplied. |
customerPhone | string | No | Optional contact phone; refreshed when supplied. |
startDate | string (YYYY-MM-DD) | No | When billing begins; defaults to today when absent. |
couponCode | string | No | Redemption code of a coupon to attach (SUB-9); blank/absent means none. A bad code rolls back the create. |
Send mutations with an Idempotency-Key header to make retries safe.
CancelSubscriptionRequest
| Field | Type | Required | Notes |
|---|---|---|---|
atPeriodEnd | boolean | No | true (default) keeps billing through the paid period then cancels at the boundary; false cancels immediately. A null field — or an absent body — is treated as true. |
SubscriptionDto (detail)
| Field | Type | Required | Notes |
|---|---|---|---|
id | number (long) | Always | Subscription id. |
status | enum | Always | One of TRIALING, ACTIVE, PAST_DUE, PAUSED, CANCELED. |
productId | number (long) | Always | The subscribed product. |
priceId | number (long) | Always | The recurring price captured at create; fixed for the lifetime. |
customerReference | string | Always | Your external customer id (attached by the service). |
startDate | string (date) | Always | When the subscription began. |
currentPeriodStart | string (date) | Always | Start of the current billing period; advanced each cycle. |
nextBillingDate | string (date) | Always | When the next invoice is due (trial end while trialing, else the cycle boundary). |
trialEndDate | string (date) | Conditional | End of the free trial; omitted when there is no trial. |
cancelAtPeriodEnd | boolean | Always | Whether a cancel is scheduled for the period end. |
canceledAt | string (datetime) | Conditional | When canceled; omitted while not canceled. |
mode | enum | Always | TEST or LIVE — from the credential, never the request. |
createdAt | string (datetime) | Always | Audit timestamp. |
updatedAt | string (datetime) | Always | Audit timestamp. |
Null-valued optional dates (trialEndDate, canceledAt) are omitted from the JSON.
SubscriptionSummaryDto (list row)
| Field | Type | Notes |
|---|---|---|
id | number (long) | Subscription id. |
status | enum | Lifecycle status. |
productId | number (long) | The subscribed product. |
priceId | number (long) | The captured recurring price. |
startDate | string (date) | When the subscription began. |
nextBillingDate | string (date) | When the next invoice is due. |
cancelAtPeriodEnd | boolean | Whether a cancel is scheduled for the period end. |
mode | enum | TEST or LIVE. |
createdAt | string (datetime) | Audit timestamp. |
Related
Products & prices
Define the recurring price a subscription bills on, including trialDays.
Coupons
Discount codes, durations, and the per-cycle discount math.
Invoicing
The cycle invoices the billing sweep issues.
Entitlements
Which subscription statuses grant product benefits.
Webhooks
Verify and consume the subscription.* events.
Catalog & subscriptions
How products, prices, and subscriptions fit together.
Products, prices & benefits
Build your catalogue with the App API — sellable products, immutable prices, and the benefit entries that feed subscriptions, purchases and entitlements.
Coupons
Author a per-app coupon catalogue, redeem a coupon by code when creating a subscription, and let the billing engine apply the discount per cycle.