Skip to Content
Devnet open · mainnet by application — these docs ship with the pre-alpha release.
IntegrationsSubscriptions

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 Invoice that 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) or AT_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 end

Status values:

StatusMeaning
INCOMPLETECreated; first invoice not yet paid. Can be activated by paying the invoice or auto-canceled by an INCOMPLETE timeout.
TRIALINGInside a configured trial window. No charges yet.
ACTIVEPaying customer; invoices renewing on schedule.
PAST_DUERenewal invoice failed; dunning ladder retrying.
UNPAIDDunning ladder exhausted; awaiting merchant intervention or auto-cancel.
CANCELEDTerminal. 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_zzz

Cursor-paginated newest-first. Filter by status, customerId.

Counts (for dashboards)

GET /v1/subscriptions/count

Returns:

{ "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:

AttemptDelay before next
1 → 21 day
2 → 33 days
3 → 47 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:

EventFires when
customer.subscription.createdNew sub created via POST /v1/subscriptions.
customer.subscription.updatedStatus transition (TRIALING → ACTIVE, ACTIVE → PAST_DUE, etc.) OR item/quantity change OR cancelAtPeriodEnd flipped.
customer.subscription.deletedSub 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:

  1. 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.
  2. 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'); }