Lifecycle and usage
Use the direct subpaths below or the root's invoiceLineItems and tenantLifecycle namespaces. Register consumers/providers from the owning module's lifecycle, not a request handler. Registrations replay when SaaS attaches or reloads and are released when their owner unloads.
Choose when your work should happen
| Task | Integration | Example result |
|---|---|---|
| Save onboarding answers | Provisioning hook | A referral code is stored before signup completes |
| Create paid product resources | created lifecycle consumer | A workspace gets its resources after payment is committed |
| Pause or resume resources | Lifecycle consumer | SaaS access waits for the required reactivation receipts |
| Add measured usage to a bill | Invoice line-item provider | 120 compute minutes produce one priced line |
For that last example, amountCents: 600 and quantity: 120 mean a line total
of €6.00 on an EUR invoice, not 120 × €6.00. Your resolver calculates the total;
SaaS does not multiply it by the quantity. See the
working provider example
before implementing the contract below.
Invoice line items
Import from /invoice-line-items. RegisterInvoiceLineItemsProvider(provider: InvoiceLineItemsProvider): void registers { id: string, resolve: InvoiceLineItemsResolver }. UnregisterInvoiceLineItemsProvider(providerId: string): void removes it; unknown IDs are ignored. Register during construct() and unregister during stop().
interface InvoiceLineItemsContext {
tenantId: string;
invoiceId: string;
currency: string;
periodStart: Date;
periodEnd: Date;
isPreview?: boolean;
}
interface InvoiceLineItem {
key: string;
description: string;
amountCents: number;
quantity?: number;
unit?: string;
metadata?: Record<string, string>;
}
type InvoiceLineItemsResolver = (
context: InvoiceLineItemsContext,
) => Promise<InvoiceLineItem[]> | InvoiceLineItem[];
The runtime asks providers for subscription_cycle renewal invoices, not initial or one-off invoices. The dates cover the cycle just closed, clipped to the subscription's paid usage period. Complimentary/grace usage is excluded; no provider runs for an empty covered window. Read tenant-scoped, pre-aggregated usage because the resolver runs inside the webhook request.
amountCents is the safe-integer line total in the invoice currency, including negative credits. Quantity and unit are audit metadata, not factors multiplied into the total. Zero amounts are dropped. Empty keys/descriptions, non-safe-integer amounts, keys containing :, or combined keys over 150 characters are invalid. Provider IDs must not contain :; registration throws if they do. IDs must be globally unique across modules: a collision replaces the earlier provider, and collisions before attachment can collapse silently.
Keep each line key stable. The runtime injects at most one line per invoice/provider/key across webhook replays and concurrent workers. A throwing provider is logged and skipped so others can run, but partial/invalid injection ultimately fails the webhook to expose underbilling. Returning lines is not a guarantee that Stripe accepted the invoice items.
Preview calls set isPreview: true, use a synthetic upcoming_<subscription id> invoice ID, and end at the request time. They quote usage without invoicing it. Do not mutate usage accounting inside a resolver. A failed resolver or invalid line makes the preview unavailable.
Line helpers
LINE_KEY_SEPARATOR is ":". buildInvoiceLineKey(providerId: string, key: string): string joins the two fragments; it does not validate them.
selectInvoiceLineItemsToCreate(providerId: string, items: readonly InvoiceLineItem[], alreadyInjectedKeys: ReadonlySet<string>): InvoiceLineItemSelection is synchronous and has no Stripe side effects. It returns accepted: PreparedInvoiceLineItem[] ({ lineKey, item }, with trimmed item keys) and skipped: SkippedInvoiceLineItem[] ({ lineKey, reason }). SkippedLineItemReason is already_invoiced, duplicate_key, zero_amount, or invalid. The set must contain full provider-prefixed keys. This selection helper does not itself provide transactional idempotency.
Tenant lifecycle
Import from /tenant-lifecycle. TENANT_LIFECYCLE_TRANSITIONS contains created, suspended and reactivation_requested; TenantLifecycleTransition is their union.
Breaking migration: Update @antelopejs/interface-dms-saas and @antelopejs/dms-saas together. The old RegisterWorkspaceLifecycleConsumer, UnregisterWorkspaceLifecycleConsumer, WorkspaceLifecycle* types, WORKSPACE_LIFECYCLE_TRANSITIONS, IsWorkspaceProvisioningCommitted, /workspace-lifecycle subpath and root workspaceLifecycle namespace are removed, without compatibility aliases. Use the tenant-named API below and root tenantLifecycle namespace instead. Persisted consumer IDs, transition values and operation IDs remain unchanged; keep existing consumer names and deduplication keys.
import type { TenantLifecycleTransition } from "@antelopejs/interface-dms-saas/tenant-lifecycle";
interface TenantLifecycleMessage {
tenantId: string;
operationId: string;
transition: TenantLifecycleTransition;
requestedAt: Date;
}
interface TenantLifecycleReceipt {
receiptId: string;
effectiveAt?: Date;
}
interface TenantLifecycleConsumer {
name: string;
transitions: readonly TenantLifecycleTransition[];
consume(message: TenantLifecycleMessage): Promise<TenantLifecycleReceipt>;
}
interface TenantLifecycleConsumerRegistration { unregister(): void; }
declare function RegisterTenantLifecycleConsumer(
consumer: TenantLifecycleConsumer,
): TenantLifecycleConsumerRegistration;
declare function UnregisterTenantLifecycleConsumer(name: string): void;
declare function IsTenantProvisioningCommitted(tenantId: string): Promise<boolean>;
Names must match /^[a-z][a-z0-9._-]{2,63}$/; invalid names throw synchronously. Pick a stable unique name and explicitly select transitions. Unregister via the returned handle or by name when the module stops.
Delivery is at least once. Deduplicate by operationId, durably link provisioned resources, then return a durable receipt. A rejection is not an acknowledgement. Suspension closes SaaS access before delivery; reactivation keeps access closed until every registered required consumer returns a receipt. created arrives after ownership, billing and pre-provisioning hooks succeed and the first invoice is paid, or no payment is required.
IsTenantProvisioningCommitted returns false for preparing, unpaid or cancelled self-service workspaces; true for committed creations and legacy/bootstrap tenants without a marker. It is not an existence, membership or access check. Storage and missing-implementation errors propagate; fail closed instead of treating an unavailable check as eligibility.
Registration and provisioning
TenantBeingProvisionedPayload from /provisioning has tenantId: string, userId: string, and optional extras: Record<string, unknown>. Use it with the DMS Hook.TENANT_BEING_PROVISIONED hook; the hook handler returns Promise<undefined>, not Promise<void>.
Listeners run serially inside provisioning, while the per-card lock is held, after workspace records exist but before registration returns. A throwing listener cancels registration and rolls back the runtime's tenant, subscription, Stripe customer and password-path account. Do not assume external side effects of your listener roll back automatically. Keep it to bounded persistence work rather than network provisioning; use durable created delivery for post-payment resources.
Extras reach password and OAuth completion paths unchanged, are absent when nothing was captured, and remain consumer-owned. The runtime does not persist or return them. /registration exports RegistrationExtrasLimits and REGISTRATION_EXTRAS_LIMITS: maxSerialisedBytes: 4096, maxKeys: 64, maxDepth: 4. Public registration also rejects __proto__, constructor, and prototype keys. Rejected extras produce HTTP 400 saas.errors.registration.extras_* errors before listeners run.