Advanced Subscription Operations
Use the local subscription resource for administrative billing flows. It resolves the owning customer and provider binding from storage, keeps the tenant boundary intact, and avoids exposing a provider subscription ID to application code.
Prerequisites
- A storage driver and, for preview/apply, an idempotency store
- A tenant-scoped canonical subscription, item, and target price ID returned by Payable
- A provider whose granular operation descriptor supports the requested policies
const providerName = 'stripe';
const operations = payable
.providers()
.subscriptionOperationCapabilities(providerName);
const subscription = payable.subscription(localSubscriptionId, tenantId);
const migrations = payable.subscriptionPriceMigrations(tenantId);
The examples below use the descriptor before presenting an action. The resource repeats the same validation before any provider request, so a stale or bypassed user interface cannot force an unsupported operation.
Canonical migration lifecycle
New price migration flows use subscriptionPriceMigrations(tenantId). The resource accepts
canonical Payable IDs, persists the financial preview, and uses the returned migration ID for every
later operation. It does not accept a provider subscription or price identifier.
Immediate upgrade
Preview an immediate upgrade when the subscriber must approve the adjustment before it is applied:
if (
operations.changePrice.preview &&
operations.changePrice.effectiveTimings.includes('immediate') &&
operations.changePrice.prorationPolicies.includes('prorateImmediately') &&
operations.changePrice.paymentFailurePolicies.includes('preventChange')
) {
const preview = await migrations.preview({
subscriptionId: canonicalSubscriptionId,
targetPriceId: canonicalBusinessPriceId,
itemId: canonicalSubscriptionItemId,
timing: { effectiveTiming: 'immediate' },
prorationPolicy: 'prorateImmediately',
paymentFailurePolicy: 'preventChange',
idempotencyKey: `preview-upgrade-${canonicalSubscriptionId}-v1`,
});
await presentPreviewForApproval(preview);
await migrations.approve(preview.id, {
idempotencyKey: `approve-upgrade-${preview.id}-v1`,
});
}
The migration binds the tenant, subscription, current items, proposed items, policies, provider
binding, renewal boundary, and calculation time. It expires after 15 minutes. Approving a stale
preview fails with SUBSCRIPTION_MIGRATION_PREVIEW_STALE.
Downgrade at the next renewal
A next-renewal downgrade schedules the replacement without presenting it as the current price:
if (
operations.changePrice.preview &&
operations.changePrice.effectiveTimings.includes('nextRenewal') &&
operations.changePrice.prorationPolicies.includes('none') &&
operations.changePrice.paymentFailurePolicies.includes('applyChange')
) {
const preview = await migrations.preview({
subscriptionId: canonicalSubscriptionId,
targetPriceId: canonicalStarterPriceId,
itemId: canonicalSubscriptionItemId,
timing: { effectiveTiming: 'nextRenewal' },
prorationPolicy: 'none',
paymentFailurePolicy: 'applyChange',
idempotencyKey: `preview-downgrade-${canonicalSubscriptionId}-v1`,
});
await presentPreviewForApproval(preview);
await migrations.approve(preview.id, {
idempotencyKey: `approve-downgrade-${preview.id}-v1`,
});
}
Approval submits the next-renewal instruction to the provider and records the accepted migration as
pending_renewal. The canonical subscription item still carries its historical local price and the
active fence remains held. At or after preview.currentRenewalDate, a trusted host settles it without
another provider call:
await migrations.settle(preview.id, {
idempotencyKey: `settle-downgrade-${preview.id}-v1`,
});
A webhook may advance lifecycle dates, but it never independently infers this local price change.
Scheduled migration
scheduled always carries an explicit instant. The core API uses a Date; HTTP and MCP inputs use
the equivalent RFC 3339 string.
const preview = await migrations.preview({
subscriptionId: canonicalSubscriptionId,
targetPriceId: canonicalBusinessPriceId,
itemId: canonicalSubscriptionItemId,
timing: {
effectiveTiming: 'scheduled',
effectiveAt: new Date('2026-10-01T09:00:00.000Z'),
},
prorationPolicy: 'prorateImmediately',
paymentFailurePolicy: 'preventChange',
idempotencyKey: `preview-scheduled-${canonicalSubscriptionId}-v1`,
});
await migrations.approve(preview.id, {
idempotencyKey: `approve-scheduled-${preview.id}-v1`,
});
Approval records scheduled and makes no provider call. A worker reads due pages from
migrations.due(...) and invokes migrations.execute(...) with its own durable idempotency key.
Failed payment behavior
The payment-failure policy is explicit and provider-specific:
preventChangeasks the provider to preserve the existing subscription when the immediate payment cannot be completed.applyChangeallows the provider to apply the subscription change even when collection needs separate recovery.
Only offer a policy listed in operations.changePrice.paymentFailurePolicies. If a provider returns
a confirmed no-side-effect failure, Payable does not mutate the local subscription:
try {
await migrations.approve(canonicalMigrationId, {
idempotencyKey: `approve-change-${canonicalMigrationId}-v1`,
});
} catch (error) {
const unchanged = await subscription.retrieve();
await recordBillingFailure(error, unchanged);
}
A provider webhook remains authoritative if the remote system applied a state transition before its request failed. Process reconciliation before deciding whether to retry.
Ambiguous reconciliation
A timeout, thrown provider error, malformed outcome, or local persistence failure after a provider
mutation moves the migration to reconciliation_required. The retained execution token prevents an
automatic second provider call. Read the provider state, compare it with the immutable canonical
migration, and complete an explicit operator reconciliation. Do not call retry() for this state.
A host crash after durable claim acquisition can leave the migration in executing before any
provider call. The same trusted TypeScript workflow resolves that retained owner: unknown records
the observation and moves it to reconciliation_required, while conclusive not_applied or
applied completes it without making a provider request.
After a trusted host workflow establishes the remote outcome, resolve it without another provider call:
await migrations.resolve(canonicalMigrationId, {
outcome: 'applied',
evidenceReference: 'operator-case-2026-08-25-0042',
idempotencyKey: `resolve-change-${canonicalMigrationId}-v1`,
});
Use not_applied only when the host has confirmed no remote application. It transitions to
retryable failed and releases the durable claim. Immediate applied also releases it; a
next-renewal applied becomes pending_renewal and retains it until settle(). Exact repeats replay
and conflicting resolutions fail.
If any existing-subscription provider mutation throws
SUBSCRIPTION_MUTATION_RECONCILIATION_REQUIRED, preserve its safe context.claimReference; do not
blindly retry. A trusted host can inspect and resolve the provider-neutral claim without a provider
call:
await payable.subscriptionMutationClaims(tenantId).resolve(claimReference, {
outcome: providerStateIsConclusive ? 'applied' : 'unknown',
evidenceReference: operatorEvidenceReference,
idempotencyKey: `resolve-direct-${claimReference}-v1`,
});
unknown keeps the claim active. not_applied safely releases it. For swap and quantity claims,
applied projects the stored item intent exactly once and releases it. Cancel, pause, resume,
scheduled-cancellation, and legacy-apply claims do not contain enough provider-returned state to
fabricate a local lifecycle projection: applied confirms and releases those claims, while the host
uses its verified provider sync or webhook path to mirror the exact provider state.
Only failed migrations are retryable, and each retry requires a new operation key.
Currency and billing-period changes are separately capability-gated. Both default to unsupported;
offer them only when operations.changePrice.supportsCurrencyChange or
operations.changePrice.supportsBillingPeriodChange is explicitly true.
Legacy API compatibility
subscription(...).previewChange() and applyChange() keep their existing signatures and DTOs.
Canonical subscriptions delegate those calls to the canonical migration resource. Preview tokens
stored before migration step 021 and historical provider-native subscriptions remain readable; the
compatibility path never fabricates canonical catalog IDs.
Pause and resume
Lifecycle pause and payment-collection pause are different contracts. Use lifecycle pause when the provider changes the subscription state:
if (operations.pause.subscription.effectiveTimings.includes('nextRenewal')) {
await subscription.pauseSubscription({
effectiveTiming: 'nextRenewal',
resumeAt: new Date('2027-01-15T00:00:00Z'),
resumeBillingPolicy: 'startNewBillingPeriod',
});
}
if (operations.resume.pausedSubscription.effectiveTimings.includes('immediate')) {
await subscription.resumePausedSubscription({
effectiveTiming: 'immediate',
billingPolicy: 'continueExistingBillingPeriod',
});
}
Use payment-collection pause when invoices continue while collection behavior changes:
if (operations.pause.paymentCollection.behaviors.includes('keepAsDraft')) {
await subscription.pausePaymentCollection({
behavior: 'keepAsDraft',
resumesAt: null,
});
}
if (operations.resume.paymentCollection) {
await subscription.resumePaymentCollection();
}
Unsupported operations
Do not infer support from the provider name or from the coarse subscriptions capability. Use the
granular descriptor to hide or disable unavailable actions:
const canPauseImmediately =
operations.pause.subscription.effectiveTimings.includes('immediate');
if (canPauseImmediately) {
await subscription.pauseSubscription({
effectiveTiming: 'immediate',
resumeAt: null,
resumeBillingPolicy: 'startNewBillingPeriod',
});
}
Calling an unavailable operation still fails before a provider request with
ProviderCapabilityNotSupportedError and code PROVIDER_CAPABILITY_NOT_SUPPORTED. This fail-fast
boundary is required for headless, API, and delayed-job callers that do not use the same interface.
Price migration rule
Archiving a price, creating a replacement, or changing the catalog default does not alter existing subscriptions. A subscriber keeps the historical price until an explicit provider-confirmed migration succeeds. See Subscriptions for identity, audit, and reconciliation details.