Subscriptions
Payable manages the full subscription lifecycle: create, swap the price, change quantity, cancel at period end (grace period), cancel immediately, and resume. Creation runs through a builder; all post-creation operations run through a manager. Every operation persists the new state locally after the provider confirms it.
Version note: canonical subscription price migrations are available on the development
mainline. They are not part of the1.0.0-beta8tag or npm package. A version and tag are created only after the development line is approved for release.
Three entry points
| Goal | Entry point | Class |
|---|---|---|
| Create a provider-independent subscription | payable.canonicalSubscriptions().create(input) | CanonicalSubscriptionResource |
| Preview and execute a canonical price migration | payable.subscriptionPriceMigrations(tenantId) | SubscriptionPriceMigrationResource interface |
| Create a subscription | payable.customer(billable).newSubscription(name) | SubscriptionBuilder |
| Manage an existing one | payable.customer(billable).subscription(name) | SubscriptionManager |
The name is the local subscription name (for example 'default'). It scopes the subscription per
customer: FindSubscriptionQuery looks it up with storage.subscriptions.findByName(customerId, name).
Creating a canonical subscription
A canonical subscription is a local recurring agreement. It does not require a registered provider, provider credentials, a checkout session, or a provider price ID. The logical customer and active recurring canonical price must belong to the same tenant.
const subscription = await payable.canonicalSubscriptions(tenantId).create({
customerId: customer.id,
name: 'default',
priceId: price.id,
quantity: 3,
activation: { state: 'pending' },
collectionResponsibility: 'merchant',
source: 'api',
});
Use activation: { state: 'active', startsAt } to activate at a known instant. Payable derives the
next renewal boundary from the accepted recurring interval. A trial requires explicit startsAt and
trialEndsAt values. Payable rejects an end that is not after the start and never infers that a
payment was collected.
Creation snapshots the canonical price ID, currency, unit amount, interval, interval count, and
quantity. Archiving or replacing the price does not rewrite these accepted terms. Exact retries for
the same (tenant, customer, name) identity return the existing local subscription. Changed terms
for that identity return SUBSCRIPTION_IDENTITY_CONFLICT.
Attach a remote identity later without replacing the local ID or accepted terms:
await payable.canonicalSubscriptions(tenantId).attachProvider(subscription.id, {
provider: 'stripe',
providerSubscriptionId: 'sub_123',
});
payable.subscription(localId, tenantId).retrieve() remains local and works without a binding.
Provider mutations require one matching binding and fail with
SUBSCRIPTION_PROVIDER_BINDING_REQUIRED before resolving or calling an adapter. Multiple bindings
return SUBSCRIPTION_PROVIDER_BINDING_AMBIGUOUS unless the caller selects one with
payable.subscription(localId, tenantId, providerName).
Use await payable.subscription(localId, tenantId).capabilities() to inspect local record
capabilities independently from the operation capabilities of each configured provider binding.
Listing canonical subscriptions and stored payments
Administrative collection reads use local storage only. They do not resolve a provider or inspect provider capabilities.
const subscriptions = await payable.canonicalSubscriptions(tenantId).list({
status: 'active',
customerId,
limit: 25,
});
const payments = await payable.storedPayments(tenantId).list({
currency: 'EUR',
reference: 'transfer-',
limit: 25,
});
const subscription = await payable
.canonicalSubscriptions(tenantId)
.retrieve(subscriptionId);
const payment = await payable.storedPayments(tenantId).retrieve(paymentId);
Both lists return { items, nextCursor, hasMore }, default to 25 records, and reject limits above
100. Subscription filters support exact id, customerId, status, canonicalPriceId,
canonicalProductId, and name. canonicalProductId is the immutable product associated with
the accepted canonical price; provider identifiers and current catalogue defaults are never used
to derive it. Payment filters support exact id, customerId, status, and currency, plus
case-insensitive substring searches for reference and description.
Set includeBindings: true on canonical subscription pages to include safe provider binding
identifiers and synchronization timestamps. Existing payable.subscriptions(tenantId, options) and
payable.payments(tenantId, options) methods remain available and continue returning arrays for
compatibility.
Creating a provider-owned subscription
SubscriptionBuilder collects state fluently, then create() runs CreateSubscriptionAction.
| Method | Effect |
|---|---|
price(priceId) | Primary price. Required before create(). |
addItem(priceId, qty) | Extra line item (default qty 1). |
trialDays(days) | Trial length. |
coupon(code) | Coupon code. |
quantity(qty) | Primary line-item quantity (default 1). |
const subscription = await payable
.customer(billable)
.newSubscription('default')
.price('price_pro')
.trialDays(14)
.coupon('LAUNCH')
.addItem('price_seats', 5)
.create();
CreateSubscriptionAction:
- Requires the provider to be direct-subscription capable (
isDirectSubscriptionCapable, i.e. it implementscreateSubscription); otherwise throwsProviderCapabilityNotSupportedError. - Requires a storage driver (inherited from
SubscriptionAction.storage(),SUBSCRIPTION_STORAGE_REQUIRED). - Syncs the customer to the provider (
SyncCustomerWithProviderAction) and loads the local customer row; throwsCustomerNotFoundErrorif missing. - Calls
provider.createSubscription({ providerCustomerId, priceId, quantity, items, trialDays, coupon }, ctx)with keyIdempotencyKey.forSubscription(subscription:create:...keyed by billable + name + price). - In a storage transaction, persists the
subscriptionsrow and onesubscription_itemsrow per line item. WhenaddItem(...)was used, the items array is the primary price followed by the additional items; otherwise it is a single primary item.
The persisted subscription captures status, priceId, quantity (default 1), trialEndsAt, and
currentPeriodEnd from the provider DTO; endsAt and currentPeriodStart start as null.
sequenceDiagram
participant App
participant Builder as SubscriptionBuilder
participant Action as CreateSubscriptionAction
participant Sync as SyncCustomerWithProviderAction
participant Provider
participant Storage
App->>Builder: price().trialDays().create()
Builder->>Action: handle(CreateSubscriptionInputData)
Action->>Action: assert direct-subscription capable + storage
Action->>Sync: handle(billable)
Sync-->>Action: providerCustomerId
Action->>Storage: customers.findByBillable
Storage-->>Action: customer (or CustomerNotFoundError)
Action->>Provider: createSubscription(input, ctx)
Provider-->>Action: SubscriptionDTO
Action->>Storage: transaction(create subscription + items)
Storage-->>App: Subscription
SubscriptionBuilder can alternatively call checkout(urls) to start the subscription through a
provider-hosted page instead of creating it directly - see 09-checkout.
Managing a subscription
SubscriptionManager.get() returns the stored subscription (Subscription | null) by name via
FindSubscriptionQuery, without touching the provider. Price and quantity changes require explicit
effective-timing, proration, and payment-failure policies. Options without itemId target the only
local item. Multi-item subscriptions require the local item ID and reject ambiguous mutations.
SubscriptionManager wraps one action per operation. They all extend SubscriptionAction, which:
- requires a storage driver (
SUBSCRIPTION_STORAGE_REQUIRED), - asserts the provider’s coarse
subscriptionscapability and the requested granular operation, - resolves the local subscription by name (
SubscriptionNotFoundErrorif missing or unmapped), - builds a deterministic idempotency key per operation
(
subscription:${operation}:${providerName}:${providerSubscriptionId}[:discriminator]).
Manage by local ID
Use the local subscription ID returned by list operations for administrative workflows:
const subscription = payable.subscription(localSubscriptionId, tenantId);
const current = await subscription.retrieve();
await subscription.swap({
priceId: 'price_business',
effectiveTiming: 'immediate',
prorationPolicy: 'prorateImmediately',
paymentFailurePolicy: 'preventChange',
});
await subscription.cancel();
get() is an alias for retrieve(). Reading resolves only the local subscription and owning
customer. Mutations resolve the separate provider binding and provider subscription ID, then return
the refreshed local record. When tenancy is enabled, tenantId is required. An ID from another
tenant returns SUBSCRIPTION_NOT_FOUND.
The resource exposes the same change-preview, lifecycle, collection, cancellation, and item mutation operations as its billable-scoped counterpart. Each method accepts the same authorization context.
The local ID is the administrative identity. Treat provider subscription IDs as integration details.
The existing payable.customer(billable, provider, tenantId).subscription(name) API remains
available for billable-scoped application flows.
Identity boundaries
A provider-neutral customer is the tenant-scoped logical identity stored by Payable. One logical customer can have bindings to multiple providers without creating duplicate local customers. A subscription belongs to that logical customer and keeps three identity layers separate:
- The local subscription ID is the portable, tenant-scoped identifier used by application code.
- A tenant-scoped provider binding identifies the remote subscription handled by an adapter.
- The provider subscription-item ID identifies one remote line item when a provider requires it.
Provider identifiers are not portable across adapters. Tenant filtering applies before a local ID
is resolved, so a caller cannot use an ID from another tenant to discover whether it exists. That
lookup returns SUBSCRIPTION_NOT_FOUND.
Historical prices and explicit migration
Archiving a catalog price prevents new selection; it does not rewrite an existing subscription. Each subscriber remains attached to the historical price recorded on its subscription until an explicit successful migration changes the relevant item. Creating a replacement price or marking it as the catalog default also leaves existing subscriptions unchanged.
Canonical migration lifecycle
Use the canonical resource for new administrative migration flows. Inputs contain only tenant-scoped Payable IDs. Provider names, provider subscription IDs, and provider price IDs are resolved from the persisted bindings and do not enter the public request.
const migrations = payable.subscriptionPriceMigrations(tenantId);
const migration = await migrations.preview({
subscriptionId: canonicalSubscriptionId,
targetPriceId: canonicalTargetPriceId,
itemId: canonicalSubscriptionItemId,
timing: { effectiveTiming: 'immediate' },
prorationPolicy: 'prorateImmediately',
paymentFailurePolicy: 'preventChange',
idempotencyKey: `price-migration-preview-${canonicalSubscriptionId}-v1`,
});
const applied = await migrations.approve(migration.id, {
idempotencyKey: `price-migration-approve-${migration.id}-v1`,
});
SubscriptionPriceMigrationResource is an exported TypeScript interface. Its implementation is
not constructible from the package root; obtain it through the tenant-scoped Payable accessor so
storage, tenancy, provider, clock, and idempotency dependencies remain correctly bound.
preview() stores immutable source price, target price, item, adjustment, renewal, policy, and
timing snapshots. approve() operates on that migration ID; it does not silently recalculate the
preview. Immediate approval executes the provider mutation. nextRenewal is also submitted to the
provider during approval, but the migration becomes pending_renewal while the local item keeps its
historical price. At or after the immutable renewal date, a trusted host calls settle() to project
the approved target locally without another provider call. Only an explicitly dated scheduled
approval moves the resource to scheduled without an early provider call.
The public states and transitions are:
previewed -> scheduled | executing | cancelled
scheduled -> executing | cancelled
executing -> applied | pending_renewal | failed | reconciliation_required
pending_renewal -> applied (explicit boundary settlement only)
failed -> executing | cancelled
reconciliation_required -> applied | pending_renewal | failed (explicit resolution only)
applied and cancelled are terminal. reconciliation_required is terminal for automatic work,
and pending_renewal stays fenced until explicit settlement. A retry is available only from
failed. Every lifecycle operation takes a separate durable idempotency key.
await migrations.settle(migration.id, {
idempotencyKey: `price-migration-settle-${migration.id}-v1`,
});
The host decides when to invoke settlement; a webhook may advance currentPeriodEnd, but it does
not independently infer or apply the canonical price projection.
Scheduled migration
An explicit schedule requires a real Date in the core API. HTTP and MCP adapters accept the same
instant as an RFC 3339 string.
const migration = await migrations.preview({
subscriptionId: canonicalSubscriptionId,
targetPriceId: canonicalTargetPriceId,
timing: {
effectiveTiming: 'scheduled',
effectiveAt: new Date('2026-10-01T09:00:00.000Z'),
},
prorationPolicy: 'prorateImmediately',
paymentFailurePolicy: 'preventChange',
idempotencyKey: `price-migration-scheduled-preview-${canonicalSubscriptionId}-v1`,
});
await migrations.approve(migration.id, {
idempotencyKey: `price-migration-scheduled-approve-${migration.id}-v1`,
});
A framework-neutral worker pages due records with migrations.due({ dueBefore, limit, cursor }) and
calls migrations.execute(id, { idempotencyKey }). Payable supplies the due-page and execution
contract, but it does not own a scheduler, queue, worker process, or host batch. The worker must use a
stable operation key for each delivery.
Eligibility and immutable approval
Before calling a provider, Payable verifies that the canonical customer, subscription, item, source price, target price, provider binding, and price bindings all belong to the tenant and still match the preview request. A different target must be active, and every target must be recurring; source and target must belong to the same canonical product, the subscription must be active or trialing, and the selected timing and policies must be supported. Currency or billing-period changes fail unless the provider capability explicitly supports them. No display value or provider identifier substitutes for a canonical ID.
Only one active migration may exist for a subscription. Preview expiry, catalog drift, renewal-boundary drift, item changes, or binding changes return a stable stale or state-conflict error before another provider mutation begins.
Ambiguous reconciliation
Payable changes the canonical subscription only after confirmed provider success. A thrown provider
error, timeout, malformed provider outcome, or local projection failure after the remote mutation may
have side effects. Payable records reconciliation_required, retains the execution fence, and returns
SUBSCRIPTION_MIGRATION_RECONCILIATION_REQUIRED. Do not call retry() or create an automatic retry
loop. Compare the provider state with the immutable migration, repair the canonical projection through
an explicit operator process, and record the result before attempting a new migration. The trusted
host process resolves ownership through the TypeScript resource; there is no generic remote route:
await migrations.resolve(migration.id, {
outcome: providerStateShowsApprovedChange ? 'applied' : 'not_applied',
evidenceReference: operatorAuditReference,
idempotencyKey: `price-migration-resolve-${migration.id}-v1`,
});
Resolution never calls the provider. Immediate applied atomically projects the immutable approved
change and releases the retained fence; next-renewal applied moves to pending_renewal and keeps
the fence until settle(). not_applied moves to retryable failed and releases the fence. Every
resolution emits audit/outbox records. Repeating the same outcome and evidence reference replays; a
conflicting resolution is rejected.
Only a provider outcome that explicitly reports not_applied with
sideEffects: 'definitively_none' can move execution to retryable failed.
Stable migration errors include:
SUBSCRIPTION_MIGRATION_NOT_FOUND;SUBSCRIPTION_MIGRATION_PREVIEW_STALE;SUBSCRIPTION_MIGRATION_TARGET_INELIGIBLE;PROVIDER_CAPABILITY_NOT_SUPPORTED;SUBSCRIPTION_MIGRATION_STATE_CONFLICT;SUBSCRIPTION_MIGRATION_RECONCILIATION_REQUIRED;SUBSCRIPTION_MIGRATION_PROVIDER_NOT_APPLIED.
Legacy API compatibility
Use previewChange() and applyChange() when the subscriber must approve the amount, effective
date, or proration before migration. A direct swap() is also explicit, but should be reserved for
flows where a separate approval preview is unnecessary. In both cases, local state changes only
after the provider confirms the mutation. SUBSCRIPTION_CHANGE_PREVIEW_STALE protects the preview
flow when the current item set changes between preview and apply.
The existing subscription(...).previewChange() and applyChange() signatures remain available.
For canonical subscriptions they project the canonical migration lifecycle back into the established
preview DTO and token contract. Stored preview tokens created before migration step 021 remain
readable. Historical provider-native subscriptions without canonical catalog snapshots keep their
legacy path because Payable does not invent canonical product, price, or amount data.
A next-renewal migration keeps the historical price in the current local items after the provider
confirms its pending-renewal instruction. At or after the immutable renewal boundary, the trusted
host calls settle() to update the effective item without another provider call. Webhooks may update
provider lifecycle dates, but they do not infer this price projection independently.
Preview and apply a change
Use the two-step flow when a customer must approve the monetary result before a change is applied. The preview token is tenant-scoped, expires after 15 minutes, and is bound to the exact items, policies, provider, subscription, and calculation timestamp that were previewed.
const preview = await manager.previewChange({
priceId: 'price_business',
effectiveTiming: 'immediate',
prorationPolicy: 'prorateImmediately',
paymentFailurePolicy: 'preventChange',
idempotencyKey: 'preview-order-42',
});
await manager.applyChange({
previewToken: preview.previewToken,
idempotencyKey: 'apply-order-42',
});
Both operations require an idempotency store. The provider is called before local state is mutated. If apply fails at the provider, Payable keeps the local subscription unchanged. A token cannot be used for a different tenant or changed request.
Before the first apply attempt, Payable rejects the token with
SUBSCRIPTION_CHANGE_PREVIEW_STALE if the current local item set no longer matches the preview.
Immediate changes update the local items after provider success. Changes scheduled for the next
renewal keep the current local items. Provider integrations that expose effective item data can
update them later through webhook reconciliation. The apply audit entry records the proposed items
without presenting them as current state.
Swap
SwapSubscriptionAction resolves one tenant-scoped local item, calls the provider with its mapped
identity and the complete local item list, then updates that exact local item. Stripe requires a
stable provider item mapping. Paddle uses the complete list so non-targeted items remain attached.
Update quantity
UpdateSubscriptionQuantityAction resolves and updates the same explicit item boundary as swap.
The idempotency key includes the quantity as a discriminator, so each distinct quantity gets its own
key. Existing rows with null provider mappings are backfilled by unambiguous provider webhook item
snapshots; Stripe rejects a mutation until that stable mapping exists.
Cancel (grace period) - subscription(name).cancel()
CancelSubscriptionAction calls provider.cancelSubscription({ providerSubscriptionId, immediately: false }),
then sets the local status and endsAt = dto.currentPeriodEnd. The subscription stays usable until
that date - this is the grace period. onGracePeriod(subscription, now) returns true while
endsAt is in the future.
Cancel now - subscription(name).cancelNow()
CancelSubscriptionNowAction calls cancelSubscription({ ..., immediately: true }), then sets
status and endsAt = clock.now(). There is no grace period; the subscription ends immediately. The
canceled-now subscription has status: 'canceled' and endsAt equal to the current clock time.
Resume - subscription(name).resume()
ResumeSubscriptionAction calls provider.resumeSubscription({ providerSubscriptionId }), then sets
status and clears endsAt = null. Resuming is meaningful for a subscription that was canceled with
grace (still within its period); clearing endsAt takes it back off the grace period. Resuming a
grace-period subscription sets endsAt back to null.
const manager = payable.customer(billable).subscription('default');
const current = await manager.get(); // Subscription | null, no provider call
await manager.swap({
priceId: 'price_business',
effectiveTiming: 'immediate',
prorationPolicy: 'prorateImmediately',
paymentFailurePolicy: 'preventChange',
});
await manager.updateQuantity({
quantity: 3,
effectiveTiming: 'immediate',
prorationPolicy: 'prorateImmediately',
paymentFailurePolicy: 'preventChange',
});
await manager.cancel(); // ends at period end (grace period)
await manager.resume(); // clears endsAt
await manager.cancelNow(); // ends immediately
Migration from implicit policies
The old shorthand calls swap(priceId) and updateQuantity(quantity) no longer select provider
policies implicitly. They fail with SUBSCRIPTION_CHANGE_POLICY_REQUIRED. Replace them with the
options forms shown above. This makes billing timing and payment-failure behavior reviewable in code
and prevents a provider default from changing application behavior.
Older integrations may expose only the coarse subscriptions capability. Keep that check for
compatibility, but use subscriptionOperationCapabilities(providerName) to decide which controls and
policies an application can offer. The granular descriptor is the authoritative contract for each
operation.
Legacy subscription-item rows may exist where the provider item ID is null. They remain readable, but an exact mutation that requires a stable remote item mapping must fail until an unambiguous provider webhook snapshot backfills the identifier. Payable never guesses the first provider item.
Provider references used by the built-in mappings:
- Stripe invoice preview and subscription update
- Paddle subscription preview and subscription update
- Paddle proration
- Revolut Merchant API
Revolut exposes a scheduled plan change but no monetary preview endpoint. Its preview is structural:
the immediate adjustment is zero for a next-renewal change, while unknown future amounts and
currencies are returned as null with an explicit provider limitation.
Pause and resume policies
resume() only reverses a pending period-end cancellation. Pausing a subscription lifecycle and
pausing payment collection are different operations with separate methods and capability checks:
await manager.pauseSubscription({
effectiveTiming: 'nextRenewal',
resumeAt: new Date('2027-01-15T00:00:00Z'),
resumeBillingPolicy: 'startNewBillingPeriod',
});
await manager.resumePausedSubscription({
effectiveTiming: 'immediate',
billingPolicy: 'continueExistingBillingPeriod',
});
await manager.pausePaymentCollection({
behavior: 'keepAsDraft',
resumesAt: null,
});
await manager.resumePaymentCollection();
Dates must be valid future Date values. resumeAt: null and resumesAt: null mean an indefinite
pause. Provider support is asserted against the complete policy before any provider request. A
provider that supports only one pause model cannot accidentally receive the other.
Paddle exposes lifecycle pause and resume. Stripe exposes payment-collection pause and resume; the
Stripe subscription lifecycle status does not become paused. SISP and Revolut currently expose
neither. See the provider matrix for the exact supported timings, behaviors, and billing-period
policies.
Scheduled lifecycle metadata is stored on the subscription as
scheduledChangeAction, scheduledChangeEffectiveAt, scheduledResumeAt, and
resumeBillingPolicy. Payment-collection metadata is stored separately as
paymentCollectionPauseBehavior and paymentCollectionResumesAt. Provider webhooks reconcile these
fields. For providers that support it, cancelScheduledSubscriptionChange() removes the current
scheduled lifecycle change before a replacement policy is submitted:
await manager.cancelScheduledSubscriptionChange();
Cancel vs cancel-now vs resume
| Operation | Provider call | Local endsAt | Customer access |
|---|---|---|---|
cancel() | immediately: false | currentPeriodEnd | Retained until period end (grace) |
cancelNow() | immediately: true | clock.now() | Ends immediately |
resume() | resumeSubscription | null | Restored |
Availability is provider-specific. Inspect it before presenting an operation in an application:
const operations = payable
.providers()
.subscriptionOperationCapabilities('paddle');
if (operations.cancel.atPeriodEnd) {
await manager.cancel();
}
See the provider subscription operation matrix for the built-in adapters and the policy values returned for price and quantity changes.
State helpers
Pure predicates over a stored subscription:
onTrial(subscription, now)-trialEndsAtin the future.onGracePeriod(subscription, now)-endsAtin the future.subscriptionEnded(subscription, now)-endsAtin the past or now.
For the underlying status transitions (trialing, active, canceled, …) see
07-state-machines.
Policies
Every subscription operation enforces a policy through assertAuthorized, gated when
deps.authorizationEnabled is true (a no-op otherwise):
| Operation | Policy |
|---|---|
create | CanCreateSubscriptionPolicy (asserted in SubscriptionBuilder.create()) |
swap, updateQuantity | CanUpdateSubscriptionPolicy |
cancel, cancelNow | CanCancelSubscriptionPolicy |
resume, resumePausedSubscription, resumePaymentCollection | CanResumeSubscriptionPolicy |
pauseSubscription, pausePaymentCollection, cancelScheduledSubscriptionChange | CanUpdateSubscriptionPolicy |
Each policy authorizes against an AuthorizationContext (allowed === true and a non-empty
actorId), supplied via the operation’s authorization argument. When authorization is disabled the
assertion is skipped, so integrators that do not opt in see no behavior change.
Edge cases
- No storage driver. Any management operation throws
PayableError(...requires a storage driver). - Provider lacks
subscriptionscapability.assertProviderCapabilitythrowsProviderCapabilityNotSupportedError. - Provider lacks the requested granular operation. Built-in providers throw
ProviderCapabilityNotSupportedErrorbefore customer synchronization or provider calls. The error context contains a stable capability such assubscriptions.create.directorsubscriptions.cancel.at-period-end. - Pause policy is invalid or unsupported. Invalid dates fail with a stable policy error. A
provider-policy mismatch fails with
ProviderCapabilityNotSupportedErrorbefore local mutation or a provider request. - Provider request fails. The stored lifecycle metadata and audit log remain unchanged. A later provider webhook is still authoritative and reconciles any provider-side state that was applied.
- Provider not direct-subscription capable on create.
CreateSubscriptionActionthrows before any provider call. - Unknown subscription name.
resolve()throwsSubscriptionNotFoundError. - Customer row missing on create.
CustomerNotFoundErrorafter sync (defensive; sync normally creates the row). - Subscription-mode checkout vs direct create.
newSubscription(...).checkout(urls)forwards only the primary price; multi-item plans needcreate()withaddItem(...).