↻ Subscription Lifecycle

Subscription Lifecycle Guide

Complete reference for recurring billing: state machine, dunning, proration, usage-based metering, webhook events, and SDK examples.

How Subscriptions Work

Subscriptions in Salesbooth are modelled as chains of linked deals. When you activate recurring billing on a closed deal, that deal becomes the root of the subscription chain. Each renewal creates a new child deal (linked via parent_deal_id) for the next billing period.

This design preserves the full deal infrastructure — payment capture, audit trail, signatures, and webhooks — for every billing period. Your existing deal integrations work unchanged for subscription renewals.

ConceptDescription
Root deal The original closed deal that was activated as a subscription. Holds billing_cycle, subscription_status, and next_billing_date.
Renewal deal Each billing period creates a new deal with parent_deal_id pointing to the root. Line items are copied from the previous deal.
Billing cycles monthly, quarterly, annual. Cycle durations use calendar months/years, not fixed day counts.
Renewal chain All deals sharing the same parent_deal_id (or root deal) form the subscription history, queryable via GET /api/v1/subscriptions?id={deal_id}.

State Machine

A subscription transitions through five states. Invalid transitions are rejected with a 409 Conflict error.

┌─────────────────────────────────────────────────────────┐ │ SUBSCRIPTION STATES │ └─────────────────────────────────────────────────────────┘ ┌──────────┐ ┌──────────│ active │──────────┐ │ └──────────┘ │ │ │ │ │ pause() cancel() │ payment │ │ fails ▼ │ │ ┌────────┐ │ ┌──────────┐ │ paused │ │ │ past_due │ └────────┘ │ └──────────┘ │ │ │ │ resume() │ retry OK retries cancel() │ │ exhausted │ │ ▼ │ │ │ ┌──────────┐ │ │ │ │ active │ ▼ │ │ └──────────┘ ┌───────────┐ │ │ │ suspended │ │ │ └───────────┘ │ │ │ │ │ │ resume() grace period │ │ cancel() expires (7d) └──────────────────┴────────────────┴────────────▼ ┌───────────┐ │ cancelled │ (terminal) └───────────┘
StatusDescriptionValid transitions
active Subscription is billing normally. Renewals are generated automatically by cron. paused, cancelled, past_due
paused Renewals are suspended. Customer retains access. Billing resumes on resume(). active, cancelled
past_due Most recent renewal payment failed. Dunning retries are in progress. active, cancelled, suspended
suspended All retry attempts exhausted. Subscription is within the 7-day grace period before auto-cancellation. active, cancelled
cancelled Terminal state. No further renewals. Cannot be reactivated — create a new subscription. none
End-of-period cancellation

Passing "end_of_period": true to the cancel action marks the subscription for cancellation at the next billing date. The status stays active until the billing date passes, then the cron transitions it to cancelled. Check metadata.cancel_at_period_end on the deal object.

Create & Activate

A subscription is activated on a closed deal (payment already collected). The deal must have status = "closed" before you call create.

curl -X POST https://salesbooth.com/api/v1/subscriptions \ -H "X-API-Key: sb_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "action": "create", "deal_id": "deal_abc123", "billing_cycle": "monthly" }'
const { SalesBooth } = require('@salesbooth/node'); const sb = new SalesBooth({ apiKey: process.env.SB_API_KEY }); // Step 1: Create and close a deal first const deal = await sb.deals.create({ customer_id: 'cust_xyz', title: 'Pro Plan', line_items: [{ name: 'Pro Plan', unit_price: 99.00, quantity: 1 }], }); // Step 2: Mark it as closed (collect payment first via Stripe/widget) // ... // Step 3: Activate recurring billing const subscription = await sb.subscriptions.create(deal.deal_id, 'monthly'); console.log(subscription.subscription_status); // "active" console.log(subscription.next_billing_date); // "2026-04-19"
$response = file_get_contents('https://salesbooth.com/api/v1/subscriptions', false, stream_context_create(['http' => [ 'method' => 'POST', 'header' => "X-API-Key: sb_live_your_key_here\r\nContent-Type: application/json", 'content' => json_encode([ 'action' => 'create', 'deal_id' => 'deal_abc123', 'billing_cycle' => 'monthly', ]), ]]) ); $subscription = json_decode($response, true); // $subscription['data']['subscription_status'] === 'active'

