# Plan: Public API, Webhooks, Enterprise-Plan, White-Label & Custom Domains Stand: 2026-07-11 · Autor: Claude (Analyse der Codebase auf Commit `d542f84`) **Feature-Gating-Übersicht:** | Feature | FREE | PRO | BUSINESS | ENTERPRISE | |---|---|---|---|---| | Webhooks | – | ✅ (3 Endpoints) | ✅ (10) | ✅ (unbegrenzt) | | Public REST API | – | – | ✅ (1.000 req/h) | ✅ (10.000 req/h) | | White-Label (Branding aus) | – | – | – | ✅ | | Custom Domain (CNAME) | – | – | – | ✅ (bis 3 Domains) | | Dynamische QRs | 3 | 50 | 500 | unbegrenzt | | Preis | 0 € | 9 €/M | 29 €/M | 149 €/M · 1.490 €/J | Empfohlene Reihenfolge: **Phase 1 → 2 → 3** ist launchbar als „Enterprise light" (CNAME als „coming soon"), Phase 4 + 5 danach. --- ## Phase 1 — Enterprise-Plan (≈ 1–2 Tage) ### 1.1 Datenbank (manuell per SQL, gemäß DB-Policy — kein Migrate) ```sql ALTER TYPE "Plan" ADD VALUE 'ENTERPRISE'; ``` Danach: `prisma/schema.prisma` → `enum Plan { FREE PRO BUSINESS ENTERPRISE }`, dann `npx prisma generate`. ### 1.2 Stripe - Im Stripe-Dashboard zwei Prices anlegen: **149 €/Monat**, **1.490 €/Jahr** (Produkt „QR Master Enterprise"). - Neue Env-Vars: `STRIPE_PRICE_ID_ENTERPRISE_MONTHLY`, `STRIPE_PRICE_ID_ENTERPRISE_YEARLY` (auch in `env.example` ergänzen). ### 1.3 Code-Änderungen | Datei | Änderung | |---|---| | `src/lib/plans.ts` | `ENTERPRISE_DYNAMIC_QR_LIMIT` sauber definieren (Wert `-1` = unbegrenzt statt 99999, oder 99999 belassen — Konsistenz mit `staticQRCodes: -1` prüfen) | | `src/lib/stripe.ts` | `STRIPE_PLANS.ENTERPRISE` mit Features-Liste + beiden priceIds; `getPlanFromStripePriceId()` um ENTERPRISE-Zweig erweitern (VOR dem BUSINESS-Check einfügen) | | Pricing-Page (`(marketing)`) | 4. Spalte Enterprise; Features: „Alles aus Business", „Public API 10k req/h", „Unbegrenzte Webhooks", „White-Label", „Custom Domain (CNAME)", „Priority Support & SLA", „AV-Vertrag (DPA)" | | Checkout/Portal-Routen (`api/stripe/*`) | Prüfen, ob Plan-Namen irgendwo hart kodiert sind (`grep -r "BUSINESS" src/app/(main)/api/stripe`) | | Webhook-Handler `api/stripe/webhook/route.ts` | Sollte über `getPlanFromStripePriceId()` automatisch funktionieren — verifizieren | ### 1.4 Zentrale Gating-Helper (Grundlage für alle weiteren Phasen) Neue Datei `src/lib/entitlements.ts`: ```ts export const ENTITLEMENTS = { FREE: { webhooks: 0, api: false, apiRatePerHour: 0, whiteLabel: false, customDomains: 0 }, PRO: { webhooks: 3, api: false, apiRatePerHour: 0, whiteLabel: false, customDomains: 0 }, BUSINESS: { webhooks: 10, api: true, apiRatePerHour: 1000, whiteLabel: false, customDomains: 0 }, ENTERPRISE: { webhooks: -1, api: true, apiRatePerHour: 10000, whiteLabel: true, customDomains: 3 }, } as const; ``` Alle Feature-Checks laufen NUR über diese Datei — nie `plan === 'PRO'` inline. ### 1.5 Verifikation - Test-Checkout mit Stripe-Testkarte (4242…) auf Enterprise monatlich + jährlich. - Downgrade/Upgrade über Customer Portal → `plan`-Feld in DB prüfen. --- ## Phase 2 — Webhooks (≈ 3–4 Tage) · ab PRO ### 2.1 Datenbank (raw SQL) ```sql CREATE TABLE "WebhookEndpoint" ( "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, "userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE, "url" TEXT NOT NULL, "secret" TEXT NOT NULL, -- whsec_..., wird nur 1x angezeigt "events" TEXT[] NOT NULL DEFAULT '{qr.scanned}', "active" BOOLEAN NOT NULL DEFAULT true, "failCount" INTEGER NOT NULL DEFAULT 0, -- für Auto-Disable "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX "WebhookEndpoint_userId_idx" ON "WebhookEndpoint"("userId"); CREATE TABLE "WebhookDelivery" ( "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, "endpointId" TEXT NOT NULL REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE, "event" TEXT NOT NULL, "payload" JSONB NOT NULL, "statusCode" INTEGER, "success" BOOLEAN NOT NULL DEFAULT false, "attempts" INTEGER NOT NULL DEFAULT 0, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" ON "WebhookDelivery"("endpointId", "createdAt"); ``` Schema.prisma nachziehen, `prisma generate`. Deliveries nach 30 Tagen löschen (einfacher Cleanup beim Dispatch oder Cron). ### 2.2 Dispatch-Modul `src/lib/webhooks.ts` - `dispatchEvent(userId, event, data)`: 1. Aktive Endpoints des Users laden, die `event` abonniert haben. 2. Payload bauen (s. u.), HMAC-SHA256 über den Raw-Body mit `secret`. 3. POST mit Headers `X-QRMaster-Signature: sha256=`, `X-QRMaster-Event`, `X-QRMaster-Delivery-Id`; Timeout 5 s. 4. Retry inline: max. 3 Versuche (0 s / 10 s / 60 s Backoff) — bewusst ohne Job-Queue, fire-and-forget wie `trackScan`. 5. `WebhookDelivery` loggen; bei Erfolg `failCount = 0`, bei Fehlschlag `failCount++`; ab 20 Fehlschlägen in Folge `active = false` + E-Mail an User (via `src/lib/email.ts`). - **SSRF-Schutz (wichtig!)**: URL-Validierung beim Anlegen UND vor jedem Request — nur `https://`, DNS auflösen und private/interne IP-Ranges blocken (127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1). Payload-Format (stabil halten, das ist API-Vertrag): ```json { "id": "evt_...", "event": "qr.scanned", "createdAt": "2026-07-11T12:00:00Z", "data": { "qrId": "...", "slug": "...", "name": "...", "scan": { "ts": "...", "country": "DE", "city": "Berlin", "device": "mobile", "os": "iOS", "isUnique": true } } } ``` Kein `ipHash` im Payload (Datenschutz). ### 2.3 Event-Quellen einhängen | Event | Ort | |---|---| | `qr.scanned` | `src/app/(main)/r/[slug]/route.ts` → am Ende von `trackScan()` (dort liegen bereits alle Geo/Device-Daten vor) | | `qr.created` / `qr.updated` / `qr.deleted` | `api/qrs/route.ts` + `api/qrs/[id]/route.ts` nach erfolgreichem DB-Write | DNT-Verhalten spiegeln: wenn `trackScan` wegen DNT keinen Scan schreibt, auch kein `qr.scanned`-Event feuern. ### 2.4 Management-API (Session-Auth, CSRF wie bestehende Routen) - `GET/POST /api/webhooks` — Liste / Anlegen (Zod-Schema in `validationSchemas.ts`; Limit aus `ENTITLEMENTS[plan].webhooks` prüfen; Secret `whsec_` + 32 random Bytes generieren, im Response einmalig zurückgeben) - `PATCH/DELETE /api/webhooks/[id]` — aktivieren/deaktivieren/URL/Events ändern, löschen - `POST /api/webhooks/[id]/test` — Test-Event `webhook.test` senden, Response-Status zurückgeben - `GET /api/webhooks/[id]/deliveries` — letzte 50 Deliveries ### 2.5 UI Neuer Tab/Bereich in `src/app/(main)/(app)/settings/page.tsx` (oder eigene Seite `settings/webhooks`): Endpoint-Liste mit Status-Badge, Anlegen-Dialog (URL + Event-Checkboxen), Secret-einmal-anzeigen-Modal (Copy-Button), „Test senden"-Button, Delivery-Log aufklappbar. Für FREE: Upsell-Karte. ### 2.6 Doku + Verifikation - Doku-Seite `/docs/webhooks` (Signatur-Verifikations-Beispiel in Node/PHP). - Test end-to-end mit webhook.site + lokalem Scan; Signatur nachrechnen; Retry testen (Endpoint der 500 liefert); SSRF-Check (`http://localhost` muss abgelehnt werden). --- ## Phase 3 — Public REST API (≈ 4–6 Tage) · ab BUSINESS ### 3.1 Datenbank (raw SQL) ```sql CREATE TABLE "ApiKey" ( "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, "userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "keyHash" TEXT NOT NULL UNIQUE, -- SHA-256 des vollen Keys "keyPrefix" TEXT NOT NULL, -- "qrm_live_a1b2c3" für UI-Anzeige "lastUsedAt" TIMESTAMP(3), "revokedAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId"); ``` Key-Format: `qrm_live_` + 32 random Bytes base62. Lookup über SHA-256-Hash (deterministisch → `keyHash` direkt per `findUnique`, kein bcrypt nötig, Keys haben genug Entropie). ### 3.2 Auth- & Rate-Limit-Middleware `src/lib/apiAuth.ts` - `authenticateApiKey(request)`: `Authorization: Bearer qrm_live_...` → Hash → Key + User laden → revoked prüfen → `ENTITLEMENTS[plan].api` prüfen → `lastUsedAt` throttled updaten (max. 1×/min) → `{ userId, plan }` zurückgeben oder 401/403. - Rate-Limiting **Redis-basiert** (nicht das in-memory `rateLimit.ts`): neuer `src/lib/redisRateLimit.ts` mit Sliding Window (`INCR` + `EXPIRE` pro `apikey:{id}:{hourBucket}` reicht). Limits aus `ENTITLEMENTS`. Fallback ohne Redis: in-memory mit Warnung. Standard-Header `X-RateLimit-*` + `Retry-After` bei 429. ### 3.3 Service-Layer-Refactoring (der eigentliche Aufwand) Kernlogik aus den Session-Routen in `src/lib/services/qrService.ts` extrahieren, damit Session-Routen und v1-Routen dieselbe Logik nutzen: `listQrs(userId)`, `createQr(userId, plan, input)` (inkl. Plan-Limit-Check), `updateQr`, `deleteQr`, `getAnalyticsSummary(userId, qrId, range)`. Bestehende Routen auf den Service umstellen — **keine Logik duplizieren**. ### 3.4 Endpoints unter `src/app/(main)/api/v1/` Kein CSRF (Bearer-Auth), JSON-Fehlerformat `{ "error": { "code", "message" } }`: | Endpoint | Beschreibung | |---|---| | `GET /api/v1/qr-codes` | Liste, Pagination `?limit=&cursor=` | | `POST /api/v1/qr-codes` | Anlegen (gleiche Zod-Schemas wie intern) | | `GET/PATCH/DELETE /api/v1/qr-codes/{id}` | Einzeloperationen | | `GET /api/v1/qr-codes/{id}/analytics?from=&to=` | Scans, Unique, Country/Device-Breakdown | | `GET /api/v1/qr-codes/{id}/image?format=png\|svg&size=` | QR-Bild (nutzt `src/lib/qr.ts`) | | `GET /api/v1/me` | Plan, Limits, Verbrauch (guter Smoke-Test-Endpoint) | Versionierung im Pfad (`/v1/`) — Breaking Changes später nur via `/v2/`. ### 3.5 Key-Verwaltung - Session-Routen `GET/POST /api/api-keys`, `DELETE /api/api-keys/[id]` (= revoke). - UI in Settings: Key-Liste (Prefix, lastUsed), „Create key" mit Einmal-Anzeige-Modal, Revoke mit Confirm. Max. 5 Keys/User. - Gating: FREE/PRO sehen Upsell auf Business. ### 3.6 Doku (Pflicht, sonst wertlos) Marketing-Seite `/docs/api`: Auth, Rate Limits, alle Endpoints mit curl-Beispielen, Fehlercodes, Webhook-Sektion verlinken. OpenAPI-Spec (`public/openapi.json`) optional als Follow-up. ### 3.7 Verifikation curl-Suite gegen lokalen Dev-Server: 401 ohne Key, 403 mit PRO-Key, CRUD-Roundtrip mit Business-Key, 429 nach Limit, revoked Key → 401. --- ## Phase 4 — White-Label (≈ 1–2 Tage) · ENTERPRISE ### 4.1 Scope „Powered by QR Master"-Branding entfernen auf allen Seiten, die Endkunden beim Scannen sehen: vCard-Display, Text-Display, Coupon-, Feedback-Seiten (`grep -ri "qr master\|qrmaster" src/app` über die Display-Seiten, um alle Stellen zu finden). ### 4.2 Umsetzung - User-Felder (raw SQL): `whiteLabelEnabled BOOLEAN DEFAULT false`, optional `brandLogoUrl TEXT`, `brandColor TEXT`. - Display-Seiten laden den QR-Owner sowieso → Branding-Block konditional auf `ENTITLEMENTS[plan].whiteLabel && whiteLabelEnabled`. - Settings-Toggle (nur Enterprise sichtbar), optional Logo-Upload via bestehendem S3-Setup. - Wichtig: Beim Downgrade (Stripe-Webhook) `whiteLabelEnabled` NICHT löschen, aber Anzeige-Check läuft immer über den aktuellen Plan → verhält sich korrekt. --- ## Phase 5 — Custom Domains / CNAME (≈ 5–8 Tage) · ENTERPRISE Der technisch riskanteste Teil — TLS für fremde Domains ist Infra-Arbeit. ### 5.1 Datenbank (raw SQL) ```sql CREATE TABLE "CustomDomain" ( "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, "userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE, "domain" TEXT NOT NULL UNIQUE, -- z. B. qr.kunde.de (lowercase) "verifyToken" TEXT NOT NULL, -- für TXT-Record "verifiedAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ); ``` ### 5.2 Verifizierungs-Flow 1. User trägt Domain ein → App zeigt zwei DNS-Records: `TXT _qrmaster-verify.qr.kunde.de = ` und `CNAME qr.kunde.de → qrmaster.net`. 2. `POST /api/domains/[id]/verify` prüft beides via Node `dns.promises.resolveTxt/resolveCname`. 3. Erst nach Verifizierung wird die Domain aktiv (verhindert Domain-Hijacking). ### 5.3 Request-Routing in Next.js - `src/middleware.ts` (bzw. erweitern, falls vorhanden): wenn `Host` ≠ qrmaster.net/localhost → rewrite auf `/r/...` Namespace; auf Fremd-Domains NUR `/r/[slug]` bedienen, alles andere → 404 (keine Login-/Marketing-Seiten unter Kundendomains, SEO/Sicherheit). - `/r/[slug]`-Route: bei Fremd-Host prüfen, dass der Slug-Owner die verifizierte Domain besitzt — sonst 404 (verhindert, dass fremde QRs über eine Kundendomain laufen). - Redis-Cache für Domain-Lookups (Hostname → userId, TTL 5 min). ### 5.4 TLS — Caddy als Reverse Proxy (Docker-Deployment) `docker-compose.yml`: Caddy-Service vor `web`, Ports 80/443, Volume für Zertifikate. ```caddyfile { on_demand_tls { ask http://web:3050/api/domains/check # gibt 200 nur für verifizierte Domains } } https:// { tls { on_demand } reverse_proxy web:3050 } qrmaster.net, www.qrmaster.net { reverse_proxy web:3050 } ``` - Neuer interner Endpoint `GET /api/domains/check?domain=` → 200 wenn verifiziert, sonst 403 (schützt vor Zertifikats-Missbrauch/Let's-Encrypt-Rate-Limits). - Falls qrmaster.net aktuell hinter Cloudflare/anderem Proxy läuft: vorher klären, wie Port 443 heute terminiert wird — Caddy ersetzt bzw. ergänzt das bestehende Setup. **Vor Umbau: aktuelles docker-compose.yml und DNS-Setup prüfen** (größte Unbekannte in diesem Plan). ### 5.5 UI Settings-Bereich „Custom Domains" (nur Enterprise): Domain hinzufügen, DNS-Anleitung mit Copy-Buttons, Verify-Button mit Status, Limit 3 Domains. QR-Detail/Generator: Kurz-URL-Anzeige nutzt die Custom Domain, wenn vorhanden. ### 5.6 Verifikation Lokal mit `/etc/hosts`-Eintrag + Caddy testen; auf dem Server mit einer eigenen Test-Domain (z. B. `qr.timo-test.de`) den kompletten Flow durchspielen, bevor es an Kunden geht. --- ## Querschnittsthemen - **Downgrade-Verhalten**: Bei Plan-Downgrade (Stripe-Webhook) Webhooks über Limit deaktivieren (nicht löschen), API-Keys behalten aber `api: false` greift beim Auth-Check, Custom Domains deaktivieren. Ein zentraler `enforceEntitlements(userId)` im Stripe-Webhook-Handler. - **Abuse**: API-Key-Erstellung rate-limiten; Webhook-URLs SSRF-geprüft; Delivery-Payloads klein halten. - **Doku/SEO**: `/docs/api` + `/docs/webhooks` sind auch Marketing-Seiten („QR Code API") — lohnt eigene SEO-Behandlung wie die Growth-Pages. ## Zeitplan gesamt | Phase | Aufwand | Abhängig von | |---|---|---| | 1 Enterprise-Plan + Entitlements | 1–2 Tage | — | | 2 Webhooks | 3–4 Tage | Phase 1 (Gating) | | 3 Public API | 4–6 Tage | Phase 1; Payload-Format aus Phase 2 wiederverwenden | | 4 White-Label | 1–2 Tage | Phase 1 | | 5 Custom Domains | 5–8 Tage | Phase 1; Infra-Klärung (Proxy/DNS) vorab | | **Gesamt** | **≈ 14–22 Arbeitstage** | Launch von „Enterprise light" nach Phase 1–3 möglich |