BirrJS
Concepts

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)
StateDescription
pendingCreated but payment not yet confirmed
trialingFree trial active, trialEndsAt set
activePayment confirmed, customer has access
expiredPeriod ended without renewal
canceledManually canceled (immediate or at period end)
failedPending subscription that never activated (effective status)
noneCustomer has no subscription at all (effective status)

Effective Status

BirrJS computes a runtime effective status that may differ from the stored database status:

StoredConditionEffective
activeWithin periodactive
activeexpiresAt passedexpired
trialingWithin trial periodtrialing
trialingtrialEndsAt passedexpired
pendingCreated > 60 min agofailed
canceledPeriod not yet endedactive (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 started
const result = await birrjs.subscribe({
  customerId: "user_123",
  planId: "pro",
});
// result.checkoutUrl → return to client for redirect

Trial (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 = now

Expiration

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

FieldRequiredDescription
planIdYesThe target plan ID. Must be a valid plan from your config.
customerIdServer onlyThe customer to subscribe. Not needed on the client (resolved from identify).
useTrialNotrue to start a free trial. Requires plan with trialDays and the @birrjs/trial plugin.
fingerprintNoBrowser fingerprint for trial abuse prevention. Required when useTrial: true. Collect via @birrjs/fingerprint.

Result Shape

FieldDescription
checkoutUrlThe payment provider's checkout URL. Redirect the user here to complete payment (Chapa).
paymentInstructionsBank account details for manual payment (Vodit).
trialEndsAtISO date string when the trial period ends. Present when a trial subscription was created or returned.
subscriptionIdThe ID of the created subscription record.
customerIdThe 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

SweepIntervalAction
ReminderEvery day at 8amFires subscription.reminder events for expiring subs
PendingEvery 5 minMarks stuck pending subscriptions as failed
ExpiryEvery 10 minMarks 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.