Request fields

FieldTypeRequiredDescription
actionstringYesMust be "create"
deal_idstringYesThe closed deal to activate as a subscription
billing_cyclestringYesOne of: monthly, quarterly, annual

On success, the deal object is returned with deal_type = "recurring", subscription_status = "active", and next_billing_date set. A subscription.created webhook is dispatched.

Renew, Pause & Resume

These actions are usually handled automatically by cron, but you can invoke them via API for manual control (e.g. admin tools or customer-initiated pauses).

Renew

Creates a new child deal for the next billing period. Line items and pricing are copied from the current deal. Metered usage charges from the completed cycle are added as line items on the renewal deal.

// Node.js SDK const renewalDeal = await sb.subscriptions.renew('deal_abc123'); // Returns the new renewal deal (not the original)
# cURL curl -X POST https://salesbooth.com/api/v1/subscriptions \ -H "X-API-Key: sb_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{"action": "renew", "deal_id": "deal_abc123"}'
Manual renewal is rarely needed

The subscription-lifecycle cron runs daily and automatically renews subscriptions 3 days before their billing date. Only invoke renewal manually if you need immediate billing (e.g., prorated mid-cycle upgrade) or are building your own billing orchestration.

Pause

Stops renewal generation while keeping the subscription alive. The customer retains access but will not be billed until resumed. No proration is applied — billing simply skips the paused period.

// Node.js SDK — pause const deal = await sb.subscriptions.pause('deal_abc123'); // Resume when the customer is ready const resumed = await sb.subscriptions.resume('deal_abc123'); // next_billing_date is recalculated from today on resume
Paused subscriptions are not billed

Pausing does not generate a prorated credit for the remaining days. If you need to refund unused time, cancel with "prorate": true instead.

Resume

Transitions the subscription back to active. The next_billing_date is recalculated from today using the original billing cycle. Any remaining days from before the pause are not credited.

Cancel & Proration

Subscriptions can be cancelled immediately or at the end of the current billing period. Immediate cancellation optionally refunds the unused portion of the billing cycle.

// Immediate cancellation with proration (default) const result = await sb.subscriptions.cancel('deal_abc123', { end_of_period: false, reason: 'customer_request', prorate: true, // default }); // result.proration contains refund details console.log(result.proration.prorated_amount); // e.g. 32.26 console.log(result.proration.refund_status); // "refunded" or "credit_note" console.log(result.proration.credit_note_id); // "cn_abc123"
// Cancel at end of current billing period — no refund const result = await sb.subscriptions.cancel('deal_abc123', { end_of_period: true, reason: 'downgrade', }); // deal.metadata.cancel_at_period_end === true // Status stays "active" until next_billing_date passes // Cron auto-cancels on billing date
// Immediate cancellation, no proration refund const result = await sb.subscriptions.cancel('deal_abc123', { end_of_period: false, prorate: false, reason: 'terms_violation', });

Proration formula

When prorate: true and the cancellation is immediate, the unused portion of the billing cycle is refunded:

prorated_amount = (remaining_days / cycle_days) × subscription_amount Example: subscription_amount = $99.00 (monthly plan) cycle_days = 31 (actual days in billing period) remaining_days = 21 (days until next billing date) prorated_amount = (21/31) × $99.00 = $67.06

The system first attempts a Stripe refund to the original payment method. If the refund fails (e.g. the payment was already refunded or too old), a credit note is issued instead. Both outcomes are reported in the proration object in the response and the subscription.proration_credit webhook.

Cancel optionTypeDefaultDescription
end_of_periodbooleanfalseIf true, subscription stays active until next_billing_date, then auto-cancels
proratebooleantrueCalculate and apply a refund/credit for unused days on immediate cancellation
reasonstringnullOptional cancellation reason, stored in audit trail and webhook payload

Dunning Strategy

When a renewal payment fails, the subscription enters past_due state and the dunning engine begins automatic retries.

Retry schedule

Retries are time-based, with increasing intervals after the first failure:

