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

HookWhen it's called
onSubscriptionCreatedNew subscription is created
onSubscriptionUpdatedSubscription plan or status changes
onSubscriptionCanceledSubscription is canceled
onSubscriptionDeletedSubscription is permanently deleted
onTrialStartedTrial period begins
onTrialEndingTrial is about to end
onTrialExpiredTrial ends without conversion
onPaymentFailedPayment attempt fails

Polar Hooks

HookWhen it's called
onSubscriptionCreatedNew subscription is created
onSubscriptionUpdatedSubscription status changes
onSubscriptionCanceledSubscription is canceled
onOrderPaidOrder payment completed
onCustomerCreatedNew customer record created
onCustomerStateChangedCustomer 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) via packages/billing/stripe/src/stripe-plugin.ts.
    • Payment failure is handled via Stripe events (see onPaymentFailed).
    • Trial ending reminders (onTrialEnding) are not guaranteed unless you wire customer.subscription.trial_will_end to 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.

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.ts
export 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,
});
}