15 KiB
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)
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 inenv.exampleergä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:
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)
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):- Aktive Endpoints des Users laden, die
eventabonniert haben. - Payload bauen (s. u.), HMAC-SHA256 über den Raw-Body mit
secret. - POST mit Headers
X-QRMaster-Signature: sha256=<hex>,X-QRMaster-Event,X-QRMaster-Delivery-Id; Timeout 5 s. - Retry inline: max. 3 Versuche (0 s / 10 s / 60 s Backoff) — bewusst ohne
Job-Queue, fire-and-forget wie
trackScan. WebhookDeliveryloggen; bei ErfolgfailCount = 0, bei FehlschlagfailCount++; ab 20 Fehlschlägen in Folgeactive = false+ E-Mail an User (viasrc/lib/email.ts).
- Aktive Endpoints des Users laden, die
- 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):
{
"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 invalidationSchemas.ts; Limit ausENTITLEMENTS[plan].webhooksprüfen; Secretwhsec_+ 32 random Bytes generieren, im Response einmalig zurückgeben)PATCH/DELETE /api/webhooks/[id]— aktivieren/deaktivieren/URL/Events ändern, löschenPOST /api/webhooks/[id]/test— Test-Eventwebhook.testsenden, Response-Status zurückgebenGET /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://localhostmuss abgelehnt werden).
Phase 3 — Public REST API (≈ 4–6 Tage) · ab BUSINESS
3.1 Datenbank (raw 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].apiprüfen →lastUsedAtthrottled updaten (max. 1×/min) →{ userId, plan }zurückgeben oder 401/403.- Rate-Limiting Redis-basiert (nicht das in-memory
rateLimit.ts): neuersrc/lib/redisRateLimit.tsmit Sliding Window (INCR+EXPIREproapikey:{id}:{hourBucket}reicht). Limits ausENTITLEMENTS. Fallback ohne Redis: in-memory mit Warnung. Standard-HeaderX-RateLimit-*+Retry-Afterbei 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, optionalbrandLogoUrl 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)
whiteLabelEnabledNICHT 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)
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
- User trägt Domain ein → App zeigt zwei DNS-Records:
TXT _qrmaster-verify.qr.kunde.de = <verifyToken>undCNAME qr.kunde.de → qrmaster.net. POST /api/domains/[id]/verifyprüft beides via Nodedns.promises.resolveTxt/resolveCname.- Erst nach Verifizierung wird die Domain aktiv (verhindert Domain-Hijacking).
5.3 Request-Routing in Next.js
src/middleware.ts(bzw. erweitern, falls vorhanden): wennHost≠ 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.
{
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: falsegreift beim Auth-Check, Custom Domains deaktivieren. Ein zentralerenforceEntitlements(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/webhookssind 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 |