Lifecycle Hooks
Handle subscription events with extensible lifecycle hooks
Lifecycle hooks let you run custom code when subscription events occur - like sending emails, updating analytics, or provisioning resources.
Provider-Specific Hooks
Hooks are now organized per-provider for better separation and flexibility:
- Stripe hooks:
packages/billing/stripe/src/hooks/ - Polar hooks:
packages/billing/polar/src/hooks/
Each provider has its own set of hooks based on the events it supports.
Stripe Hooks
| Hook | When it's called |
|---|---|
onSubscriptionCreated | New subscription is created |
onSubscriptionUpdated | Subscription plan or status changes |
onSubscriptionCanceled | Subscription is canceled |
onSubscriptionDeleted | Subscription is permanently deleted |
onTrialStarted | Trial period begins |
onTrialEnding | Trial is about to end |
onTrialExpired | Trial ends without conversion |
onPaymentFailed | Payment attempt fails |
Polar Hooks
| Hook | When it's called |
|---|---|
onSubscriptionCreated | New subscription is created |
onSubscriptionUpdated | Subscription status changes |
onSubscriptionCanceled | Subscription is canceled |
onOrderPaid | Order payment completed |
onCustomerCreated | New customer record created |
onCustomerStateChanged | Customer state changes |
How Hooks Are Triggered
Stripe:
- Subscription create/update/cancel/delete are triggered by Better Auth’s Stripe integration.
- Trial start/expiry are wired from your billing config (
freeTrial) viapackages/billing/stripe/src/stripe-plugin.ts. - Payment failure is handled via Stripe events (see
onPaymentFailed). - Trial ending reminders (
onTrialEnding) are not guaranteed unless you wirecustomer.subscription.trial_will_endto the hook.
Polar:
- Hook execution depends on Polar webhooks being configured (
POLAR_WEBHOOK_SECRET). - Without webhooks, checkout/portal can still work, but lifecycle hooks will not run automatically.
- Hook execution depends on Polar webhooks being configured (
Important: Idempotency and Error Handling
- Webhooks can be retried and events can be delivered more than once. Make hooks idempotent (e.g., use unique keys like subscription id + event type).
- Prefer logging + continuing over throwing. A hook should not prevent billing state from being updated.
Adding Your Logic
Edit the hook files in your provider's hooks directory. Each hook receives relevant event data.
On Subscription Created
If you want to perform any actions when a user subscribes to a plan, you can use the onSubscriptionCreated hook:
export async function onSubscriptionCreated(subscription: { id: string; plan: string; status: string; referenceId: string;}) { // Send welcome email await sendWelcomeEmail({ referenceId: subscription.referenceId, plan: subscription.plan, }); // Track conversion in analytics await trackEvent('subscription_created', { plan: subscription.plan, referenceId: subscription.referenceId, }); // Store plan limits in database await db.insert(subscriptionLimits).values({ referenceId: subscription.referenceId, maxSeats: getPlanSeats(subscription.plan), maxProjects: getPlanProjects(subscription.plan), });}On Payment Failed
If you want to perform any actions when a payment fails, you can use the onPaymentFailed hook:
export async function onPaymentFailed(payment: { subscriptionId: string; customerId: string | Stripe.Customer | Stripe.DeletedCustomer | null; invoiceId: string; amount: number; currency: string; attemptCount: number; nextPaymentAttempt: Date | null;}) { // Email the customer await sendEmail({ template: 'payment-failed', data: { amount: payment.amount, currency: payment.currency, nextAttempt: payment.nextPaymentAttempt, }, }); // Alert your team after multiple failures if (payment.attemptCount >= 3) { await notifySlack({ channel: '#billing-alerts', message: `Payment failed ${payment.attemptCount} times`, }); }}On Trial Ending (Reminder)
If you want to perform actions shortly before a trial ends (e.g. reminder emails), use the onTrialEnding hook.
In Stripe, this typically corresponds to the customer.subscription.trial_will_end webhook event (commonly sent ~3 days before the trial ends). If you need this behavior, wire the event to your hook in packages/billing/stripe/src/stripe-plugin.ts.
export async function onTrialEnding(trial: { id: string; plan: string; referenceId: string; trialEnd: Date | null;}) { if (!trial.trialEnd) { return; } await sendEmail({ template: 'trial-ending-reminder', data: { plan: trial.plan, trialEnd: trial.trialEnd, daysRemaining: Math.ceil( (trial.trialEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24), ), }, });}On Trial Expired (No Conversion)
Use onTrialExpired for when a trial ends without converting to a paid subscription (cleanup, follow-ups, etc.).
On Subscription Canceled
If you want to perform any actions when a subscription is canceled, you can use the onSubscriptionCanceled hook:
export async function onSubscriptionCanceled(subscription: { id: string; plan: string; status: string; referenceId: string;}) { // Send cancellation survey await sendEmail({ template: 'cancellation-survey', data: { plan: subscription.plan }, }); // Track churn await trackEvent('subscription_canceled', { plan: subscription.plan, referenceId: subscription.referenceId, });}Common use cases
Sync to External Services
// on-subscription-created.tsexport async function onSubscriptionCreated(subscription) { // Update CRM await updateHubspotDeal({ referenceId: subscription.referenceId, plan: subscription.plan, status: 'won', }); // Track in analytics await posthog.capture({ distinctId: subscription.referenceId, event: 'subscription_started', properties: { plan: subscription.plan }, });}Error Handling
Hooks should handle errors gracefully - don't let a failed email prevent the subscription from completing:
export async function onSubscriptionCreated(subscription) { const logger = await getLogger(); try { await sendWelcomeEmail(subscription); } catch (error) { // Log but don't throw - the subscription should still succeed logger.error('Failed to send welcome email', { error: error.message, subscriptionId: subscription.id, }); } // Continue with other logic await trackEvent('subscription_created', subscription);}Polar Hook Examples
On Order Paid
Polar fires this when an order payment completes:
export async function onOrderPaid(order: { id: string; customerId: string;}) { const logger = await getLogger(); logger.info('Order paid', { orderId: order.id, customerId: order.customerId, }); // Your custom logic here await trackEvent('order_paid', { orderId: order.id, customerId: order.customerId, });}On Customer Created
Polar fires this when a new customer is created:
export async function onCustomerCreated(customer: { id: string; email: string;}) { const logger = await getLogger(); logger.info('Customer created in Polar', { customerId: customer.id, email: customer.email, }); // Sync to CRM, analytics, etc. await syncToCRM({ externalId: customer.id, email: customer.email, });}