Webhooks are the backbone of Kit's payment system. Every subscription change, payment, and order flows through a single webhook endpoint at
/api/webhooks/lemonsqueezy. Kit processes 11 event types through modular handlers, verifies every request with HMAC-SHA256 signatures, and includes the customer portal for self-service management.This page covers the webhook architecture, event handling, and customer portal integration. For initial webhook configuration, see Lemon Squeezy Setup.
Webhook Architecture
Every webhook request follows this pipeline:
Lemon Squeezy (event occurs)
|
v
POST /api/webhooks/lemonsqueezy
|
|--- 1. Rate limiting (Upstash Redis)
|--- 2. Read raw body (needed for signature)
|--- 3. Get X-Signature header
|--- 4. HMAC-SHA256 verification
| |--- FAIL → 401 Unauthorized
| |--- PASS → continue
|
v
Parse Payload
|--- Extract event_name from meta
|--- Extract userId from custom_data
|--- Validate userId present
|
v
Route to Handler (switch on event_name)
|--- subscription_created → handleSubscriptionCreated()
|--- subscription_updated → handleSubscriptionUpdated()
|--- subscription_cancelled → handleSubscriptionCancelled()
|--- ... (11 total events)
|
v
Return 200 OK (always, even on handler errors)
The webhook route is protected by rate limiting and HMAC signature verification. Here is the complete route handler:
src/app/api/webhooks/lemonsqueezy/route.ts
/**
* Lemon Squeezy Webhook Handler
* Processes subscription and payment events with modular handlers
*/
import { NextRequest, NextResponse } from 'next/server'
import type { WebhookPayload } from '@/lib/payments/types'
import { verifyWebhookSignature } from './lib/signature-verification'
import {
extractUserId,
logWebhookEvent,
logWebhookError,
} from './lib/webhook-helpers'
import {
handleSubscriptionCreated,
handleSubscriptionUpdated,
handleSubscriptionCancelled,
handleSubscriptionResumed,
handleSubscriptionExpired,
handleSubscriptionPaused,
handleSubscriptionUnpaused,
handlePaymentFailed,
handlePaymentSuccess,
handlePaymentRecovered,
handleOrderCreated,
} from './handlers'
import { withRateLimit } from '@/lib/security/rate-limit-middleware'
// Disable body parsing, we need the raw body for signature verification
export const runtime = 'nodejs'
export const POST = withRateLimit('webhooks', async (request: NextRequest) => {
try {
// Verify webhook signature
const verificationResult = await verifyWebhookSignature(request)
if (!verificationResult.isValid) {
logWebhookError(
verificationResult.error || 'Signature verification failed'
)
return NextResponse.json(
{ error: verificationResult.error },
{
status: verificationResult.error?.includes('not configured')
? 500
: 401,
}
)
}
// Parse the webhook payload
const payload: WebhookPayload = JSON.parse(verificationResult.rawBody!)
// Extract event data
const eventName = payload.meta.event_name
const userId = extractUserId(payload)
// Log incoming webhook event with more details
logWebhookEvent(`Webhook received: ${eventName}`, {
eventName,
subscriptionId: payload.data?.id,
userId,
customData: payload.meta.custom_data,
testMode: payload.meta.test_mode,
timestamp: new Date().toISOString(),
})
if (!userId) {
logWebhookError('No userId in custom data', {
eventName,
customData: payload.meta.custom_data,
})
return NextResponse.json(
{ error: 'No userId in custom data' },
{ status: 400 }
)
}
// Handle different event types with proper error handling
try {
switch (eventName) {
case 'subscription_created':
await handleSubscriptionCreated(payload, String(userId))
break
case 'subscription_updated':
await handleSubscriptionUpdated(payload, String(userId))
break
case 'subscription_cancelled':
await handleSubscriptionCancelled(payload, String(userId))
break
case 'subscription_resumed':
await handleSubscriptionResumed(payload, String(userId))
break
case 'subscription_expired':
await handleSubscriptionExpired(payload, String(userId))
break
case 'subscription_paused':
await handleSubscriptionPaused(payload, String(userId))
break
case 'subscription_unpaused':
await handleSubscriptionUnpaused(payload, String(userId))
break
case 'subscription_payment_failed':
await handlePaymentFailed(payload, String(userId))
break
case 'subscription_payment_success':
await handlePaymentSuccess(payload, String(userId))
break
case 'subscription_payment_recovered':
await handlePaymentRecovered(payload, String(userId))
break
case 'order_created':
await handleOrderCreated(payload, String(userId))
break
default:
logWebhookEvent(`Unhandled webhook event: ${eventName}`)
}
logWebhookEvent(`Webhook processed successfully: ${eventName}`)
return NextResponse.json({ received: true }, { status: 200 })
} catch (handlerError) {
logWebhookError(`Error in handler for ${eventName}:`, handlerError)
// Return 200 to prevent Lemon Squeezy from retrying
// but log the error for debugging
return NextResponse.json(
{ received: true, error: 'Handler error logged' },
{ status: 200 }
)
}
} catch (error) {
logWebhookError('Webhook processing error:', error)
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
)
}
})
Signature Verification
Every webhook request includes an
X-Signature header containing an HMAC-SHA256 hash of the request body, signed with your webhook secret. Kit verifies this signature using timing-safe comparison to prevent timing attacks:src/app/api/webhooks/lemonsqueezy/lib/signature-verification.ts
export async function verifyWebhookSignature(
request: NextRequest
): Promise<VerificationResult> {
try {
// Get the raw body
const rawBody = await request.text()
// Get the signature from headers (case-insensitive)
const signatureHeader =
request.headers.get('x-signature') || request.headers.get('X-Signature')
if (!signatureHeader) {
return {
isValid: false,
error: 'No signature provided',
}
}
// Verify webhook secret is configured
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET
if (!secret) {
return {
isValid: false,
error: 'Webhook secret not configured',
}
}
// Convert signature from hex string to Buffer
const signature = Buffer.from(signatureHeader, 'hex')
// Create HMAC hash and convert to Buffer
const hmac = Buffer.from(
crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
'hex'
)
// Verify both buffers have same length before comparing
if (signature.length === 0 || hmac.length === 0) {
return {
isValid: false,
error: 'Invalid signature format',
}
}
// Compare signatures using timing-safe comparison
const signatureValid =
signature.length === hmac.length &&
crypto.timingSafeEqual(hmac, signature)
if (!signatureValid) {
return {
isValid: false,
error: 'Signature verification failed',
}
}
return {
isValid: true,
rawBody,
}
} catch (error) {
return {
isValid: false,
error: `Verification error: ${error instanceof Error ? error.message : 'Unknown error'}`,
}
}
}
Never process a webhook without verifying the signature first. Without verification, an attacker could send fabricated webhook payloads to create fake subscriptions, grant unauthorized access, or manipulate credit balances. Kit rejects all requests with invalid or missing signatures.
Three security measures are in place:
- HMAC-SHA256 — The request body is hashed with the shared secret. Only Lemon Squeezy and your server know this secret.
- Timing-safe comparison — Uses
crypto.timingSafeEqual()instead of===to prevent timing attacks that could leak the expected hash value. - Buffer length validation — Checks that both the signature and computed hash are non-empty before comparison, preventing edge cases with malformed headers.
Supported Events
Kit handles 11 Lemon Squeezy webhook events. Each event has a dedicated handler file in the
handlers/ directory:| Event | Handler File | Purpose |
|---|---|---|
subscription_created | subscription-created.ts | New subscription — create DB record, init credits, sync tier, send welcome email |
subscription_updated | subscription-updated.ts | Plan change — detect upgrade/downgrade, adjust tier and credits |
subscription_cancelled | subscription-cancelled.ts | Cancellation — set grace period, record cancellation date |
subscription_resumed | subscription-resumed.ts | Reactivation — restore active status and feature access |
subscription_expired | subscription-expired.ts | Expiration — revoke access, downgrade user to free tier |
subscription_paused | subscription-paused.ts | Pause — suspend features while preserving subscription data |
subscription_unpaused | subscription-unpaused.ts | Unpause — restore features after pause period |
subscription_payment_success | payment-success.ts | Payment received — reset monthly credits, update billing period |
subscription_payment_failed | payment-failed.ts | Payment failed — log failure, trigger dunning flow |
subscription_payment_recovered | payment-recovered.ts | Recovery — restore access after failed payment resolution |
order_created | order-created.ts | One-time purchase — process bonus credit top-up packages |
Event Handler Structure
Each handler follows a consistent pattern: receive the parsed payload and userId, perform database operations, and throw on unrecoverable errors. The route catches handler errors and still returns
200 to prevent retries.apps/boilerplate/src/app/api/webhooks/lemonsqueezy/
├── route.ts # Entry point, signature verification, routing
├── handlers/
│ ├── index.ts # Barrel export for all handlers
│ ├── subscription-created.ts
│ ├── subscription-updated.ts
│ ├── subscription-cancelled.ts
│ ├── subscription-resumed.ts
│ ├── subscription-expired.ts
│ ├── subscription-paused.ts
│ ├── subscription-unpaused.ts
│ ├── payment-success.ts
│ ├── payment-failed.ts
│ ├── payment-recovered.ts
│ └── order-created.ts
├── lib/
│ ├── signature-verification.ts
│ └── webhook-helpers.ts # Logging, userId extraction
└── types.ts # SubscriptionAttributes, webhook types
Key Event Flows
subscription_created
The most complex handler — it creates the subscription, initializes credits, syncs the tier, and sends a welcome email:
- Idempotency check — Looks up the subscription by
subscriptionId. If it already exists, returns immediately without creating a duplicate. - User lookup — Tries the database ID first, then falls back to Clerk ID. This handles both ID formats that might be passed in
custom_data. - Duplicate prevention — If the user already has an active subscription (
active,on_trial, orpaused), the handler prevents creating a second one. - Subscription creation — Creates the
Subscriptionrecord with all fields from the Lemon Squeezy payload. - Tier sync — Calculates the tier from the variant ID and updates
User.tier. - Trial tracking — If the subscription status is
on_trial, setshasUsedTrial = true. - Credit initialization — Calls
updateUserCredits()to setcreditBalanceandcreditsPerMonthbased on the tier. - Welcome email — Sends a non-blocking welcome email via the email service. Failures are logged but do not prevent subscription creation.
Kit's webhook handlers are idempotent — processing the same event twice produces the same result. The
subscription_created handler checks for existing subscriptions before creating. This is critical because Lemon Squeezy may retry webhooks if the initial delivery appears to fail.subscription_updated
Handles plan changes (upgrades, downgrades, billing period switches):
- Load current subscription from the database
- Compare variant IDs — If the variant changed, detect whether it is an upgrade or downgrade using
getTierLevel() - Update subscription record with new variant ID, status, and billing dates
- Sync user tier — Update
User.tierto match the new subscription tier - Adjust credits — In credit-based mode, update
creditsPerMonthto the new tier's allocation
subscription_cancelled
Records the cancellation while preserving access until the billing period ends:
- Update subscription status to
cancelled - Set
canceledAtto the current timestamp - Preserve
currentPeriodEnd— The user keeps paid access until this date - The grace period handling is automatic —
getSubscriptionTier()checks whethercurrentPeriodEndis in the future for cancelled subscriptions
order_created
Handles one-time purchases, specifically bonus credit top-ups. This handler uses the
purchaseBonusCredits() service layer function for the core processing:- Pricing model guard — If the credit-based model is not active (
isCreditBased()returns false), the event is skipped silently. This is not an error — the webhook may fire for subscription orders in classic SaaS mode. - Parse the order payload — Extract
variant_idfromfirst_order_item. The variant ID is explicitly converted to a string because Lemon Squeezy sends it as a number, while environment variables store variant IDs as strings. - Match to a bonus credit package — Flatten all tier packages from the centralized pricing config and match by variant ID. Non-matching orders are silently skipped — they are subscription orders, not bonus credit purchases.
- Idempotency check — Look up
BonusCreditPurchasebylemonSqueezyId(unique constraint). If the order was already processed, skip silently to prevent duplicate credit additions on webhook retries. - Process purchase — Call
purchaseBonusCredits()which atomically: incrementsUser.bonusCredits, creates aBonusCreditPurchaserecord with configurable expiration date, and logs apurchasetransaction in the credit ledger. - Send confirmation email — Non-blocking email via
sendTemplatedEmail()with thebonus_credits_purchasedtemplate. Email failures are logged but do not affect the purchase — the credits are already added.
The
order_created handler uses the Lemon Squeezy order ID as a unique constraint (lemonSqueezyId) on the BonusCreditPurchase model. If Lemon Squeezy retries the webhook, the handler detects the existing record and returns without adding duplicate credits.Error Handling
Kit always returns HTTP 200 to Lemon Squeezy, even when a handler encounters an internal error. This prevents Lemon Squeezy from retrying the webhook, which could cause duplicate processing. Internal errors are logged for debugging.
The error handling strategy has two layers:
Layer 1 — Signature verification failures return non-200 status codes:
401for invalid or missing signatures400for missing userId in custom data500for missing webhook secret configuration
Layer 2 — Handler errors are caught and logged, but the endpoint still returns
200:typescript
try {
switch (eventName) {
case 'subscription_created':
await handleSubscriptionCreated(payload, userId)
break
// ... other events
}
return NextResponse.json({ received: true }, { status: 200 })
} catch (handlerError) {
logWebhookError(`Error in handler for ${eventName}:`, handlerError)
// Return 200 to prevent retries — error is logged
return NextResponse.json(
{ received: true, error: 'Handler error logged' },
{ status: 200 }
)
}
Idempotency Patterns
All handlers follow idempotency patterns to safely handle duplicate deliveries:
- subscription_created — Checks if subscription already exists by
subscriptionId - subscription_updated — Compares current state before applying changes
- payment_success — Uses
creditsResetAtto detect if credits were already reset
Customer Portal
Lemon Squeezy provides a fully hosted customer portal where users can manage their subscriptions without any custom UI on your side.
Integration
The portal is accessed through a signed URL fetched from the Lemon Squeezy API. Kit wraps this in the
getCustomerPortal() function:typescript
import { getCustomerPortal } from '@/lib/payments/subscription'
// In an API route
export async function GET(request: NextRequest) {
const userId = getCurrentUserId(request)
const { portalUrl } = await getCustomerPortal(userId)
return NextResponse.redirect(portalUrl)
}
Portal URL Generation
The portal URL is extracted from the subscription's
urls.customer_portal attribute. This URL is pre-signed by Lemon Squeezy and valid for 24 hours:- Kit fetches the subscription from the Lemon Squeezy API using the stored
subscriptionId - The API response includes a
urls.customer_portalfield with a signed URL - Kit validates the subscription ID format and environment before making the API call
- The URL is returned to the client for redirect
Each portal URL is valid for 24 hours from generation. If a user bookmarks the URL, it will stop working after expiration. Generate a fresh URL each time the user clicks "Manage Subscription" in your UI.
Portal Capabilities
The Lemon Squeezy customer portal allows users to:
- View invoices — Download PDF invoices for all payments
- Update payment method — Change credit card or add PayPal
- Cancel subscription — Self-service cancellation (triggers
subscription_cancelledwebhook) - View billing history — See all past charges and payment status
- Update billing address — Change address for tax calculation
No additional UI implementation is needed for these features.
Testing Webhooks
Lemon Squeezy Test Mode
The simplest approach: enable test mode in the dashboard and create test subscriptions. All events will fire with test data.
Local Development with Tunnel
For local webhook testing:
1
Start your development server
bash
pnpm dev:boilerplate
2
Start a tunnel
bash
ngrok http 3000
3
Update webhook URL
Set the webhook URL in the Lemon Squeezy dashboard to your tunnel URL:
https://abc123.ngrok.io/api/webhooks/lemonsqueezy
4
Create a test subscription
Use a test credit card to trigger the full webhook flow. Check your terminal output for webhook processing logs.
Delivery Log
Lemon Squeezy shows a delivery log for each webhook endpoint in the dashboard (Settings > Webhooks). Each delivery shows:
- Timestamp
- Event type
- HTTP response status
- Response body
- Number of retry attempts
If a delivery fails, Lemon Squeezy retries with exponential backoff. Use the delivery log to debug webhook issues.
Kit logs all webhook events to the console with detailed context (event name, subscription ID, user ID, timestamp). Check your terminal or server logs to trace the full webhook processing pipeline.