Webhooks & Kundenportal

Lemon Squeezy Webhook-Events, HMAC-SHA256-Signaturverifizierung und Kundenportal-Integration

Webhooks sind das Rückgrat von Kits Zahlungssystem. Jede Abonnement-Änderung, Zahlung und Bestellung fließt durch einen einzigen Webhook-Endpunkt unter /api/webhooks/lemonsqueezy. Kit verarbeitet 11 Event-Typen durch modulare Handler, verifiziert jede Anfrage mit HMAC-SHA256-Signaturen und enthält das Kundenportal für Self-Service-Verwaltung.
Diese Seite behandelt die Webhook-Architektur, Event-Handling und die Kundenportal-Integration. Für die anfängliche Webhook-Konfiguration siehe Lemon Squeezy Setup.

Webhook-Architektur

Jede Webhook-Anfrage folgt dieser Pipeline:
Lemon Squeezy (Event tritt auf)
    |
    v
POST /api/webhooks/lemonsqueezy
    |
    |--- 1. Rate Limiting (Upstash Redis)
    |--- 2. Rohen Body lesen (für Signatur benötigt)
    |--- 3. X-Signature-Header abrufen
    |--- 4. HMAC-SHA256-Verifizierung
    |         |--- FEHLGESCHLAGEN → 401 Unauthorized
    |         |--- BESTANDEN → weiter
    |
    v
Payload parsen
    |--- event_name aus meta extrahieren
    |--- userId aus custom_data extrahieren
    |--- userId-Präsenz validieren
    |
    v
An Handler weiterleiten (switch auf event_name)
    |--- subscription_created  → handleSubscriptionCreated()
    |--- subscription_updated  → handleSubscriptionUpdated()
    |--- subscription_cancelled → handleSubscriptionCancelled()
    |--- ... (11 Events gesamt)
    |
    v
200 OK zurückgeben (immer, auch bei Handler-Fehlern)
Der Webhook-Route ist durch Rate Limiting und HMAC-Signaturverifizierung geschützt. Hier ist der vollständige 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 }
    )
  }
})

Signaturverifizierung

Jede Webhook-Anfrage enthält einen X-Signature-Header mit einem HMAC-SHA256-Hash des Anfrage-Bodys, signiert mit deinem Webhook-Secret. Kit verifiziert diese Signatur mit timing-sicherem Vergleich, um Timing-Angriffe zu verhindern:
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'}`,
    }
  }
}
Es gibt drei Sicherheitsmaßnahmen:
  1. HMAC-SHA256 — Der Anfrage-Body wird mit dem gemeinsamen Secret gehasht. Nur Lemon Squeezy und dein Server kennen dieses Secret.
  2. Timing-sicherer Vergleich — Verwendet crypto.timingSafeEqual() statt ===, um Timing-Angriffe zu verhindern, die den erwarteten Hash-Wert preisgeben könnten.
  3. Buffer-Längenvalidierung — Prüft, ob sowohl die Signatur als auch der berechnete Hash nicht leer sind, bevor der Vergleich erfolgt, um Randfälle mit fehlerhaften Headern zu verhindern.

Unterstützte Events

Kit verarbeitet 11 Lemon Squeezy-Webhook-Events. Jedes Event hat eine dedizierte Handler-Datei im Verzeichnis handlers/:
EventHandler-DateiZweck
subscription_createdsubscription-created.tsNeues Abonnement — DB-Datensatz erstellen, Credits initialisieren, Stufe synchronisieren, Willkommens-E-Mail senden
subscription_updatedsubscription-updated.tsPlanwechsel — Upgrade/Downgrade erkennen, Stufe und Credits anpassen
subscription_cancelledsubscription-cancelled.tsKündigung — Gnadenfrist setzen, Kündigungsdatum erfassen
subscription_resumedsubscription-resumed.tsReaktivierung — Aktiven Status und Feature-Zugriff wiederherstellen
subscription_expiredsubscription-expired.tsAblauf — Zugriff entziehen, Nutzer auf Free-Stufe downgraden
subscription_pausedsubscription-paused.tsPause — Features pausieren, Abonnement-Daten erhalten
subscription_unpausedsubscription-unpaused.tsPause aufheben — Features nach Pausenzeitraum wiederherstellen
subscription_payment_successpayment-success.tsZahlung erhalten — monatliche Credits zurücksetzen, Abrechnungszeitraum aktualisieren
subscription_payment_failedpayment-failed.tsZahlung fehlgeschlagen — Fehler protokollieren, Dunning-Fluss auslösen
subscription_payment_recoveredpayment-recovered.tsWiederherstellung — Zugriff nach Behebung fehlgeschlagener Zahlung wiederherstellen
order_createdorder-created.tsEinmalkauf — Bonus-Credit-Aufladepakete verarbeiten

Event-Handler-Struktur

