Skip to main content

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

FilePurpose
template/app/api/stripe/webhook/route.tsStripe webhook handler
template/app/api/lemonsqueezy/webhook/route.tsLemonSqueezy webhook handler
template/app/api/polar/webhook/route.tsPolar webhook entry point
template/app/api/polar/webhook/router.tsPolar event routing
template/app/api/polar/webhook/handlers.tsPolar event handlers
template/app/api/polar/webhook/types.tsPolar webhook type definitions
template/app/api/polar/webhook/utils.tsPolar utility functions

Common Event Types

All providers normalize their events to the shared WebhookEventType enum:

WebhookEventTypeStripeLemonSqueezyPolar
SUBSCRIPTION_CREATEDcustomer.subscription.createdsubscription_createdsubscription.created
SUBSCRIPTION_UPDATEDcustomer.subscription.updatedsubscription_updatedsubscription.updated
SUBSCRIPTION_CANCELLEDcustomer.subscription.deletedsubscription_cancelledsubscription.canceled
PAYMENT_SUCCEEDEDpayment_intent.succeededorder_createdcheckout.succeeded
PAYMENT_FAILEDpayment_intent.payment_failed--checkout.failed
SUBSCRIPTION_PAYMENT_SUCCEEDEDinvoice.payment_succeededsubscription_payment_successinvoice.paid
SUBSCRIPTION_PAYMENT_FAILEDinvoice.payment_failedsubscription_payment_failedinvoice.payment_failed
SUBSCRIPTION_TRIAL_ENDINGcustomer.subscription.trial_will_endsubscription_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:

  1. Check if it is a sponsor ad subscription (special handling)
  2. Update subscription records via WebhookSubscriptionService
  3. Extract customer info and prepare email data
  4. Send appropriate notification email
  5. 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
}
}

All three providers detect sponsor ad subscriptions via metadata and route them to dedicated handlers:

ActionFunctionDescription
Payment confirmedhandleSponsorAdActivation()Sets ad status to pending review
Subscription cancelledhandleSponsorAdCancellation()Cancels the sponsor ad
Payment renewedhandleSponsorAdRenewal()Extends the ad end date

Best Practices

  1. Always verify signatures -- never process unverified webhooks
  2. Use raw body for signature verification -- parse JSON separately after verification
  3. Return 200 quickly -- payment providers retry on non-2xx responses
  4. Isolate email failures -- wrap email sending in nested try/catch
  5. Validate event types -- check against an allowlist before dispatching
  6. Log with structured data -- include event IDs and types in all log entries
  7. Use singleton providers -- getOrCreateStripeProvider() prevents multiple instances