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'}`,
}
}
}
Verarbeite niemals einen Webhook ohne vorherige Signaturverifizierung. Ohne Verifizierung könnte ein Angreifer gefälschte Webhook-Payloads senden, um gefälschte Abonnements zu erstellen, unbefugten Zugriff zu gewähren oder Credit-Guthaben zu manipulieren. Kit lehnt alle Anfragen mit ungültigen oder fehlenden Signaturen ab.
Es gibt drei Sicherheitsmaßnahmen:
- HMAC-SHA256 — Der Anfrage-Body wird mit dem gemeinsamen Secret gehasht. Nur Lemon Squeezy und dein Server kennen dieses Secret.
- Timing-sicherer Vergleich — Verwendet
crypto.timingSafeEqual()statt===, um Timing-Angriffe zu verhindern, die den erwarteten Hash-Wert preisgeben könnten. - 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/:| Event | Handler-Datei | Zweck |
|---|---|---|
subscription_created | subscription-created.ts | Neues Abonnement — DB-Datensatz erstellen, Credits initialisieren, Stufe synchronisieren, Willkommens-E-Mail senden |
subscription_updated | subscription-updated.ts | Planwechsel — Upgrade/Downgrade erkennen, Stufe und Credits anpassen |
subscription_cancelled | subscription-cancelled.ts | Kündigung — Gnadenfrist setzen, Kündigungsdatum erfassen |
subscription_resumed | subscription-resumed.ts | Reaktivierung — Aktiven Status und Feature-Zugriff wiederherstellen |
subscription_expired | subscription-expired.ts | Ablauf — Zugriff entziehen, Nutzer auf Free-Stufe downgraden |
subscription_paused | subscription-paused.ts | Pause — Features pausieren, Abonnement-Daten erhalten |
subscription_unpaused | subscription-unpaused.ts | Pause aufheben — Features nach Pausenzeitraum wiederherstellen |
subscription_payment_success | payment-success.ts | Zahlung erhalten — monatliche Credits zurücksetzen, Abrechnungszeitraum aktualisieren |
subscription_payment_failed | payment-failed.ts | Zahlung fehlgeschlagen — Fehler protokollieren, Dunning-Fluss auslösen |
subscription_payment_recovered | payment-recovered.ts | Wiederherstellung — Zugriff nach Behebung fehlgeschlagener Zahlung wiederherstellen |
order_created | order-created.ts | Einmalkauf — 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:
- Idempotenzprüfung — Sucht das Abonnement anhand der
subscriptionId. Wenn es bereits existiert, wird sofort zurückgekehrt, ohne ein Duplikat zu erstellen. - 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. - Duplikatverhinderung — Wenn der Nutzer bereits ein aktives Abonnement hat (
active,on_trialoderpaused), verhindert der Handler die Erstellung eines zweiten. - Abonnement-Erstellung — Erstellt den
Subscription-Datensatz mit allen Feldern aus dem Lemon Squeezy-Payload. - Stufensynchronisierung — Berechnet die Stufe aus der Variant-ID und aktualisiert
User.tier. - Trial-Tracking — Wenn der Abonnement-Status
on_trialist, wirdhasUsedTrial = truegesetzt. - Credit-Initialisierung — Ruft
updateUserCredits()auf, umcreditBalanceundcreditsPerMonthbasierend auf der Stufe zu setzen. - Willkommens-E-Mail — Sendet eine nicht-blockierende Willkommens-E-Mail über den E-Mail-Dienst. Fehler werden protokolliert, verhindern aber nicht die Abonnement-Erstellung.
Kits Webhook-Handler sind idempotent — das zweifache Verarbeiten desselben Events erzeugt dasselbe Ergebnis. Der
subscription_created-Handler prüft vor dem Erstellen auf vorhandene Abonnements. Dies ist entscheidend, da Lemon Squeezy Webhooks wiederholen kann, wenn die erste Zustellung scheinbar fehlgeschlagen ist.subscription_updated
Verarbeitet Planwechsel (Upgrades, Downgrades, Abrechnungszeitraum-Wechsel):
- Aktuelles Abonnement aus der Datenbank laden
- Variant-IDs vergleichen — Wenn sich die Variante geändert hat, erkennen, ob es sich um ein Upgrade oder Downgrade handelt, mit
getTierLevel() - Abonnement-Datensatz mit neuer Variant-ID, Status und Abrechnungsdaten aktualisieren
- Nutzer-Stufe synchronisieren —
User.tierauf die neue Abonnement-Stufe aktualisieren - Credits anpassen — Im credit-basierten Modus
creditsPerMonthauf die Zuteilung der neuen Stufe aktualisieren
subscription_cancelled
Erfasst die Kündigung und bewahrt dabei den Zugriff bis zum Ende des Abrechnungszeitraums:
- Abonnement-Status auf
cancelledaktualisieren canceledAtauf den aktuellen Zeitstempel setzencurrentPeriodEndbeibehalten — Der Nutzer behält den bezahlten Zugriff bis zu diesem Datum- Die Gnadenfrist-Verarbeitung ist automatisch —
getSubscriptionTier()prüft, obcurrentPeriodEndfü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:- 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. - Bestellungs-Payload parsen —
variant_idausfirst_order_itemextrahieren. Die Variant-ID wird explizit in einen String konvertiert, da Lemon Squeezy sie als Zahl sendet, während Umgebungsvariablen Variant-IDs als Strings speichern. - 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.
- Idempotenzprüfung —
BonusCreditPurchasenachlemonSqueezyId(Unique Constraint) suchen. Wenn die Bestellung bereits verarbeitet wurde, stillschweigend überspringen, um doppelte Credit-Hinzufügungen bei Webhook-Wiederholungen zu verhindern. - Kauf verarbeiten —
purchaseBonusCredits()aufrufen, das atomar:User.bonusCreditserhöht, einenBonusCreditPurchase-Datensatz mit konfigurierbarem Ablaufdatum erstellt und einepurchase-Transaktion im Credit-Ledger protokolliert. - Bestätigungs-E-Mail senden — Nicht-blockierende E-Mail über
sendTemplatedEmail()mit derbonus_credits_purchased-Vorlage. E-Mail-Fehler werden protokolliert, beeinflussen aber den Kauf nicht — die Credits sind bereits hinzugefügt.
Der
order_created-Handler verwendet die Lemon Squeezy-Bestell-ID als Unique Constraint (lemonSqueezyId) im BonusCreditPurchase-Modell. Wenn Lemon Squeezy den Webhook wiederholt, erkennt der Handler den vorhandenen Datensatz und kehrt zurück, ohne doppelte Credits hinzuzufügen.Fehlerbehandlung
Kit gibt Lemon Squeezy immer HTTP 200 zurück, auch wenn ein Handler auf einen internen Fehler stößt. Dies verhindert, dass Lemon Squeezy den Webhook wiederholt, was zu einer doppelten Verarbeitung führen könnte. Interne Fehler werden für das Debugging protokolliert.
Die Fehlerbehandlungsstrategie hat zwei Ebenen:
Ebene 1 — Signaturverifizierungsfehler geben Nicht-200-Statuscodes zurück:
401bei ungültigen oder fehlenden Signaturen400bei fehlender userId in den benutzerdefinierten Daten500bei 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
subscriptionIdexistiert - 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:- Kit ruft das Abonnement von der Lemon Squeezy API mithilfe der gespeicherten
subscriptionIdab - Die API-Antwort enthält ein Feld
urls.customer_portalmit einer signierten URL - Kit validiert das Format der Abonnement-ID und die Umgebung, bevor der API-Aufruf erfolgt
- Die URL wird dem Client zur Weiterleitung zurückgegeben
Jede Portal-URL ist ab der Generierung 24 Stunden gültig. Wenn ein Nutzer die URL als Lesezeichen speichert, funktioniert sie nach dem Ablauf nicht mehr. Generiere jedes Mal eine neue URL, wenn der Nutzer in deiner UI auf „Abonnement verwalten" klickt.
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.
Kit protokolliert alle Webhook-Events in der Konsole mit detailliertem Kontext (Event-Name, Abonnement-ID, Nutzer-ID, Zeitstempel). Überprüfe dein Terminal oder deine Server-Logs, um die vollständige Webhook-Verarbeitungs-Pipeline nachzuverfolgen.