Jeder Handler folgt einem einheitlichen Muster: den geparsten Payload und die userId empfangen, Datenbankoperationen durchführen und bei nicht behebbaren Fehlern werfen. Die Route fängt Handler-Fehler ab und gibt trotzdem 200 zurück, um Wiederholungen zu verhindern.
apps/boilerplate/src/app/api/webhooks/lemonsqueezy/
├── route.ts                    # Einstiegspunkt, Signaturverifizierung, Routing
├── handlers/
│   ├── index.ts                # Barrel-Export für alle Handler
│   ├── 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-Extraktion
└── types.ts                    # SubscriptionAttributes, Webhook-Typen

Wichtige Event-Flüsse

subscription_created

Der komplexeste Handler — er erstellt das Abonnement, initialisiert Credits, synchronisiert die Stufe und sendet eine Willkommens-E-Mail:
  1. Idempotenzprüfung — Sucht das Abonnement anhand der subscriptionId. Wenn es bereits existiert, wird sofort zurückgekehrt, ohne ein Duplikat zu erstellen.
  2. Nutzersuche — Versucht zuerst die Datenbank-ID, fällt dann auf die Clerk-ID zurück. Dies verarbeitet beide ID-Formate, die in custom_data übergeben werden könnten.
  3. Duplikatverhinderung — Wenn der Nutzer bereits ein aktives Abonnement hat (active, on_trial oder paused), verhindert der Handler die Erstellung eines zweiten.
  4. Abonnement-Erstellung — Erstellt den Subscription-Datensatz mit allen Feldern aus dem Lemon Squeezy-Payload.
  5. Stufensynchronisierung — Berechnet die Stufe aus der Variant-ID und aktualisiert User.tier.
  6. Trial-Tracking — Wenn der Abonnement-Status on_trial ist, wird hasUsedTrial = true gesetzt.
  7. Credit-Initialisierung — Ruft updateUserCredits() auf, um creditBalance und creditsPerMonth basierend auf der Stufe zu setzen.
  8. Willkommens-E-Mail — Sendet eine nicht-blockierende Willkommens-E-Mail über den E-Mail-Dienst. Fehler werden protokolliert, verhindern aber nicht die Abonnement-Erstellung.

subscription_updated

Verarbeitet Planwechsel (Upgrades, Downgrades, Abrechnungszeitraum-Wechsel):
  1. Aktuelles Abonnement aus der Datenbank laden
  2. Variant-IDs vergleichen — Wenn sich die Variante geändert hat, erkennen, ob es sich um ein Upgrade oder Downgrade handelt, mit getTierLevel()
  3. Abonnement-Datensatz mit neuer Variant-ID, Status und Abrechnungsdaten aktualisieren
  4. Nutzer-Stufe synchronisierenUser.tier auf die neue Abonnement-Stufe aktualisieren
  5. Credits anpassen — Im credit-basierten Modus creditsPerMonth auf die Zuteilung der neuen Stufe aktualisieren

subscription_cancelled

Erfasst die Kündigung und bewahrt dabei den Zugriff bis zum Ende des Abrechnungszeitraums:
  1. Abonnement-Status auf cancelled aktualisieren
  2. canceledAt auf den aktuellen Zeitstempel setzen
  3. currentPeriodEnd beibehalten — Der Nutzer behält den bezahlten Zugriff bis zu diesem Datum
  4. Die Gnadenfrist-Verarbeitung ist automatisch — getSubscriptionTier() prüft, ob currentPeriodEnd für gekündigte Abonnements in der Zukunft liegt

order_created

Verarbeitet Einmalkäufe, speziell Bonus-Credit-Aufladungen. Dieser Handler verwendet die Service-Layer-Funktion purchaseBonusCredits() für die Kernverarbeitung:
  1. Preismodell-Guard — Wenn das credit-basierte Modell nicht aktiv ist (isCreditBased() gibt false zurück), wird das Event stillschweigend übersprungen. Dies ist kein Fehler — der Webhook kann für Abonnement-Bestellungen im klassischen SaaS-Modus feuern.
  2. Bestellungs-Payload parsenvariant_id aus first_order_item extrahieren. Die Variant-ID wird explizit in einen String konvertiert, da Lemon Squeezy sie als Zahl sendet, während Umgebungsvariablen Variant-IDs als Strings speichern.
  3. Einem Bonus-Credit-Paket zuordnen — Alle Stufen-Pakete aus der zentralen Pricing-Config flachklopfen und nach Variant-ID abgleichen. Nicht übereinstimmende Bestellungen werden stillschweigend übersprungen — es sind Abonnement-Bestellungen, keine Bonus-Credit-Käufe.
  4. IdempotenzprüfungBonusCreditPurchase nach lemonSqueezyId (Unique Constraint) suchen. Wenn die Bestellung bereits verarbeitet wurde, stillschweigend überspringen, um doppelte Credit-Hinzufügungen bei Webhook-Wiederholungen zu verhindern.
  5. Kauf verarbeitenpurchaseBonusCredits() aufrufen, das atomar: User.bonusCredits erhöht, einen BonusCreditPurchase-Datensatz mit konfigurierbarem Ablaufdatum erstellt und eine purchase-Transaktion im Credit-Ledger protokolliert.
  6. Bestätigungs-E-Mail senden — Nicht-blockierende E-Mail über sendTemplatedEmail() mit der bonus_credits_purchased-Vorlage. E-Mail-Fehler werden protokolliert, beeinflussen aber den Kauf nicht — die Credits sind bereits hinzugefügt.

