Lifecycle Hooks
Handle subscription events with extensible lifecycle hooks
Run custom code when billing events occur by editing hook files in packages/billing/stripe/src/hooks/ or packages/billing/polar/src/hooks/. Each hook receives event data and executes your logic - send emails, update CRMs, provision resources, or track analytics.
This guide is part of the Billing & Subscriptions documentation.
Lifecycle hooks are async functions that execute in response to billing provider webhook events (subscription created, canceled, payment failed, etc.), enabling you to extend the billing system without modifying core code.
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, });}Decision Rules
Implement a hook when:
- You need to sync billing events to external services (CRM, analytics, email)
- You want to provision resources when subscriptions start
- You need to notify users about payment failures or trial endings
- You want audit logging for subscription changes
Keep hooks lightweight when:
- The operation is blocking (prefer async queues for slow operations)
- You're doing database writes that could fail
- The external service has high latency
If unsure: Start with logging. Add functionality incrementally and always wrap external calls in try/catch.
Common Pitfalls
- Throwing errors that block subscription updates: Wrap all external calls in try/catch. A failed email shouldn't prevent subscription state from updating.
- Non-idempotent hooks: Webhooks can be delivered multiple times. Use unique keys (subscription ID + event type) to detect duplicates.
- Forgetting to configure webhooks: Hooks only run when webhook events arrive. For Polar, this requires explicit webhook setup. For Stripe local dev, run
stripe:listen. - Heavy synchronous operations: Long-running tasks (video processing, large file operations) should be queued, not run inline.
- Missing logger initialization: Always
await getLogger()before logging. The logger may need async initialization. - Assuming
onTrialEndingfires automatically: For Stripe, you need to wirecustomer.subscription.trial_will_endto the hook manually in the plugin.
Frequently Asked Questions
How do I test hooks locally?
Can I add new hooks beyond the predefined ones?
Why isn't my hook running?
Should I use hooks for feature provisioning?
How do I handle retried webhook events?
Next: Customization →