Webhooks & Customer Portal

Lemon Squeezy webhook events, HMAC-SHA256 signature verification, and customer portal integration

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'}`,
    }
  }
}
Three security measures are in place:
  1. HMAC-SHA256 — The request body is hashed with the shared secret. Only Lemon Squeezy and your server know this secret.
  2. Timing-safe comparison — Uses crypto.timingSafeEqual() instead of === to prevent timing attacks that could leak the expected hash value.
  3. 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:
EventHandler FilePurpose
subscription_createdsubscription-created.tsNew subscription — create DB record, init credits, sync tier, send welcome email
subscription_updatedsubscription-updated.tsPlan change — detect upgrade/downgrade, adjust tier and credits
subscription_cancelledsubscription-cancelled.tsCancellation — set grace period, record cancellation date
subscription_resumedsubscription-resumed.tsReactivation — restore active status and feature access
subscription_expiredsubscription-expired.tsExpiration — revoke access, downgrade user to free tier
subscription_pausedsubscription-paused.tsPause — suspend features while preserving subscription data
subscription_unpausedsubscription-unpaused.tsUnpause — restore features after pause period
subscription_payment_successpayment-success.tsPayment received — reset monthly credits, update billing period
subscription_payment_failedpayment-failed.tsPayment failed — log failure, trigger dunning flow
subscription_payment_recoveredpayment-recovered.tsRecovery — restore access after failed payment resolution
order_createdorder-created.tsOne-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:
  1. Idempotency check — Looks up the subscription by subscriptionId. If it already exists, returns immediately without creating a duplicate.
  2. 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.
  3. Duplicate prevention — If the user already has an active subscription (active, on_trial, or paused), the handler prevents creating a second one.
  4. Subscription creation — Creates the Subscription record with all fields from the Lemon Squeezy payload.
  5. Tier sync — Calculates the tier from the variant ID and updates User.tier.
  6. Trial tracking — If the subscription status is on_trial, sets hasUsedTrial = true.
  7. Credit initialization — Calls updateUserCredits() to set creditBalance and creditsPerMonth based on the tier.
  8. Welcome email — Sends a non-blocking welcome email via the email service. Failures are logged but do not prevent subscription creation.

subscription_updated

Handles plan changes (upgrades, downgrades, billing period switches):
  1. Load current subscription from the database
  2. Compare variant IDs — If the variant changed, detect whether it is an upgrade or downgrade using getTierLevel()
  3. Update subscription record with new variant ID, status, and billing dates
  4. Sync user tier — Update User.tier to match the new subscription tier
  5. Adjust credits — In credit-based mode, update creditsPerMonth to the new tier's allocation

subscription_cancelled

Records the cancellation while preserving access until the billing period ends:
  1. Update subscription status to cancelled
  2. Set canceledAt to the current timestamp
  3. Preserve currentPeriodEnd — The user keeps paid access until this date
  4. The grace period handling is automatic — getSubscriptionTier() checks whether currentPeriodEnd is 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:
  1. 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.
  2. Parse the order payload — Extract variant_id from first_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.
  3. 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.
  4. Idempotency check — Look up BonusCreditPurchase by lemonSqueezyId (unique constraint). If the order was already processed, skip silently to prevent duplicate credit additions on webhook retries.
  5. Process purchase — Call purchaseBonusCredits() which atomically: increments User.bonusCredits, creates a BonusCreditPurchase record with configurable expiration date, and logs a purchase transaction in the credit ledger.
  6. Send confirmation email — Non-blocking email via sendTemplatedEmail() with the bonus_credits_purchased template. Email failures are logged but do not affect the purchase — the credits are already added.

Error Handling

The error handling strategy has two layers:
Layer 1 — Signature verification failures return non-200 status codes:
  • 401 for invalid or missing signatures
  • 400 for missing userId in custom data
  • 500 for 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 creditsResetAt to 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:
  1. Kit fetches the subscription from the Lemon Squeezy API using the stored subscriptionId
  2. The API response includes a urls.customer_portal field with a signed URL
  3. Kit validates the subscription ID format and environment before making the API call
  4. The URL is returned to the client for redirect

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_cancelled webhook)
  • 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.