OI Payments Docs
Guides

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:

ConditionResult
Price not visible in this app + mode404 RESOURCE_NOT_FOUND
Price is ONE_TIME (not recurring)400 VALIDATION_ERROR
Owning product is not ACTIVE422 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.

PriceInitial statustrialEndDatenextBillingDate
trialDays > 0TRIALINGstartDate + trialDaystrialEndDate (first invoice at trial end)
no trialACTIVEomitted (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.

StatusMeaningEntitles?
TRIALINGCreated on a trial price; no charge yet, first invoice falls at trial end.Yes
ACTIVEBilling normally from the start date (or recovered/activated).Yes
PAST_DUEA renewal payment failed (B7 dunning); recoverable or auto-canceled after a grace window.No
PAUSEDBilling suspended by the app (SUB-7); resumable.No
CANCELEDTerminal — 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.

atPeriodEndBehaviourWebhook
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.
falseCancels 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:

ActionAllowed fromResultWebhook
PauseACTIVE or TRIALING→ PAUSED (stamps pausedAt)subscription.paused
ResumePAUSED→ 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:

  1. Skips anything no longer billable (already advanced, paused, or canceled).
  2. If a cancel was scheduled for the period end, matures it: → CANCELED, emits subscription.canceled, issues no invoice.
  3. 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, clears pastDueSince, and emits subscription.activated (recovery reuses the activated event) along with subscription.payment_succeeded and subscription.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:

ConditionResult
Unknown code404 RESOURCE_NOT_FOUNDthe whole create rolls back
Inactive / expired code400 VALIDATION_ERRORthe whole create rolls back
Subscription already has an active coupon400 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.

EventFired when
subscription.createdOn create.
subscription.activatedTrial ends and the first cycle bills (TRIALING → ACTIVE); also on PAST_DUE → ACTIVE recovery.
subscription.pausedPause (→ PAUSED).
subscription.resumedResume (→ ACTIVE).
subscription.past_dueRenewal invoice overdue (ACTIVE → PAST_DUE).
subscription.canceledImmediate cancel, a scheduled cancel maturing at period end, or dunning auto-cancel.
subscription.payment_succeededA cycle invoice settles.
subscription.payment_failedA cycle invoice goes overdue.
subscription.renewedA settled cycle confirms the renewed period.
subscription.trial_will_endTrial-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

FieldTypeRequiredNotes
priceIdnumber (long)YesA RECURRING, active price of an ACTIVE product. 404 if not visible, 400 if one-time, 422 if archived / product inactive.
customerReferencestringYesYour own external customer id; find-or-created in this app + mode.
customerNamestringNoOptional display name; refreshed on the customer reference when supplied.
customerEmailstringNoOptional contact email; refreshed when supplied.
customerPhonestringNoOptional contact phone; refreshed when supplied.
startDatestring (YYYY-MM-DD)NoWhen billing begins; defaults to today when absent.
couponCodestringNoRedemption 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

FieldTypeRequiredNotes
atPeriodEndbooleanNotrue (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)

FieldTypeRequiredNotes
idnumber (long)AlwaysSubscription id.
statusenumAlwaysOne of TRIALING, ACTIVE, PAST_DUE, PAUSED, CANCELED.
productIdnumber (long)AlwaysThe subscribed product.
priceIdnumber (long)AlwaysThe recurring price captured at create; fixed for the lifetime.
customerReferencestringAlwaysYour external customer id (attached by the service).
startDatestring (date)AlwaysWhen the subscription began.
currentPeriodStartstring (date)AlwaysStart of the current billing period; advanced each cycle.
nextBillingDatestring (date)AlwaysWhen the next invoice is due (trial end while trialing, else the cycle boundary).
trialEndDatestring (date)ConditionalEnd of the free trial; omitted when there is no trial.
cancelAtPeriodEndbooleanAlwaysWhether a cancel is scheduled for the period end.
canceledAtstring (datetime)ConditionalWhen canceled; omitted while not canceled.
modeenumAlwaysTEST or LIVE — from the credential, never the request.
createdAtstring (datetime)AlwaysAudit timestamp.
updatedAtstring (datetime)AlwaysAudit timestamp.

Null-valued optional dates (trialEndDate, canceledAt) are omitted from the JSON.

SubscriptionSummaryDto (list row)

FieldTypeNotes
idnumber (long)Subscription id.
statusenumLifecycle status.
productIdnumber (long)The subscribed product.
priceIdnumber (long)The captured recurring price.
startDatestring (date)When the subscription began.
nextBillingDatestring (date)When the next invoice is due.
cancelAtPeriodEndbooleanWhether a cancel is scheduled for the period end.
modeenumTEST or LIVE.
createdAtstring (datetime)Audit timestamp.

On this page