Fehlerbehandlung

Die Fehlerbehandlungsstrategie hat zwei Ebenen:
Ebene 1 — Signaturverifizierungsfehler geben Nicht-200-Statuscodes zurück:
  • 401 bei ungültigen oder fehlenden Signaturen
  • 400 bei fehlender userId in den benutzerdefinierten Daten
  • 500 bei fehlender Webhook-Secret-Konfiguration
Ebene 2 — Handler-Fehler werden abgefangen und protokolliert, aber der Endpunkt gibt trotzdem 200 zurück:
typescript
try {
  switch (eventName) {
    case 'subscription_created':
      await handleSubscriptionCreated(payload, userId)
      break
    // ... andere Events
  }
  return NextResponse.json({ received: true }, { status: 200 })
} catch (handlerError) {
  logWebhookError(`Error in handler for ${eventName}:`, handlerError)
  // 200 zurückgeben, um Wiederholungen zu verhindern — Fehler wird protokolliert
  return NextResponse.json(
    { received: true, error: 'Handler error logged' },
    { status: 200 }
  )
}

Idempotenz-Muster

Alle Handler folgen Idempotenz-Mustern, um doppelte Zustellungen sicher zu verarbeiten:
  • subscription_created — Prüft, ob Abonnement bereits nach subscriptionId existiert
  • subscription_updated — Vergleicht aktuellen Zustand, bevor Änderungen angewendet werden
  • payment_success — Verwendet creditsResetAt, um zu erkennen, ob Credits bereits zurückgesetzt wurden

Kundenportal

Lemon Squeezy stellt ein vollständig gehostetes Kundenportal bereit, über das Nutzer ihre Abonnements verwalten können, ohne dass eine benutzerdefinierte UI auf deiner Seite erforderlich ist.

Integration

Auf das Portal wird über eine signierte URL zugegriffen, die von der Lemon Squeezy API abgerufen wird. Kit kapselt dies in der Funktion getCustomerPortal():
typescript
import { getCustomerPortal } from '@/lib/payments/subscription'

// In einer API-Route
export async function GET(request: NextRequest) {
  const userId = getCurrentUserId(request)
  const { portalUrl } = await getCustomerPortal(userId)

  return NextResponse.redirect(portalUrl)
}

Portal-URL-Generierung

Die Portal-URL wird aus dem urls.customer_portal-Attribut des Abonnements extrahiert. Diese URL wird von Lemon Squeezy vorabbzeichnet und ist 24 Stunden gültig:
  1. Kit ruft das Abonnement von der Lemon Squeezy API mithilfe der gespeicherten subscriptionId ab
  2. Die API-Antwort enthält ein Feld urls.customer_portal mit einer signierten URL
  3. Kit validiert das Format der Abonnement-ID und die Umgebung, bevor der API-Aufruf erfolgt
  4. Die URL wird dem Client zur Weiterleitung zurückgegeben

Portal-Funktionen

Das Lemon Squeezy-Kundenportal ermöglicht Nutzern:
  • Rechnungen einsehen — PDF-Rechnungen für alle Zahlungen herunterladen
  • Zahlungsmethode aktualisieren — Kreditkarte wechseln oder PayPal hinzufügen
  • Abonnement kündigen — Self-Service-Kündigung (löst subscription_cancelled-Webhook aus)
  • Abrechnungsverlauf einsehen — Alle vergangenen Abbuchungen und Zahlungsstatus anzeigen
  • Rechnungsadresse aktualisieren — Adresse für die Steuerberechnung ändern
Für diese Features ist keine zusätzliche UI-Implementierung erforderlich.

Webhooks testen

Lemon Squeezy Test-Modus

Der einfachste Ansatz: Aktiviere den Test-Modus im Dashboard und erstelle Test-Abonnements. Alle Events feuern mit Testdaten.

Lokale Entwicklung mit Tunnel

Für lokale Webhook-Tests:
1

Entwicklungsserver starten

bash
pnpm dev:boilerplate
2

Tunnel starten

bash
ngrok http 3000
3

Webhook-URL aktualisieren

Setze die Webhook-URL im Lemon Squeezy-Dashboard auf deine Tunnel-URL:
https://abc123.ngrok.io/api/webhooks/lemonsqueezy
4

Test-Abonnement erstellen

Verwende eine Test-Kreditkarte, um den vollständigen Webhook-Fluss auszulösen. Überprüfe deine Terminal-Ausgabe auf Webhook-Verarbeitungs-Logs.

Zustellungsprotokoll

Lemon Squeezy zeigt ein Zustellungsprotokoll für jeden Webhook-Endpunkt im Dashboard (Settings > Webhooks). Jede Zustellung zeigt:
  • Zeitstempel
  • Event-Typ
  • HTTP-Antwort-Status
  • Antworttext
  • Anzahl der Wiederholungsversuche
Wenn eine Zustellung fehlschlägt, wiederholt Lemon Squeezy mit exponentiellem Backoff. Verwende das Zustellungsprotokoll, um Webhook-Probleme zu debuggen.