Webhook Processing
Overview
The Ever Works Template processes incoming webhooks from three payment providers: Stripe, Lemon Squeezy, and Polar. Each provider has a dedicated API route that verifies signatures, normalizes event types to a common WebhookEventType enum, and dispatches to handler functions for subscription management, payment tracking, and email notifications.
Architecture
Source Files
| File | Purpose |
|---|---|
template/app/api/stripe/webhook/route.ts | Stripe webhook handler |
template/app/api/lemonsqueezy/webhook/route.ts | LemonSqueezy webhook handler |
template/app/api/polar/webhook/route.ts | Polar webhook entry point |
template/app/api/polar/webhook/router.ts | Polar event routing |
template/app/api/polar/webhook/handlers.ts | Polar event handlers |
template/app/api/polar/webhook/types.ts | Polar webhook type definitions |
template/app/api/polar/webhook/utils.ts | Polar utility functions |
Common Event Types
All providers normalize their events to the shared WebhookEventType enum:
| WebhookEventType | Stripe | LemonSqueezy | Polar |
|---|---|---|---|
SUBSCRIPTION_CREATED | customer.subscription.created | subscription_created | subscription.created |
SUBSCRIPTION_UPDATED | customer.subscription.updated | subscription_updated | subscription.updated |
SUBSCRIPTION_CANCELLED | customer.subscription.deleted | subscription_cancelled | subscription.canceled |
PAYMENT_SUCCEEDED | payment_intent.succeeded | order_created | checkout.succeeded |
PAYMENT_FAILED | payment_intent.payment_failed | -- | checkout.failed |
SUBSCRIPTION_PAYMENT_SUCCEEDED | invoice.payment_succeeded | subscription_payment_success | invoice.paid |
SUBSCRIPTION_PAYMENT_FAILED | invoice.payment_failed | subscription_payment_failed | invoice.payment_failed |
SUBSCRIPTION_TRIAL_ENDING | customer.subscription.trial_will_end | subscription_trial_will_end | -- |
Stripe Webhook Processing
Signature Verification
export async function POST(request: NextRequest) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'No signature provided' }, { status: 400 });
}
const stripeProvider = getOrCreateStripeProvider();
const webhookResult = await stripeProvider.handleWebhook(body, signature);
if (!webhookResult.received) {
return NextResponse.json({ error: 'Webhook not processed' }, { status: 400 });
}
// Route to handler based on event type
switch (webhookResult.type) {
case WebhookEventType.SUBSCRIPTION_CREATED:
await handleSubscriptionCreated(webhookResult.data);
break;
// ... other cases
}
return NextResponse.json({ received: true });
}
Handler Pattern (Stripe)
Each handler follows a consistent pattern:
- Check if it is a sponsor ad subscription (special handling)
- Update subscription records via
WebhookSubscriptionService - Extract customer info and prepare email data
- Send appropriate notification email
- Log success or failure
async function handleSubscriptionCreated(data: any) {
// Check for sponsor ad
if (isSponsorAdSubscription(data)) {
await handleSponsorAdActivation(data);
return;
}
// Update database
await webhookSubscriptionService.handleSubscriptionCreated(data);
// Send email notification
const customerInfo = extractCustomerInfo(data);
const emailData = {
customerName: customerInfo.customerName,
planName: getPlanName(priceId),
amount: formatAmount(unitAmount, currency),
// ...
};
await paymentEmailService.sendNewSubscriptionEmail(emailData);
}
LemonSqueezy Webhook Processing
Event Type Mapping
LemonSqueezy uses different event names that are mapped to the common enum:
function mapLemonSqueezyEventType(lemonsqueezyEventType: string): string {
const eventMapping: Record<string, string> = {
'subscription_created': WebhookEventType.SUBSCRIPTION_CREATED,
'subscription_updated': WebhookEventType.SUBSCRIPTION_UPDATED,
'subscription_cancelled': WebhookEventType.SUBSCRIPTION_CANCELLED,
'subscription_payment_success': WebhookEventType.SUBSCRIPTION_PAYMENT_SUCCEEDED,
'subscription_payment_failed': WebhookEventType.SUBSCRIPTION_PAYMENT_FAILED,
'order_created': WebhookEventType.PAYMENT_SUCCEEDED,
'order_refunded': WebhookEventType.REFUND_SUCCEEDED,
};
return eventMapping[lemonsqueezyEventType] || lemonsqueezyEventType;
}
Custom Data Access
LemonSqueezy uses custom_data and meta.custom_data for metadata (instead of Stripe's metadata):
function isSponsorAdSubscription(data: Record<string, unknown>): boolean {
const customData = data.custom_data as Record<string, string> | undefined;
const meta = data.meta as Record<string, unknown> | undefined;
const metaCustomData = meta?.custom_data as Record<string, string> | undefined;
return customData?.type === 'sponsor_ad' || metaCustomData?.type === 'sponsor_ad';
}
Polar Webhook Processing
Polar uses a more structured architecture with separate files for routing, handling, and types.
Router Pattern
// router.ts
function isValidWebhookEventType(eventType: string): eventType is WebhookEventType {
const allowedEventTypes: Set<WebhookEventType> = new Set([
WebhookEventType.SUBSCRIPTION_CREATED,
WebhookEventType.SUBSCRIPTION_UPDATED,
// ... all handled types
]);
return allowedEventTypes.has(eventType as WebhookEventType);
}
export async function routeWebhookEvent(
eventType: string,
data: PolarWebhookData
): Promise<void> {
if (!isValidWebhookEventType(eventType)) {
logger.warn('Invalid or unhandled webhook event type', { eventType });
return;
}
const eventHandlers: Partial<Record<WebhookEventType, Handler>> = {
[WebhookEventType.SUBSCRIPTION_CREATED]: handleSubscriptionCreated,
[WebhookEventType.SUBSCRIPTION_UPDATED]: handleSubscriptionUpdated,
// ... handler map
};
const handler = eventHandlers[eventType];
if (handler) await handler(data);
}
The router validates event types against an allowlist before dispatching, preventing unvalidated dynamic method calls.
Signature Verification (Polar)
const WEBHOOK_SIGNATURE_HEADER = 'webhook-signature';
const WEBHOOK_TIMESTAMP_HEADER = 'webhook-timestamp';
const WEBHOOK_ID_HEADER = 'webhook-id';
export async function POST(request: NextRequest): Promise<NextResponse> {
const bodyText = await request.text();
const body = JSON.parse(bodyText);
// Validate payload structure
if (!validateWebhookPayload(body)) {
return NextResponse.json({ error: 'Invalid webhook payload' }, { status: 400 });
}
// Verify signature with all three headers
const polarProvider = getOrCreatePolarProvider();
const webhookResult = await polarProvider.handleWebhook(
body,
signatureHeader,
bodyText, // Raw body for signature verification
timestampHeader,
webhookIdHeader
);
await routeWebhookEvent(webhookResult.type, webhookResult.data);
return NextResponse.json({ received: true });
}
Resilient Email Handling
Polar handlers wrap email operations in nested try/catch blocks so email failures never fail the webhook:
export async function handleSubscriptionCreated(data: PolarWebhookData): Promise<void> {
try {
await webhookSubscriptionService.handleSubscriptionCreated(data);
try {
// Email sending - isolated failure domain
const emailResult = await paymentEmailService.sendNewSubscriptionEmail(emailData);
} catch (emailError) {
// Log but don't fail the webhook
logger.warn('Skipping email notification due to configuration error');
}
} catch (error) {
logger.error('Error handling subscription created');
throw error; // Re-throw: database failures should fail the webhook
}
}
Sponsor Ad Handling
All three providers detect sponsor ad subscriptions via metadata and route them to dedicated handlers:
| Action | Function | Description |
|---|---|---|
| Payment confirmed | handleSponsorAdActivation() | Sets ad status to pending review |
| Subscription cancelled | handleSponsorAdCancellation() | Cancels the sponsor ad |
| Payment renewed | handleSponsorAdRenewal() | Extends the ad end date |
Best Practices
- Always verify signatures -- never process unverified webhooks
- Use raw body for signature verification -- parse JSON separately after verification
- Return 200 quickly -- payment providers retry on non-2xx responses
- Isolate email failures -- wrap email sending in nested try/catch
- Validate event types -- check against an allowlist before dispatching
- Log with structured data -- include event IDs and types in all log entries
- Use singleton providers --
getOrCreateStripeProvider()prevents multiple instances