Initial failure → status: past_due Retry 1 at +1 day → status: active (if succeeds) or past_due (if fails) Retry 2 at +3 days → status: active (if succeeds) or past_due (if fails) Retry 3 at +7 days → status: active (if succeeds) or suspended (if fails) After 3 failed retries → status: suspended After 7 more days (grace period) → status: cancelled (auto)
ConstantValueDescription
DUNNING_SCHEDULE[1, 3, 7]Days after initial failure for each retry attempt
MAX_RETRY_ATTEMPTS3Maximum payment retry attempts before suspension
GRACE_PERIOD_DAYS7Days a suspended subscription has before auto-cancellation

Webhooks during dunning

Each event in the dunning lifecycle dispatches a webhook:

  • subscription.payment_failed — fired on initial failure and each retry failure. Contains retry_count, max_retries, and next_retry_at.
  • subscription.payment_retried — fired when a retry succeeds. Contains succeeded: true and payment_intent_id.
  • subscription.suspended — fired when all retries are exhausted.
  • subscription.cancelled — fired when the grace period expires.

Manual retry

You can manually retry a past_due subscription at any time:

// Retry payment immediately const deal = await sb.subscriptions.retryPayment('deal_abc123'); // Or grant additional grace days before retrying const deal = await sb.subscriptions.retryPayment('deal_abc123', 3);
Use manual retry for payment method updates

When a customer updates their payment method, call retryPayment immediately to attempt recovery. This resets the subscription to active without waiting for the cron retry schedule.

Escalating notifications

Customer email notifications are sent at each retry step, with escalating urgency. The customer receives:

  • Initial failure — soft notice, "update your payment method"
  • Retry 2 failure — warning, "subscription at risk"
  • Retry 3 failure — final warning before suspension
  • Suspension — account access at risk

Billing Cycle Changes

Switch between monthly, quarterly, and annual mid-cycle. The system calculates a prorated adjustment automatically.

Preview before committing

Always preview the proration first — this is a read-only operation:

// Preview: what would it cost to switch from monthly to annual? const preview = await sb.subscriptions.previewCycleChange('deal_abc123', 'annual'); console.log(preview); // { // old_cycle: "monthly", // new_cycle: "annual", // old_total: 99.00, // new_total: 1188.00, ← same monthly rate × 12 // proration_amount: 892.13, ← net charge (annual charge minus monthly credit) // credit_amount: 67.06, ← unused days on current monthly cycle // remaining_days: 21, // new_next_billing_date: "2027-03-19", // currency: "USD" // }

Apply the cycle change

const result = await sb.subscriptions.changeCycle('deal_abc123', 'annual'); // result.proration contains the applied adjustment // The next_billing_date is reset to today + 1 year

The proration formula for cycle changes:

credit_amount = (remaining_days / old_cycle_days) × old_total new_charge = (remaining_days / new_cycle_days) × new_total proration_amount = new_charge - credit_amount Monthly → Annual example: old_total = $99/month, 21 remaining days in 31-day cycle credit = (21/31) × $99 = $67.06 new_total = $1,188/year (= $99 × 12) new_charge = (21/365) × $1,188 = $68.40 proration = $68.40 − $67.06 = $1.34 (small net charge)
Dispatch on cycle change

The subscription.cycle_changed webhook is dispatched with the proration object. The deal's billing_cycle and total fields are updated in-place on the existing deal.

Upgrade / Downgrade

Change the subscription's line items mid-cycle. The system calculates a prorated adjustment based on the difference between old and new plan prices for the remaining days.

// Upgrade from Basic ($49) to Pro ($99), 21 days remaining in 31-day cycle const result = await sb.subscriptions.change('deal_abc123', [ { name: 'Pro Plan', unit_price: 99.00, quantity: 1 }, ]); // result.proration: // { // old_total: 49.00, // new_total: 99.00, // proration_factor: 0.6774, ← 21/31 // proration_amount: 33.87, ← (99 − 49) × 0.6774 // remaining_days: 21, // cycle_days: 31, // }

The formula:

proration_factor = remaining_days / cycle_days old_credit = old_total × proration_factor new_charge = new_total × proration_factor proration_amount = new_charge − old_credit = (new_total − old_total) × proration_factor

