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.
| Concept | Description |
|---|---|
| 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)
└───────────┘
| Status | Description | Valid 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 |
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
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | Must be "create" |
deal_id | string | Yes | The closed deal to activate as a subscription |
billing_cycle | string | Yes | One 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"}'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 resumePausing 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.06The 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 option | Type | Default | Description |
|---|---|---|---|
end_of_period | boolean | false | If true, subscription stays active until next_billing_date, then auto-cancels |
prorate | boolean | true | Calculate and apply a refund/credit for unused days on immediate cancellation |
reason | string | null | Optional 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)| Constant | Value | Description |
|---|---|---|
DUNNING_SCHEDULE | [1, 3, 7] | Days after initial failure for each retry attempt |
MAX_RETRY_ATTEMPTS | 3 | Maximum payment retry attempts before suspension |
GRACE_PERIOD_DAYS | 7 | Days 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. Containsretry_count,max_retries, andnext_retry_at.subscription.payment_retried— fired when a retry succeeds. Containssucceeded: trueandpayment_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);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 yearThe 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)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_factorWebhooks: upgrades dispatch subscription.upgraded, downgrades dispatch subscription.downgraded. Both also dispatch subscription.changed.
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
| Model | Calculation | Use 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.
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 type | Trigger | Key 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
| Operation | Required scope |
|---|---|
| List subscriptions, get subscription detail, preview cycle change, analytics, usage summary/history, list meters | deals:read |
| Create, renew, pause, resume, cancel, change, change-cycle, retry-payment, add-meter, remove-meter, record-usage | deals:write |
Cron Automation
The subscription lifecycle runs automatically via cron/subscription-lifecycle.php, which executes daily and handles all recurring tasks.
| Task | What 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=200The general cron/deal-lifecycle.php cron also handles subscription-related deal transitions. Running both is safe and recommended — each task is idempotent.