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)
none ──► (no subscription record exists)| State | Description |
|---|---|
pending | Created but payment not yet confirmed |
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 |
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 to redirect the user.
const result = await client.subscribe({ planId: "pro" });
// result.checkoutUrl → redirect to Chapa checkoutconst result = await birrjs.subscribe({
customerId: "user_123",
planId: "pro",
});
// result.checkoutUrl → return to client for redirectPayment 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). |
Result Shape
| Field | Description |
|---|---|
checkoutUrl | The payment provider's checkout URL. Redirect the user here to complete payment. |
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.
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 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.