Subscriptions integration guide
Audience. Merchants integrating recurring billing — SaaS, subscription commerce, periodic dues. Covers the 6-state lifecycle, trials, proration, dunning, the
customer.subscription.*event family, and a full create-to-cancel flow.
TL;DR
NinjaPay subscriptions use a Stripe-compatible resource graph: (Customer, Product, Price, Subscription, SubscriptionItem, Invoice), the standard TRIALING → ACTIVE → PAST_DUE → UNPAID → CANCELED lifecycle, industry-reference proration math, and equivalent trial semantics. Differences:
- Settlement is Solana-native. Each renewal generates an
Invoicethat must be paid via a PaymentIntent (the customer signs from their wallet at checkout time, or pays automatically if a stored payment method flow lands). - Two cancel modes:
IMMEDIATE(status flips to CANCELED now) orAT_PERIOD_END(cancellation scheduled; current period’s access preserved). - Dunning is configurable. The default ladder retries failed invoices with exponential backoff; missing all retries flips the sub to
UNPAID, and a configurable grace period before auto-cancel.
State machine
INCOMPLETE (created; first invoice not paid yet)
│
├── (first invoice paid) ──▶ ACTIVE (or TRIALING if trial configured)
├── (trial elapses) ──▶ ACTIVE
│
▼
ACTIVE / TRIALING
│
├── (renewal invoice paid) ──▶ ACTIVE (loop)
├── (renewal invoice fails to pay) ──▶ PAST_DUE
│ │
│ ├── (retry succeeds) ──▶ ACTIVE
│ └── (retries exhausted) ──▶ UNPAID
│ │
│ └── (grace exceeded) ──▶ CANCELED (auto)
│
├── cancel(mode='IMMEDIATE') ──▶ CANCELED
└── cancel(mode='AT_PERIOD_END') ──▶ ACTIVE (scheduled) ──▶ CANCELED at period endStatus values:
| Status | Meaning |
|---|---|
INCOMPLETE | Created; first invoice not yet paid. Can be activated by paying the invoice or auto-canceled by an INCOMPLETE timeout. |
TRIALING | Inside a configured trial window. No charges yet. |
ACTIVE | Paying customer; invoices renewing on schedule. |
PAST_DUE | Renewal invoice failed; dunning ladder retrying. |
UNPAID | Dunning ladder exhausted; awaiting merchant intervention or auto-cancel. |
CANCELED | Terminal. Either IMMEDIATE cancel, AT_PERIOD_END reached, or auto-cancel from UNPAID. |
Resource shapes
Subscription
{
"id": "sub_2c4e8f1a9b3d456",
"customerId": "cust_zzz",
"status": "ACTIVE",
"currentPeriodStart": "2026-05-01T00:00:00.000Z",
"currentPeriodEnd": "2026-06-01T00:00:00.000Z",
"trialEnd": null,
"cancelAtPeriodEnd": false,
"canceledAt": null,
"items": [
{
"id": "si_yyy",
"priceId": "price_xxx",
"quantity": 2,
"unitAmount": "10.000000",
"currency": "USDC"
}
],
"createdAt": "2026-04-15T00:00:00.000Z",
"updatedAt": "2026-05-01T00:00:00.000Z"
}Price (input)
Prices live on Products. Recurring prices specify the billing period:
{
"id": "price_xxx",
"productId": "prod_aaa",
"currency": "USDC",
"unitAmount": "10.000000",
"type": "RECURRING",
"billingPeriod": "MONTH",
"intervalCount": 1
}billingPeriod ∈ {DAY, WEEK, MONTH, YEAR}. intervalCount is the multiplier — e.g. MONTH × 3 = quarterly billing.
Endpoints
Create
POST /v1/subscriptions
Authorization: Bearer <JWT>
Content-Type: application/json
Idempotency-Key: sub_for_cust_zzz_v1
{
"customerId": "cust_zzz",
"items": [
{ "priceId": "price_pro_monthly", "quantity": 2 }
]
}Returns the new Subscription in INCOMPLETE state. The first invoice is auto-generated; once it’s paid the sub transitions to ACTIVE (or TRIALING if the price configures a trial). The merchant role gate requires OWNER / ADMIN / FINANCE.
List + filter
GET /v1/subscriptions
GET /v1/subscriptions?status=ACTIVE
GET /v1/subscriptions?status=PAST_DUE&status=UNPAID # OR semantics
GET /v1/subscriptions?customerId=cust_zzzCursor-paginated newest-first. Filter by status, customerId.
Counts (for dashboards)
GET /v1/subscriptions/countReturns:
{
"active": 84,
"trialing": 7,
"past_due": 3,
"unpaid": 1,
"incomplete": 0,
"canceled": 12,
"total": 107
}The dashboard derives “subscriptions in dunning” as past_due + unpaid — past_due is recoverable by the dunning worker; unpaid is the dangerous state where retries have exhausted (per the dashboard dunning banner shipped at γ.30).
Update (PATCH)
PATCH /v1/subscriptions/:id
{ "items": [ { "priceId": "price_pro_monthly", "quantity": 3 } ] }Quantity changes proration automatically — the next invoice surfaces a credit/debit line for the unused/extra portion of the current period.
Cancel
POST /v1/subscriptions/:id/cancel
{ "mode": "IMMEDIATE" } # or "AT_PERIOD_END"IMMEDIATE: status flips to CANCELED synchronously. The customer loses access right now.
AT_PERIOD_END: status stays ACTIVE (with cancelAtPeriodEnd: true); a worker fires the actual transition at currentPeriodEnd. The customer keeps access through the period they’ve already paid for.
Trials
Trials are configured per-price (not per-subscription) — when a customer starts a sub against a trial-enabled price, the sub goes to TRIALING until trialEnd, then auto-transitions to ACTIVE and generates the first paid invoice.
Proration
Mid-period quantity or price changes prorate against the unused portion of the current period:
// Customer upgrades from 2 → 5 seats halfway through the month.
await client.subscriptions.update(sub.id, {
items: [{ priceId: 'price_pro_monthly', quantity: 5 }],
});
// Next invoice carries:
// - the new quantity at full price for the upcoming period
// - a debit for the additional 3 seats × half-period unused
// (Or a credit if the customer downgraded.)Proration math is implemented in packages/billing/src/proration.ts with property-based tests over the rounding rules (per ADR-0033 §6 — proration is one of the four mutation-tested critical paths).
Dunning
When a renewal invoice fails (insufficient wallet balance, failed PaymentIntent, etc), the sub transitions to PAST_DUE. The dunning worker retries on a configurable schedule:
| Attempt | Delay before next |
|---|---|
| 1 → 2 | 1 day |
| 2 → 3 | 3 days |
| 3 → 4 | 7 days |
| 4 (final) | UNPAID — no further auto-retry |
After UNPAID, a configurable grace period (default 14 days) elapses before the sub auto-cancels. The merchant’s customer.subscription.updated webhook fires on every status transition, including the dunning escalations.
Industry-standard cadence. The defaults can be tuned via merchant settings (planned for a future slice; today they’re org-default constants).
Webhook events
Standard customer.subscription.* namespace:
| Event | Fires when |
|---|---|
customer.subscription.created | New sub created via POST /v1/subscriptions. |
customer.subscription.updated | Status transition (TRIALING → ACTIVE, ACTIVE → PAST_DUE, etc.) OR item/quantity change OR cancelAtPeriodEnd flipped. |
customer.subscription.deleted | Sub reaches CANCELED (immediate, at-period-end, or dunning auto-cancel). |
For invoice-side signals (the actual money moving), subscribe to the invoice.* family — see the webhook integration guide. Most subscription integrations subscribe to:
['customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'invoice.paid',
'invoice.finalized',
'invoice.marked_uncollectible']That set covers every actionable transition: new customer onboarded, status changed, sub canceled, payment succeeded, payment failed (via finalized → no paid event), or written off.
Sample flow — monthly SaaS
import { NinjaPayClient } from '@ninjapay/sdk';
const client = new NinjaPayClient({ apiKey: process.env.NINJAPAY_API_KEY });
// 1. Customer signs up. Standard customer create.
const customer = await client.customers.create({
email: 'alice@example.com',
name: 'Alice Example',
});
// 2. Start the subscription. INCOMPLETE → first invoice fires.
const sub = await client.subscriptions.create({
customerId: customer.id,
items: [{ priceId: 'price_pro_monthly_2900', quantity: 1 }],
});
// First invoice auto-generated. Its hosted-checkout URL is in the
// invoice's PaymentIntent — surface it to the customer.
// 3. (Mid-period) Customer adds 4 more seats.
await client.subscriptions.update(sub.id, {
items: [{ priceId: 'price_pro_monthly_2900', quantity: 5 }],
});
// Next invoice prorates the additional seats × unused portion.
// 4. Customer wants to cancel at the end of their billing month.
await client.subscriptions.cancel(sub.id, { mode: 'AT_PERIOD_END' });
// Sub stays ACTIVE through currentPeriodEnd; access preserved.
// customer.subscription.updated fires now (cancelAtPeriodEnd=true);
// customer.subscription.deleted fires when the period actually
// elapses.Operational urgency
The dashboard surfaces two signals:
- The home action-items card (Phase 6.8.γ.20) shows “X subscriptions past due” + the UNPAID subset. Deep-links to
/dashboard/subscriptions?status=PAST_DUE. - The DunningBanner on /dashboard/subscriptions (Phase 6.8.γ.30) escalates the tone — warning when only PAST_DUE rows exist; destructive when any UNPAID exists. UNPAID means “auto-cancel imminent unless you reach out to the customer or extend the grace window manually.”
Surface the same signal in any custom integration via client.subscriptions.count():
const counts = await client.subscriptions.count();
if (counts.unpaid > 0) {
ops.notifyChurnRisk(counts.unpaid);
}
if (counts.pastDue + counts.unpaid > 5) {
ops.escalate('high-dunning-volume');
}Related
packages/billing-runtime/src/subscription-service.ts— service-layer state-machine + transition guards.packages/billing/src/proration.ts— proration math, property-tested.- Webhook integration guide — full event taxonomy including
customer.subscription.*andinvoice.*. - Refunds integration guide — for refunding a paid invoice (subscriptions don’t have a separate refund flow — the invoice’s PaymentIntent is the refundable artifact).
- Migration guide — direct equivalent table for integrators porting an existing Subscriptions integration.