Webhooks: upgrades dispatch subscription.upgraded, downgrades dispatch subscription.downgraded. Both also dispatch subscription.changed.

Proration is not automatically charged

The proration_amount in the response is informational. The actual charge adjustment is included in the next renewal deal as a line item. If you need to collect the difference immediately, create a separate one-time deal for the proration amount.

Usage-Based Metering

Add metered billing on top of base subscription prices. Meters track consumption during each billing cycle; charges are calculated and added to the renewal deal.

Billing models

ModelCalculationUse case
per_unit billable_qty × unit_price Simple usage: API calls, storage GB, SMS messages
tiered Each range of units charged at that tier's rate Volume discounts that stack: first 1000 at $0.01, next 9000 at $0.005, etc.
volume All units charged at the rate of the tier the total falls into Bulk pricing: if you consume 5000 units, all 5000 are charged at the 5000-tier rate

Step 1: Add a meter to a subscription

// $0.005 per API call, first 1000 calls included const meter = await sb.subscriptions.addMeter('deal_abc123', { metric_name: 'api_calls', billing_model: 'per_unit', unit_price: 0.005, included_quantity: 1000, // free tier currency: 'USD', }); console.log(meter.meter_id); // "mtr_abc123"
// Tiered: first 1000 at $0.01, next 9000 at $0.005, remainder at $0.002 const meter = await sb.subscriptions.addMeter('deal_abc123', { metric_name: 'storage_gb', billing_model: 'tiered', tiers: [ { up_to: 1000, unit_price: 0.01 }, { up_to: 10000, unit_price: 0.005 }, { up_to: null, unit_price: 0.002 }, // null = unlimited ], }); // Example: 5000 GB consumed // Tier 1: 1000 × $0.01 = $10.00 // Tier 2: 4000 × $0.005 = $20.00 // Total: $30.00
// Volume: ALL units charged at rate of tier the total falls into const meter = await sb.subscriptions.addMeter('deal_abc123', { metric_name: 'seats', billing_model: 'volume', tiers: [ { up_to: 10, unit_price: 25.00 }, // ≤10 seats: $25/seat { up_to: 50, unit_price: 20.00 }, // ≤50 seats: $20/seat { up_to: null, unit_price: 15.00 }, // >50 seats: $15/seat ], }); // Example: 30 seats consumed // Falls into 50-seat tier → 30 × $20 = $600 (all 30 at $20)

Step 2: Record usage events

Record usage as it happens during the billing cycle. Usage recording is idempotent — pass an idempotency_key to prevent duplicate charges on retry.

// Record a single event await sb.subscriptions.recordUsage('deal_abc123', 'api_calls', 150, { idempotency_key: 'req_' + requestId, // prevents duplicates metadata: { endpoint: '/api/v1/deals', status: 200 }, }); // Record multiple events in one request (batch) await sb.subscriptions.recordUsageBatch('deal_abc123', [ { metric_name: 'api_calls', quantity: 50, idempotency_key: 'batch_1' }, { metric_name: 'api_calls', quantity: 100, idempotency_key: 'batch_2' }, { metric_name: 'storage_gb', quantity: 5.2, idempotency_key: 'batch_3' }, ]);

Step 3: Query current cycle usage

// Get projected charges for the current billing cycle const summary = await sb.subscriptions.usageSummary('deal_abc123'); console.log(summary); // { // subscription_deal_id: "deal_abc123", // billing_cycle: { start: "2026-03-19", end: "2026-04-19" }, // meters: [ // { // meter_id: "mtr_abc123", // metric_name: "api_calls", // billing_model: "per_unit", // total_quantity: 8500, // included_quantity: 1000, // billable_quantity: 7500, // unit_price: 0.005, // charge: 37.50, // currency: "USD", // event_count: 85, // } // ], // total_usage_charges: 37.50, // base_subscription_amount: 99.00, // projected_total: 136.50, // }

How usage charges are billed

At renewal time, the cron calculates all metered charges from the completed billing cycle and adds them as item_type: "metered_usage" line items on the new renewal deal. The renewal total includes both the base subscription and usage charges.

