Subscriptions
The subscription lifecycle — from pending to active to canceled or expired.
Lifecycle States
┌──► failed (after 60 min pending)
pending ──► active ──► expired
│
├──► canceled (at period end)
│
trialing ──► active (on payment)
none ──► (no subscription record exists)| State | Description |
|---|---|
pending | Created but payment not yet confirmed |
trialing | Free trial active, trialEndsAt set |
active | Payment confirmed, customer has access |
expired | Period ended without renewal |
canceled | Manually canceled (immediate or at period end) |
failed | Pending subscription that never activated (effective status) |
none | Customer has no subscription at all (effective status) |
Effective Status
BirrJS computes a runtime effective status that may differ from the stored database status:
| Stored | Condition | Effective |
|---|---|---|
active | Within period | active |
active | expiresAt passed | expired |
trialing | Within trial period | trialing |
trialing | trialEndsAt passed | expired |
pending | Created > 60 min ago | failed |
canceled | Period not yet ended | active (until period end) |
This is handled by getEffectiveStatus():
const effective = getEffectiveStatus(subscription);
// "pending" | "active" | "canceled" | "failed" | "expired" | "none"Subscription Flow
Subscribe
Call subscribe() with the target plan ID. BirrJS creates a subscription in pending status and initializes a transaction with the payment provider. For paid plans, the result includes a checkoutUrl (redirect providers like Chapa) or paymentInstructions (receipt-based providers like Vodit).
// Paid plan
const result = await client.subscribe({ planId: "pro" });
// result.checkoutUrl → redirect to Chapa checkout
// or result.paymentInstructions → show bank channels (Vodit)
// With trial
const result = await client.subscribe({
planId: "pro",
useTrial: true,
fingerprint: await collectFingerprint(),
});
// result.trialEndsAt → trial startedconst result = await birrjs.subscribe({
customerId: "user_123",
planId: "pro",
});
// result.checkoutUrl → return to client for redirectTrial (optional)
If the plan has trialDays configured and the @birrjs/trial plugin is registered, the subscription is created in trialing status with a trialEndsAt date. The user can access features during the trial.
When the trial ends or the user chooses to pay early, the subscription transitions to active. Entitlements are either reset or carried over based on resetOnTrialConversion.
// User clicks "Pay now" → Chapa checkout
const { checkoutUrl } = await client.subscribe({ planId: "pro" });
window.location.href = checkoutUrl;
// After payment, Chapa webhook activates the subscription// User clicks "Pay now" → get bank channels
const { paymentInstructions } = await client.subscribe({ planId: "pro" });
// User pays and submits receipt
await client.verifyReceipt({ subscriptionId, receiptUrl });Stale trialing subscriptions are marked expired by the expiry sweep cron (every 10 minutes by default).
Payment confirmed (webhook)
When the provider sends a charge.success webhook, BirrJS transitions the subscription to active, sets startedAt, and calculates expiresAt.
Renewal
On the next charge.success webhook, the subscription is renewed: expiresAt extends from its current value.
Cancel
Cancellation behavior depends on the subscription state:
// Active subscription: cancel at period end (keeps access until paid period ends)
await client.cancelSubscription({ subscriptionId });
// status stays "active", endedAt = expiresAt
// Pending subscription (never activated): cancels immediately
// status → "canceled", endedAt = nowExpiration
If the period ends without renewal, the subscription transitions to expired. This can happen via:
- The expiry sweep cron job (every 10 minutes by default)
getEffectiveStatus()on read
Subscribe Input
| Field | Required | Description |
|---|---|---|
planId | Yes | The target plan ID. Must be a valid plan from your config. |
customerId | Server only | The customer to subscribe. Not needed on the client (resolved from identify). |
useTrial | No | true to start a free trial. Requires plan with trialDays and the @birrjs/trial plugin. |
fingerprint | No | Browser fingerprint for trial abuse prevention. Required when useTrial: true. Collect via @birrjs/fingerprint. |
Result Shape
| Field | Description |
|---|---|
checkoutUrl | The payment provider's checkout URL. Redirect the user here to complete payment (Chapa). |
paymentInstructions | Bank account details for manual payment (Vodit). |
trialEndsAt | ISO date string when the trial period ends. Present when a trial subscription was created or returned. |
subscriptionId | The ID of the created subscription record. |
customerId | The customer ID the subscription was created for. |
If checkoutUrl is set, the subscription stays in pending until the provider confirms payment via webhook. If paymentInstructions is set, the user pays manually and submits their receipt via verifyReceipt. If trialEndsAt is set, the subscription is in trialing status with free access until that date.
Reminders
Subscriptions approaching expiry can trigger reminders via the "subscription.reminder" event. A daily cron sweep checks for subscriptions expiring on the exact days listed in reminderLeadDays and fires the event. A dedup table prevents duplicate sends.
A subscription expiring June 20 with reminderLeadDays: [7, 3, 1] gets reminders on June 13, June 17, and June 19. Each event includes daysUntilExpiry so your handler can customize per day.
createBirr({
scheduling: {
reminderSweepCron: "0 8 * * *", // daily at 8am
reminderLeadDays: [7, 3, 1], // send 7, 3, and 1 day before
},
on: {
"subscription.reminder": async (payload) => {
await resend.emails.send({
to: payload.customerEmail,
subject: `Your ${payload.planName} expires in ${payload.daysUntilExpiry} days`,
text: `Renew: https://myapp.com/renew?sid=${payload.subscriptionId}`,
});
},
},
plugins: [
afromessage({
messages: {
subscriptionReminder: "{daysUntil} days left. Renew now!",
},
}),
],
});Reminder records are automatically cleared when a subscription is reactivated, so renewed subscriptions get fresh reminders for the new period.
Cron Sweeps
| Sweep | Interval | Action |
|---|---|---|
| Reminder | Every day at 8am | Fires subscription.reminder events for expiring subs |
| Pending | Every 5 min | Marks stuck pending subscriptions as failed |
| Expiry | Every 10 min | Marks expired active and trialing subscriptions as expired |
The scheduler runs automatically by default (mode: "auto"). You can set mode: "external" and call the cron endpoints manually, or mode: "manual" to never run cron.
Webhook Idempotency
Webhooks are deduplicated using a unique index on (providerId, providerReferenceId). If a webhook is received twice, the second one is skipped.