Included quantity (free tier)

Set included_quantity on a meter to grant a free allowance each cycle. For example, included_quantity: 1000 means the first 1000 units are free. Billable quantity = max(0, total_quantity − included_quantity).

Webhook Events

Subscribe to subscription lifecycle events via webhook endpoints. All events include the deal object plus event-specific fields.

Event typeTriggerKey payload fields
subscription.created Recurring billing activated on a deal deal object with subscription_status, billing_cycle, next_billing_date
subscription.renewed Renewal deal created successfully new renewal deal, invoice_id, billing_period_start, billing_period_end
subscription.paused Subscription paused deal object
subscription.resumed Subscription resumed from pause deal object with updated next_billing_date
subscription.cancelled Subscription cancelled (immediately or end-of-period) deal object, end_of_period, reason, proration_amount, proration
subscription.changed Line items updated (upgrade or downgrade) deal object, proration.old_total, proration.new_total, proration.proration_amount
subscription.upgraded Plan changed to higher price Same as subscription.changed
subscription.downgraded Plan changed to lower price Same as subscription.changed
subscription.cycle_changed Billing cycle switched (e.g. monthly → annual) deal object, proration.old_cycle, proration.new_cycle, proration.proration_amount
subscription.payment_failed Renewal or retry payment failed deal_id, renewal_deal_id, retry_count, max_retries, next_retry_at, error
subscription.payment_retried Retry payment succeeded deal_id, retry_count, succeeded: true, payment_intent_id
subscription.suspended All retries exhausted, grace period begun deal object
subscription.renewal_upcoming Sent 7 days and 1 day before billing date deal_id, next_billing_date, billing_cycle, total, currency, days_before_renewal
subscription.proration_credit Proration refund/credit issued on cancellation deal_id, prorated_amount, remaining_days, cycle_days, refund_id, refund_status, credit_note_id
subscription.meter_added Usage meter added to subscription meter object with meter_id, metric_name, billing_model
subscription.meter_removed Usage meter archived meter_id, status: "archived"
subscription.usage_charges_applied Metered usage charges added to renewal deal deal_id, renewal_deal_id, total_usage_charges, line_items

Example: handling subscription events

// Node.js webhook handler app.post('/webhooks/salesbooth', express.raw({ type: 'application/json' }), (req, res) => { // Verify signature first (see Webhook Integration Guide) const event = JSON.parse(req.body); switch (event.event) { case 'subscription.created': // Provision access for new subscription await provisionAccess(event.data.customer_id, event.data.billing_cycle); break; case 'subscription.renewed': // Send receipt, update subscription record in your DB await sendRenewalReceipt(event.data.customer_id, event.data.invoice_id); break; case 'subscription.payment_failed': // Notify customer, prompt payment method update await notifyPaymentFailed(event.data.deal_id, event.data.retry_count); break; case 'subscription.cancelled': // Deprovision access await revokeAccess(event.data.customer_id); break; case 'subscription.renewal_upcoming': // Optional: send custom reminder email if (event.data.days_before_renewal === 7) { await sendRenewalReminder(event.data.deal_id, event.data.next_billing_date); } break; } res.json({ received: true }); });

SDK Examples

The sb.subscriptions resource provides 18 methods covering the full lifecycle.

Full lifecycle workflow

const { SalesBooth } = require('@salesbooth/node'); const sb = new SalesBooth({ apiKey: process.env.SB_API_KEY }); // ── 1. CREATE ───────────────────────────────────────────────── // Activate billing on a closed deal const sub = await sb.subscriptions.create('deal_abc123', 'monthly'); // ── 2. LIST & GET ────────────────────────────────────────────── const allSubs = await sb.subscriptions.list({ status: 'active', limit: 20 }); const subDetail = await sb.subscriptions.get('deal_abc123'); // subDetail.subscription.dunning_schedule → [1, 3, 7] // subDetail.renewal_history → array of past renewal deals // subDetail.events → last 50 subscription events // ── 3. PAUSE & RESUME ───────────────────────────────────────── await sb.subscriptions.pause('deal_abc123'); // ... customer is on leave ... await sb.subscriptions.resume('deal_abc123'); // ── 4. RENEW (manual) ───────────────────────────────────────── const renewalDeal = await sb.subscriptions.renew('deal_abc123'); // ── 5. UPGRADE ──────────────────────────────────────────────── const upgraded = await sb.subscriptions.change('deal_abc123', [ { name: 'Enterprise Plan', unit_price: 299.00, quantity: 1 }, ]); console.log(upgraded.proration.proration_amount); // prorated difference // ── 6. SWITCH CYCLE ─────────────────────────────────────────── const preview = await sb.subscriptions.previewCycleChange('deal_abc123', 'annual'); console.log(preview.proration_amount); const changed = await sb.subscriptions.changeCycle('deal_abc123', 'annual'); // ── 7. USAGE METERING ───────────────────────────────────────── const meter = await sb.subscriptions.addMeter('deal_abc123', { metric_name: 'api_calls', billing_model: 'per_unit', unit_price: 0.005, included_quantity: 1000, }); await sb.subscriptions.recordUsage('deal_abc123', 'api_calls', 250, { idempotency_key: 'evt_' + Date.now(), }); const summary = await sb.subscriptions.usageSummary('deal_abc123'); const history = await sb.subscriptions.usageHistory('deal_abc123', { metric: 'api_calls', limit: 100, }); await sb.subscriptions.removeMeter(meter.meter_id); // ── 8. CANCEL ───────────────────────────────────────────────── const cancelled = await sb.subscriptions.cancel('deal_abc123', { end_of_period: false, prorate: true, reason: 'customer_request', }); console.log(cancelled.proration.prorated_amount); // ── 9. ANALYTICS ────────────────────────────────────────────── const metrics = await sb.subscriptions.analytics(); // { mrr: 4850.00, arr: 58200.00, churn_rate: 2.4, // active_subscriptions: 52, net_revenue_retention: 108.2 }

Listening to SDK events

The SDK emits client-side events for every subscription mutation:

sb.on('subscription.created', (data) => console.log('Created:', data)); sb.on('subscription.renewed', (data) => console.log('Renewed:', data)); sb.on('subscription.paused', (data) => console.log('Paused:', data)); sb.on('subscription.resumed', (data) => console.log('Resumed:', data)); sb.on('subscription.cancelled', (data) => console.log('Cancelled:', data)); sb.on('subscription.changed', (data) => console.log('Changed:', data)); sb.on('subscription.cycle_changed', (data) => console.log('Cycle changed:', data)); sb.on('subscription.payment_retried', (data) => console.log('Retry succeeded:', data));

Required API scopes

OperationRequired scope
List subscriptions, get subscription detail, preview cycle change, analytics, usage summary/history, list metersdeals:read
Create, renew, pause, resume, cancel, change, change-cycle, retry-payment, add-meter, remove-meter, record-usagedeals:write

Cron Automation

The subscription lifecycle runs automatically via cron/subscription-lifecycle.php, which executes daily and handles all recurring tasks.

TaskWhat it does
renew Creates renewal deals for active subscriptions with next_billing_date ≤ today + 3 days. Handles end-of-period cancellations.
flag_past_due Transitions subscriptions past their billing date to past_due status.
retry Retries payment for past_due subscriptions according to the dunning schedule (next_retry_at ≤ NOW()).
suspend Suspends past_due subscriptions that have exhausted all retry attempts.
cancel_expired Cancels suspended subscriptions that have exceeded the 7-day grace period.
notify Sends renewal reminder notifications 7 days and 1 day before billing date.

All tasks use FOR UPDATE SKIP LOCKED to safely handle concurrent cron runs — no double-renewals or double-charges are possible. Each task is also idempotent and safe to re-run.

Running individual tasks manually

# Preview what would renew without making changes php cron/subscription-lifecycle.php --dry-run --task=renew # Run just the dunning retry task php cron/subscription-lifecycle.php --task=retry # Run all tasks with a larger batch size php cron/subscription-lifecycle.php --batch-size=200
deal-lifecycle.php also processes subscriptions

The general cron/deal-lifecycle.php cron also handles subscription-related deal transitions. Running both is safe and recommended — each task is idempotent.

Next Steps