Feinkonzept §3.12 — Offene API & Entwickler-Portal
Feinkonzept §3.12 — Offene API & Entwickler-Portal
Abschnitt betitelt „Feinkonzept §3.12 — Offene API & Entwickler-Portal“Modul: Kern · Quelle:
FUNKTIONSUMFANG.md§3.12 Status: Draft v1.0 · 19.04.2026 · Autor: Ron (Senior Consultant · API Platform Design) HTML-Hero-Mockup:12-api-entwickler-portal.htmlCross-Link: §3.7 Reporting ↔ §3.12 API (Reports exportierbar viaPOST /v1/kern/reports/exports)
1. Header & Metadaten
Abschnitt betitelt „1. Header & Metadaten“| Feld | Wert |
|---|---|
| Feinkonzept-ID | fk-kern-12-api-portal |
| Titel | Offene API & Entwickler-Portal |
| Modul | Kern (obligatorisch) |
| Quelle | FUNKTIONSUMFANG §3.12, TECH-STACK §5.3 / §5.4 |
| Release-Stufe | MVP (Swagger UI, OpenAPI 3.1, API-Keys, Sandbox, 4 Webhook-Events) · V1 (OAuth 2.1+PKCE, Redoc, Status-Page, weitere Events) · V1.5 (SDK-Autogen Dart/TS/Python, Rate-Limit-Self-Service) |
| Referenzkunde | Steuerberater Kanzlei Bauer & Partner, München — zieht nächtlich Zeit- und Lohn-Daten von 12 Werkszeit-Kunden via /v1/kern/time-entries und /v1/kern/payroll/exports. Sekundär: BauMaterial24 GmbH (Plugin-Partner) — baut eine Inventar-Integration gegen /v1/handwerk/material/*. |
| Verantwortlich | Platform-Team (Backend) · DX-Team (Portal/Docs) |
| Abnahme durch | CTO + DSB + Referenzkunde |
| Schätzung | 48 Eng-Tage (MVP 20 · V1 18 · V1.5 10) |
1.1 Konventionen dieses Dokuments
Abschnitt betitelt „1.1 Konventionen dieses Dokuments“- 📱 Mobile · 🌐 Web · 🔄 beide · 🧑💻 Entwickler-Portal-spezifisch
- 🟢 MVP · 🟡 V1 · 🔴 V1.5+
curl/wz_live_*/wz_sandbox_*als Monospace — echte Demo-Werte.
1.2 Abgegrenzte Begriffe
Abschnitt betitelt „1.2 Abgegrenzte Begriffe“- “Public API” = alle Endpoints unterhalb
api.werkszeit.de/v1/…. Kein Subset einer „internen“ API — Client und Dritte nutzen dieselbe Spec. Kein “Partner-API” daneben (Altlast aus Version 1 der Altapp — dort 70 Routen ohne Modul-Gate). - “Sandbox” = eigener Tenant
sandbox-werkszeithinterapi-sandbox.werkszeit.de, mit Seed-Daten, nächtlich um 03:00 CET zurückgesetzt. Nicht: Mocking-Server. - “Webhook-Event” = HTTP-POST vom Werkszeit-Backend zu einem vom Kunden registrierten Endpoint, mit HMAC-SHA-256-Signatur. Nicht: Server-Sent Events, WebSockets (out-of-scope).
- “Idempotency-Key” = UUID v7 im Request-Header
Idempotency-Key, 24 h TTL, exactly-once-Garantie pro Tenant.
2. Kontext & Problem
Abschnitt betitelt „2. Kontext & Problem“2.1 Ausgangslage
Abschnitt betitelt „2.1 Ausgangslage“Im Handwerk ist die API-Landschaft fragmentiert: Jedes Handwerkssystem bietet einen “Datenexport nach DATEV”, manchmal einen “Webservice für den Steuerberater” — keines bietet eine veröffentlichte, versionierte OpenAPI-Spec, die ein Dritter ohne Telefonat mit dem Vertrieb nutzen könnte. Die Altapp hatte einen Endpoint-Zoo aus 70 Routen ohne Dokumentation; jede zweite Integration war ein individuelles CSV-Schema „weil der Kunde das so braucht“.
Werkszeit stellt diese Politik auf den Kopf: Alle Endpoints sind öffentlich dokumentiert, alle Kunden können alle Endpoints ihrer gebuchten Module nutzen, alle Integrationen basieren auf derselben Spec, die auch der Flutter-Client verwendet.
2.2 Zu lösende Probleme
Abschnitt betitelt „2.2 Zu lösende Probleme“- Dokumentations-Drift. Wenn Spec und Implementierung auseinanderlaufen, wird die API unbrauchbar. Gegenmaßnahme: Spec-Driven Development via
@hono/zod-openapi— die Spec wird aus den Route-Definitionen generiert, nicht daneben gepflegt. CI bricht, wenn die committete Spec von der generierten abweicht. - „API ausprobieren“ ohne Echt-Daten-Risiko. Steuerberater wollen einen neuen Job-Run ausprobieren, ohne die Produktiv-Zeitbuchungen eines Kunden zu riskieren. Gegenmaßnahme: Sandbox-Tenant mit nächtlichem Reset, Keys mit Präfix
wz_sandbox_*, identische Route-Oberfläche. - Rate-Limit-Transparenz. Altsystem-API warf 500 bei Überlast. Gegenmaßnahme: RFC 9239-konforme Header (
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset), planabhängige Quotas, klare429 Too Many RequestsmitRetry-After. - Webhook-Sicherheit. Altsystem signierte Webhooks mit einem symmetrischen Token im URL-Query — abfangbar, replay-bar. Gegenmaßnahme: HMAC-SHA-256 im
X-Werkszeit-Signature-Header über Body + Timestamp,X-Werkszeit-Timestampmit ±5 min Replay-Schutz, Signing-Secret pro Endpoint rotierbar. - Exactly-once bei Geld-bewegenden Calls. „Rechnung versenden“ darf nicht zweimal fahren, wenn der Client den Request wegen Timeout wiederholt. Gegenmaßnahme:
Idempotency-Key(UUID v7) für alle nicht-idempotenten Mutationen, 24 h TTL, stabile Antwort bei Retry. - Partner-Apps im Kundenauftrag. BauMaterial24 soll Inventar-Daten ziehen, ohne einen API-Key des Handwerkskunden sehen zu müssen. Gegenmaßnahme: OAuth 2.1 Authorization Code + PKCE, Einwilligungs-Screen mit Scope-Liste, Token-Widerruf durch Kunden jederzeit.
2.3 Altsystem-Narben (aus LESSONS-LEARNED)
Abschnitt betitelt „2.3 Altsystem-Narben (aus LESSONS-LEARNED)“- 70 Routen, davon 9 ohne Auth (“Health”-Routen, die versehentlich Daten zurückgaben).
- Keine Versionierung — Breaking Changes fuhren unangekündigt in Production.
- Webhook-URL-Pfad enthielt das Secret im Query-String (
?token=abc123). - Partner-Apps bekamen vom Handwerksbetrieb per E-Mail den API-Key zugeschickt.
- DATEV-Export-Endpoint lieferte bei Lastspitzen 504, statt 429 mit
Retry-After.
Jede dieser Narben adressiert §3.12 direkt. Nichts davon wird wiederholt.
3. Personas & Rollen
Abschnitt betitelt „3. Personas & Rollen“- Anna Bauer (Steuerberaterin, 52, Kanzlei Bauer & Partner München). Nutzt die Werkszeit-API nicht selbst — ihr IT-Dienstleister schreibt einen nächtlichen Cron-Job. Anna guckt nur, ob der Job durchgelaufen ist. Sie öffnet das Portal im Quartal einmal, um den Rate-Limit-Verbrauch zu prüfen.
- Stefan Lang (DevOps-Freelancer, 34). Schreibt für Anna den Cron-Job. Nutzt Swagger UI am Anfang, dann
curlaus einem Ansible-Playbook. Will: eine einzige OpenAPI-Datei, ein Postman-Collection-Link, eine sauber versionierte URL. - Tim Osterloh (Integration-Engineer bei BauMaterial24, 28). Baut die Partner-App. Nutzt OAuth 2.1 + PKCE, läuft durch den Einwilligungs-Screen mit einem Test-Tenant, braucht die JavaScript-SDK.
- Thomas Schmidt (Bauleiter / Admin bei shk-gebruder-schmidt, 48). Verwaltet in seinem Tenant API-Keys und OAuth-Clients. Vergibt Tim einen OAuth-Client „BauMaterial24 Inventar-Sync“ mit Scope
handwerk.material.read. Sieht im Audit-Log, wann Tims App das letzte Mal Daten gezogen hat. Kennt nicht “Authorization Code Flow”, aber versteht „Berechtigung erteilen / widerrufen“.
3.1 Rollen-/Scope-Matrix
Abschnitt betitelt „3.1 Rollen-/Scope-Matrix“| Rolle | GET /v1/** |
Admin-API (Keys, OAuth-Clients) | Webhooks verwalten | Audit-Log lesen |
|---|---|---|---|---|
| Admin (Tenant) | ✅ | ✅ | ✅ | ✅ |
| Manager | ✅ (Scope team) |
❌ | ❌ | nur eigene Team-Events |
| Mitarbeiter | ✅ (Scope own) |
❌ | ❌ | nur eigene Events |
API-Key wz_live_* |
nach Scope-Liste des Keys | ❌ | ❌ | ❌ |
| OAuth-Token (Partner) | nach Consent-Scopes | ❌ | ❌ | ❌ |
| Sandbox-User (Portal-Besucher) | Sandbox-Tenant only | ✅ (im Sandbox) | ✅ (im Sandbox) | ✅ (im Sandbox) |
Enforcement: RLS in Postgres (tenant_id = current_setting('app.tenant_id')::uuid) + Modul-Gate in Hono-Route-Registrierung + Scope-Check im OAuth-Introspection-Middleware.
4. User-Stories
Abschnitt betitelt „4. User-Stories“4.1 Core — MVP
Abschnitt betitelt „4.1 Core — MVP“US-1 · Als Steuerberater-Dienstleister will ich die komplette API-Dokumentation in einer durchsuchbaren Web-Oberfläche sehen, damit ich ohne Vertriebstelefonat loslegen kann.
Akzeptiert: Swagger UI unter docs.werkszeit.de/api/ zeigt alle ≥80 Endpoints, gruppiert nach Modul, durchsuchbar, „Try it out“ funktioniert gegen den Sandbox-Tenant.
US-2 · Als Admin eines Werkszeit-Tenants will ich API-Keys selbst erzeugen und widerrufen, damit ich nicht auf den Support warten muss.
Akzeptiert: Einstellungen → API-Zugänge → „Neuer Key“ erzeugt einen wz_live_*-Key mit wählbarem Scope-Set, zeigt ihn einmalig, hashed ihn in der DB (Argon2id), bietet optional IP-Allowlist.
US-3 · Als Integration-Engineer will ich gegen eine Sandbox programmieren, damit ich keine Produktivdaten riskiere.
Akzeptiert: api-sandbox.werkszeit.de akzeptiert wz_sandbox_*-Keys, die DB ist mit den Seed-Tenants (sandbox-werkszeit-maler, sandbox-werkszeit-shk) vorbefüllt, nächtlicher Reset um 03:00 CET bringt den Stand zurück.
US-4 · Als Entwickler will ich Webhook-Events abonnieren, um nicht pollen zu müssen.
Akzeptiert: Im Portal kann ich einen Endpoint https://kunde.de/webhook eintragen, eine Event-Liste wählen (mindestens: time.entry.created, time.entry.updated, invoice.sent, signature.captured), ein Signing-Secret bekommen, Retry-Policy sehen (5 Retries, exponential backoff, dead-letter nach 24 h).
US-5 · Als Rechnungs-Versand-Microservice will ich idempotent Rechnungen versenden, damit ein Retry keine Doppel-Rechnung erzeugt.
Akzeptiert: POST /v1/kern/invoices/{id}/send mit Idempotency-Key: 01870abc-… (UUID v7). Zweiter Call mit gleichem Key innerhalb 24 h liefert exakt dieselbe Antwort, ohne die Aktion zu wiederholen.
US-6 · Als API-Client will ich bei Rate-Limit-Überschreitung klare Header bekommen, damit ich automatisch zurückfahren kann.
Akzeptiert: Jede Response enthält RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (RFC 9239-konform). 429 liefert zusätzlich Retry-After in Sekunden.
4.2 Core — V1
Abschnitt betitelt „4.2 Core — V1“US-7 · Als Admin will ich OAuth 2.1 Clients für Partner-Apps anlegen, damit ein Dritter in meinem Auftrag zugreifen kann, ohne meinen API-Key zu kennen. Akzeptiert: „Neuer OAuth-Client“ → Redirect-URI + Name + Logo → Client-ID + Secret → Einwilligungs-Screen zeigt angefragte Scopes („Diese App darf … Material lesen, … Rechnungen erzeugen“), Widerruf in Einstellungen → „Verbundene Apps“.
US-8 · Als Entwickler will ich den aktuellen Status der API sehen, damit ich Incidents zuordnen kann.
Akzeptiert: status.werkszeit.de zeigt Availability-Uptime pro Endpoint-Gruppe (Auth, Zeiterfassung, Reporting, Rechnungen), laufende Incidents, Wartungsfenster.
US-9 · Als Entwickler will ich Redoc-Dokumentation druckfähig als Referenz, damit ich offline arbeiten kann (im Zug, in der Kanzlei ohne Gast-WLAN).
Akzeptiert: docs.werkszeit.de/api/reference rendert Redoc, „Als PDF drucken“ liefert ein sauberes 300-Seiten-PDF.
4.3 Core — V1.5
Abschnitt betitelt „4.3 Core — V1.5“US-10 · Als Entwickler will ich SDKs in Dart, TypeScript, Python, damit ich nicht fetch/dio von Hand schreibe.
Akzeptiert: npm install @werkszeit/api, pub add werkszeit_api, pip install werkszeit-api laden typisierte Clients, jeweils aus OpenAPI-Spec generiert, mit eingebautem Retry + Idempotency-Key.
US-11 · Als Admin will ich meinen Rate-Limit-Verbrauch im Portal sehen, damit ich Plan-Upgrade rechtzeitig entscheiden kann. Akzeptiert: Einstellungen → API-Zugänge → „Verbrauch“ zeigt Zeitreihe (letzte 30 Tage), Top-5-Endpoints, Vorhersage „Plan-Limit in ~9 Tagen erreicht“.
4.4 Anti-Stories (Nicht-Ziele)
Abschnitt betitelt „4.4 Anti-Stories (Nicht-Ziele)“- NUS-1 · GraphQL-API. Argument: REST+OpenAPI reicht für das Handwerksszenario, GraphQL verdoppelt die Dokumentations-Oberfläche.
- NUS-2 · gRPC. Argument: Dieselbe. Optional für V3, falls ein Großkunde das explizit bezahlt.
- NUS-3 · Eigenes API-Gateway (Kong, Apigee). Argument: Hono + Cloudflare vor Postgres reicht bis mindestens 10k Anfragen/Sek. Gateway-Migration wäre ein V3-Upgrade.
5. Funktionale Anforderungen
Abschnitt betitelt „5. Funktionale Anforderungen“5.1 OpenAPI 3.1 — Spec-Driven Development
Abschnitt betitelt „5.1 OpenAPI 3.1 — Spec-Driven Development“- FR-5.1.1 · OpenAPI-Spec wird generiert aus den Route-Definitionen via
@hono/zod-openapi. Jede Route deklariert Zod-Schemas für Request (Params, Query, Body) und Response (200, 4xx, 5xx). Die Spec ist kein separates Artefakt — sie ist der Output. - FR-5.1.2 · CI-Gate:
pnpm openapi:generate && git diff --exit-code packages/openapi-spec/openapi.json. Abweichung = Build-Fehler. - FR-5.1.3 · Versionierung über URL-Präfix (
/v1/,/v2/). Breaking Changes erzeugen eine neue Major-Version. Nicht-Breaking Additions (neue optionale Felder, neue Endpoints) gehen in dieselbe Major. - FR-5.1.4 · Deprecation-Politik: Deprecated Endpoints werden im
Deprecation-Header (RFC 9745) markiert, die Spec kennzeichnet sie mitdeprecated: true, Sunset-Datum imSunset-Header (RFC 8594) mindestens 6 Monate in der Zukunft. - FR-5.1.5 · JSON + YAML + HTML (Swagger UI) + HTML (Redoc) — alle vier Artefakte aus derselben Quelle, CI-gebaut, gehostet auf Cloudflare Pages.
5.2 Developer-Portal-Oberfläche
Abschnitt betitelt „5.2 Developer-Portal-Oberfläche“- FR-5.2.1 · 🌐
docs.werkszeit.de/api/zeigt Swagger UI,docs.werkszeit.de/api/referenceRedoc,api.werkszeit.de/openapi.json+/openapi.yamldie Spec-Artefakte. - FR-5.2.2 · „Try it out“ in Swagger UI: Dropdown für Umgebung (Sandbox vs. Production — Sandbox default für nicht-eingeloggte Besucher), Eingabe des API-Keys, Request-Logging sichtbar.
- FR-5.2.3 · Code-Beispiele pro Endpoint:
curl, Dart, TypeScript, Python, PHP (für DATEV-Shops). Aus Templates generiert, bei jedem Build. - FR-5.2.4 · Webhook-Katalog (AsyncAPI 3.0) unter
api.werkszeit.de/webhooks: pro Event-Typ ein Payload-Schema, HMAC-Signing-Regeln, Retry-Policy, Beispiel-Payload. - FR-5.2.5 · Status-Page
status.werkszeit.de(V1): pro Endpoint-Gruppe Availability-Metric 30/90 Tage, Incidents-Feed (RSS + E-Mail-Subscription), Wartungsfenster-Ankündigungen.
5.3 Sandbox-Tenant
Abschnitt betitelt „5.3 Sandbox-Tenant“- FR-5.3.1 · Tenant-ID
sandbox-werkszeit. Hinterapi-sandbox.werkszeit.deerreichbar (separater Hostname, nicht Query-Parameter — klare Trennung im Audit-Log). - FR-5.3.2 · Seed-Daten enthalten: 2 Sub-Tenants
sandbox-werkszeit-maler,sandbox-werkszeit-shk; 25 + 80 Mitarbeiter; 100 Zeitbuchungen; 5 Projekte; 3 Rechnungen (2 draft, 1 sent); 2 Unterschriften. - FR-5.3.3 · Reset nächtlich 03:00 CET. Job truncated + seeded die Sandbox-Schema-DB. Ankündigung im Response-Header vor 02:45 CET:
X-Werkszeit-Sandbox-Reset: 2026-04-20T03:00:00+02:00. - FR-5.3.4 · Sandbox-Keys (
wz_sandbox_*) werden auf der Entwickler-Portal-Startseite ohne Registrierung ausgegeben (one-click: „Sandbox-Key erzeugen“), 7 Tage gültig, unbegrenzt viele Keys pro IP. - FR-5.3.5 · AGPL-Hinweis (Sandbox-Quellcode): Footer des Portals zeigt „Sandbox läuft auf werkszeit-server (AGPL-3.0). Quellcode: github.com/…“. Pflicht nach AGPL §13, weil die Sandbox ein Netzwerkdienst ist, den Dritte nutzen.
5.4 Authentifizierung — API-Keys
Abschnitt betitelt „5.4 Authentifizierung — API-Keys“- FR-5.4.1 · Format:
wz_live_oderwz_sandbox_+ 32 Base62-Zeichen. Prefix erkennt Umgebung sofort, Leaks in Logs bleiben erkennbar (Log-Filter: regexwz_(live|sandbox)_[A-Za-z0-9]{32}). - FR-5.4.2 · Im Klartext nur einmal gezeigt (Create-Response). In der DB als Argon2id-Hash (parameters:
m=64MB, t=3, p=4). Lookup: erste 8 Zeichen als Index, Full-Hash-Compare gegen Treffer. - FR-5.4.3 · Scope-Liste pro Key (z.B.
kern.time-entries.read,handwerk.material.read). Enforcement in Middleware vor Route-Handler. - FR-5.4.4 · IP-Allowlist optional (CIDR, bis zu 20 Blocks). Check via Cloudflare
CF-Connecting-IP. - FR-5.4.5 · Last-Used-Timestamp wird pro Request aktualisiert (async, batched, 60s). Keys ohne Aktivität > 180 Tage werden deaktiviert mit E-Mail-Warnung 30/7/1 Tag vorher.
- FR-5.4.6 · Audit-Log: Jede Key-Erzeugung, -Rotation, -Widerruf als
api_key.created/revoked/rotated-Event mitactor_user_id,ip,user_agent.
5.5 Authentifizierung — OAuth 2.1 + PKCE (V1)
Abschnitt betitelt „5.5 Authentifizierung — OAuth 2.1 + PKCE (V1)“- FR-5.5.1 · Flow: Authorization Code + PKCE (RFC 7636,
code_challenge_method=S256). Kein Implicit Flow, kein Resource Owner Password Credentials — OAuth 2.1 verbietet beide. - FR-5.5.2 · Authorization-Endpoint:
https://auth.werkszeit.de/oauth/authorize. Token-Endpoint:https://auth.werkszeit.de/oauth/token. Discovery:https://auth.werkszeit.de/.well-known/oauth-authorization-server(RFC 8414). - FR-5.5.3 · Einwilligungs-Screen zeigt: App-Name + Logo, angefragte Scopes (menschlesbar: “Zeitbuchungen lesen”, “Rechnungen erstellen”), Tenant-Kontext (der Admin sieht „Du bist eingeloggt bei musterbetrieb-maler“), Widerruf-Hinweis.
- FR-5.5.4 · Access-Token: JWT, signiert mit ES256, 1h TTL. Refresh-Token: opaque, 30 Tage TTL, rotierend (rotation on use). Revocation-Endpoint (RFC 7009).
- FR-5.5.5 · Tenant-Kontext im JWT-Claim
wz_tenant_id. RLS-Policy berücksichtigt diesen Claim; eine App mit Token von Tenant A kann nicht auf Tenant B zugreifen. - FR-5.5.6 · Einstellungen → „Verbundene Apps“ listet pro User/Tenant alle OAuth-Tokens, Widerrufs-Button pro Eintrag. DSGVO Art. 7 Abs. 3 konform (Widerruf so einfach wie Einwilligung).
5.6 Rate-Limits
Abschnitt betitelt „5.6 Rate-Limits“- FR-5.6.1 · Planabhängige API-Key-Quotas (pro Key, pro Minute):
- Starter-Plan: 300 req/min
- Business-Plan: 600 req/min
- Pro-Plan: 2000 req/min
- FR-5.6.2 · Tenant-Hard-Cap: 5000 req/min über alle Keys eines Tenants.
- FR-5.6.3 · Response-Header bei jeder Antwort (RFC 9239 Draft):
RateLimit-Policy: 300;w=60RateLimit-Limit: 300RateLimit-Remaining: 247RateLimit-Reset: 42
- FR-5.6.4 ·
429 Too Many RequestsmitRetry-After: 42(Sekunden) und Body{"error":"rate_limited","retry_after_s":42}. - FR-5.6.5 · Implementierung via Redis-Cluster, Sliding-Window-Counter, Key
ratelimit:{tenant_id}:{api_key_id}:{minute}. - FR-5.6.6 · Burst-Quota: Pro Key 2× Limit für ≤10 Sekunden (Token-Bucket), dann Rückfall auf Steady-State.
- FR-5.6.7 · Excluded Endpoints:
GET /health,GET /openapi.json— nicht gedrosselt, aber protokolliert.
5.7 Webhooks (AsyncAPI 3.0)
Abschnitt betitelt „5.7 Webhooks (AsyncAPI 3.0)“- FR-5.7.1 · Event-Katalog MVP:
time.entry.created,time.entry.updated,time.entry.deleted(logical delete),invoice.sent,signature.captured,report.export.ready,report.export.failed. - FR-5.7.2 · Delivery: HTTP-POST mit JSON-Body, Content-Type
application/json; charset=utf-8. - FR-5.7.3 · Signatur-Header
X-Werkszeit-Signature: sha256=<hex>, berechnet alsHMAC-SHA256(signing_secret, timestamp + '.' + body). Signing-Secret ist 32 Byte Base64, pro Endpoint, rotierbar (altes + neues gleichzeitig gültig für 24h während Rotation). - FR-5.7.4 · Timestamp-Header
X-Werkszeit-Timestamp: 1745073600(Unix-Seconds). Der Empfänger muss prüfen:abs(now - timestamp) ≤ 300(5 Min) — sonst verwerfen. Replay-Schutz. - FR-5.7.5 · Event-ID-Header
X-Werkszeit-Event-Id: 01870abc-…(UUID v7, globally unique). Der Empfänger soll deduplizieren. - FR-5.7.6 · Retry-Policy: 5 Versuche, exponential backoff (30s, 2m, 10m, 1h, 6h). Nach fünftem Fehlversuch → Dead-Letter-Queue (24h aufgehoben, Admin-Alert via E-Mail).
- FR-5.7.7 · Endpoint-URL muss HTTPS sein, TLS 1.3 bevorzugt. HTTP-Only-Endpoints (HTTP ohne S) werden bei der Registrierung abgelehnt (
400 Bad Request). - FR-5.7.8 · Receiver-Anforderung (dokumentiert, nicht enforced): antworte innerhalb 10s mit 2xx, sonst zählt als Fehler. Langes Processing bitte in Queue.
5.8 Idempotenz (nicht-idempotente Mutations)
Abschnitt betitelt „5.8 Idempotenz (nicht-idempotente Mutations)“- FR-5.8.1 · Header
Idempotency-Key: <uuid-v7>für allePOST-Endpoints, die nicht von Natur aus idempotent sind (v.a./invoices/{id}/send,/payroll/runs,/reports/exports). - FR-5.8.2 · Server speichert Key + Response-Hash für 24h in Redis (key:
idem:{tenant_id}:{idempotency_key}). Zweiter Call mit identischem Key innerhalb TTL: exakt dieselbe Antwort, ohne die Aktion erneut auszuführen. - FR-5.8.3 · Conflict-Fall: Zweiter Call mit identischem Key, aber abweichendem Request-Body →
409 Conflictmit{"error":"idempotency_key_mismatch"}. - FR-5.8.4 · UUID v7 wird bevorzugt (time-ordered), aber jeder UUID akzeptiert. Non-UUID-Werte →
400 Bad Request.
5.9 Admin-UI: API-Zugänge
Abschnitt betitelt „5.9 Admin-UI: API-Zugänge“- FR-5.9.1 · 🌐
Einstellungen → API-Zugänge(nur Admin-Rolle), drei Tabs: API-Keys · OAuth-Clients · Webhooks · Audit-Log. - FR-5.9.2 · API-Keys-Tab: Liste (Name, Präfix
wz_live_abc1…, Scopes, Last-Used, Status), „Neuer Key“-Dialog, Rotate-Button (generiert neuen Key, alter 24h parallel gültig), Widerruf-Button. - FR-5.9.3 · OAuth-Clients-Tab: Pro Client (Name, Logo, Redirect-URIs, Scopes), Client-Secret einmalig anzeigen, „Verbundene Nutzer“ (welche User haben eingewilligt), Einwilligungs-Widerruf.
- FR-5.9.4 · Webhooks-Tab: Endpoint-URL, Event-Selektion (Checkboxen), Signing-Secret rotieren, Zustellungs-Historie (letzte 100 Deliveries mit Status-Code, Latenz, Retry-Count), Test-Delivery-Button.
- FR-5.9.5 · Audit-Log-Tab: Tabelle aller API-Ereignisse (Key-Create/Revoke, OAuth-Consent/Revoke, Webhook-Delivery-Failed), filterbar, CSV-Export.
5.10 Barrierefreiheit (BFSG/WCAG 2.2 AA)
Abschnitt betitelt „5.10 Barrierefreiheit (BFSG/WCAG 2.2 AA)“- FR-5.10.1 · Swagger UI wird mit angepasstem Theme ausgeliefert, das WCAG AA-Kontraste erfüllt (Swagger-Default genügt nur teilweise).
- FR-5.10.2 · Admin-UI (
Einstellungen → API-Zugänge): Tastatur-navigierbar, alle Aktionen via Screenreader verkündet, Touch-Targets ≥ 48dp. - FR-5.10.3 · Code-Beispiele:
<pre><code>mitrole="region"undaria-label="Code-Beispiel cURL für POST /v1/kern/time-entries". Copy-Button mitaria-label="In die Zwischenablage kopieren". - FR-5.10.4 · Farb-agnostische Status-Darstellung (Status-Page): Piktogramme zusätzlich zu Farbe.
6. Mockups & Flows
Abschnitt betitelt „6. Mockups & Flows“ASCII-Wireframes. HTML-Realisierung siehe
12-api-entwickler-portal.html.
6.1 🌐 Web — Developer-Portal-Startseite (Swagger UI)
Abschnitt betitelt „6.1 🌐 Web — Developer-Portal-Startseite (Swagger UI)“┌───────────────────────────────────────────────────────────────────────────┐│ docs.werkszeit.de/api/ [Sandbox-Key erzeugen ▾] │├───────────────────────────────────────────────────────────────────────────┤│ Werkszeit API v1 [🔍 Suche] ││ Die API, die der Flutter-Client selbst nutzt. Kein Partner-API-Subset. ││ ││ 🟢 Servers ││ ● production api.werkszeit.de ││ ○ sandbox api-sandbox.werkszeit.de (Reset tgl. 03:00 CET) ││ ││ 🔐 Authentifizierung ││ [Authorize] → X-Werkszeit-API-Key: wz_sandbox_abc… ││ ││ 📂 Kern ││ ▸ POST /v1/kern/time-entries Zeitbuchung anlegen ││ ▸ GET /v1/kern/time-entries/{id} Zeitbuchung lesen ││ ▸ POST /v1/kern/reports/exports Report-Export (§3.7) ◀── Cross-Link│ ▸ POST /v1/kern/invoices/{id}/send Rechnung senden · Idempotent ││ 📂 Handwerk ││ ▸ GET /v1/handwerk/material/stock Material-Bestand ││ ││ 🪝 Webhooks (AsyncAPI 3.0) → /webhooks ││ ● time.entry.created ● invoice.sent ● signature.captured ││ ││ [Try it out: POST /v1/kern/time-entries] │└───────────────────────────────────────────────────────────────────────────┘6.2 🌐 Web — Einstellungen → API-Zugänge (Admin)
Abschnitt betitelt „6.2 🌐 Web — Einstellungen → API-Zugänge (Admin)“┌───────────────────────────────────────────────────────────────────────────┐│ Werkszeit · musterbetrieb-maler Thomas S. (Admin) [Profil▾]│├──────────────┬────────────────────────────────────────────────────────────┤│ Sidebar │ Einstellungen / API-Zugänge ││ ⏱ Zeit │ ────────────────────────────────────────────────────────── ││ 📊 Reports │ [API-Keys] [OAuth-Clients] [Webhooks] [Audit-Log] ││ ⚙ Einstell. │ ││ ▸ Nutzer │ API-Keys (3) [+ Neuer Key]││ ▸ API │ ┌────────────────────────────────────────────────────────┐ ││ (aktiv) │ │Name │Präfix │Scopes │Letzte│Status │ ││ ▸ Module │ ├────────────┼───────────┼─────────────┼──────┼──────────┤ ││ │ │Kanzlei B. │wz_live_k7 │time:read │heute │✓ aktiv │ ││ │ │ │ │payroll:read │ │[Rotate] │ ││ │ │DATEV-Sync │wz_live_dv │exports:rw │gestern│✓ aktiv │ ││ │ │Altsystem │wz_live_xy │time:rw │>180T │⚠ Warnung │ ││ │ └────────────────────────────────────────────────────────┘ ││ │ [⬇ Audit-CSV der letzten 90 Tage] │└──────────────┴────────────────────────────────────────────────────────────┘6.3 🌐 Web — OAuth-Einwilligungs-Screen (Partner-App)
Abschnitt betitelt „6.3 🌐 Web — OAuth-Einwilligungs-Screen (Partner-App)“┌────────────────────────────────────────────────────┐│ Werkszeit · auth.werkszeit.de/oauth/authorize │├────────────────────────────────────────────────────┤│ ┌───────────────┐ ││ │ [BM24 Logo] │ ││ └───────────────┘ ││ BauMaterial24 Inventar-Sync ││ möchte im Namen von Thomas Schmidt ││ auf musterbetrieb-maler zugreifen. ││ ││ Diese App darf: ││ ✓ Material-Bestand lesen ││ (handwerk.material.read) ││ ✓ Material-Bewegungen lesen ││ (handwerk.material.movements.read) ││ ✗ KEINE Zeitbuchungen lesen ││ ✗ KEINE Rechnungen erstellen ││ ││ Du kannst diese Einwilligung jederzeit unter ││ Einstellungen → Verbundene Apps widerrufen. ││ ││ [ Ablehnen ] [ Erlauben ] ││ ││ Redirect an: https://baumaterial24.de/callback │└────────────────────────────────────────────────────┘6.4 🌐 Web — Swagger UI „Try it out“ mit Sandbox
Abschnitt betitelt „6.4 🌐 Web — Swagger UI „Try it out“ mit Sandbox“┌───────────────────────────────────────────────────────────────┐│ POST /v1/kern/time-entries [Try it out] [Execute] │├───────────────────────────────────────────────────────────────┤│ Parameters: none ││ Body (application/json): ││ ┌───────────────────────────────────────────────────────────┐ ││ │{ │ ││ │ "project_id": "01872de0-…", │ ││ │ "started_at": "2026-04-19T07:02:00+02:00", │ ││ │ "source": "nfc" │ ││ │} │ ││ └───────────────────────────────────────────────────────────┘ ││ Headers: ││ X-Werkszeit-API-Key: wz_sandbox_abc… [Edit] ││ Idempotency-Key: 01870abc-… [UUID v7 generieren] ││ ││ ─── Response ─────────────────────────────────────── 201 ──── ││ RateLimit-Limit: 300 ││ RateLimit-Remaining: 289 ││ RateLimit-Reset: 28 ││ X-Werkszeit-Sandbox-Reset: 2026-04-20T03:00:00+02:00 ││ { ││ "id": "01872f3b-…", ││ "project_id": "01872de0-…", ││ "started_at": "2026-04-19T07:02:00+02:00", ││ "source": "nfc", ││ "sha256": "c7a4…9e12" ││ } │└───────────────────────────────────────────────────────────────┘6.5 📱 Mobile — “Verbundene Apps” (DSGVO Art. 7 Abs. 3 Widerruf)
Abschnitt betitelt „6.5 📱 Mobile — “Verbundene Apps” (DSGVO Art. 7 Abs. 3 Widerruf)“┌──────────────────────────────┐│ 13:42 5G ●●●●│├──────────────────────────────┤│ ← Verbundene Apps ⚙ │├──────────────────────────────┤│ Apps, die in deinem Namen ││ Daten abrufen dürfen: ││ ││ ┌──────────────────────────┐ ││ │ [BM24] BauMaterial24 │ ││ │ Inventar-Sync │ ││ │ Seit 12.04.2026 │ ││ │ 🟢 Aktiv · 2x/Tag │ ││ │ Scopes: Material lesen │ ││ │ [ Zugriff widerrufen ] │ ││ └──────────────────────────┘ ││ ││ ┌──────────────────────────┐ ││ │ [SB] Kanzlei Bauer │ ││ │ DATEV-Export │ ││ │ Seit 01.03.2026 │ ││ │ 🟢 Aktiv · 1x/Tag │ ││ │ Scopes: Zeit, Lohn │ ││ │ [ Zugriff widerrufen ] │ ││ └──────────────────────────┘ ││ ││ DSGVO Art. 7 Abs. 3 — Widerruf││ jederzeit, rückwirkend nicht. │└──────────────────────────────┘6.6 🌐 Web — Webhook-Delivery-Historie
Abschnitt betitelt „6.6 🌐 Web — Webhook-Delivery-Historie“┌────────────────────────────────────────────────────────────────────┐│ Webhook https://kunde.de/werkszeit-webhook [Testen] [Secret ↻] │├────────────────────────────────────────────────────────────────────┤│ Events: ☑ time.entry.created ☑ invoice.sent ☐ signature.captured ││ ││ Letzte 100 Deliveries [⬇ CSV 7 Tage] ││ ┌────────────────────────────────────────────────────────────────┐ ││ │Zeit │Event │Status│Lat. │Retries │ ││ ├────────────────┼───────────────────┼──────┼──────┼────────────┤ ││ │19.04 13:38:22 │invoice.sent │ 200 │142ms │0 │ ││ │19.04 13:37:01 │time.entry.created │ 200 │ 89ms │0 │ ││ │19.04 13:36:45 │time.entry.created │ 500 │ 42ms │3 ⚠ retry │ ││ │19.04 13:36:15 │time.entry.created │ 504 │10.0s │5 ❌ DLQ │ ││ └────────────────────────────────────────────────────────────────┘ ││ Dead-Letter-Queue: 1 Event (24h Aufbewahrung) [Replay ⟲] │└────────────────────────────────────────────────────────────────────┘6.7 Flow — OAuth 2.1 + PKCE End-to-End
Abschnitt betitelt „6.7 Flow — OAuth 2.1 + PKCE End-to-End“Partner-App Browser auth.werkszeit.de api.werkszeit.de │ │ │ │ │ 1. code_verifier│ │ │ │ = random(43) │ │ │ │ 2. code_challenge │ │ │ = SHA256(verifier) │ │ │ │ │ │ │ 3. Redirect → /oauth/authorize │ │ │ ?client_id=…&code_challenge=… │ │ │ &scope=handwerk.material.read │ │ │ &state=csrf-token │ │ ├────────────────>│ │ │ │ ├────────────────────>│ │ │ │ │ │ │ │ 4. Einwilligungs-Screen │ │ │<────────────────────│ │ │ │ │ │ │ │ 5. [Erlauben] │ │ │ ├────────────────────>│ │ │ │ │ │ │ 6. Redirect zurück mit ?code=…&state=…│ │ │ │<────────────────────│ │ │<────────────────│ │ │ │ │ │ │ │ 7. POST /oauth/token │ │ │ code=… & code_verifier=… │ │ ├────────────────────────────────────────>│ │ │ │ │ │ │ 8. access_token (JWT) + refresh_token │ │ │<────────────────────────────────────────│ │ │ │ │ │ │ 9. GET /v1/handwerk/material/stock │ │ │ Authorization: Bearer <access_token> │ ├──────────────────────────────────────────────────────────>│ │ │ │ │ │ 10. 200 OK (gefiltert auf Tenant + Scopes) │ │<──────────────────────────────────────────────────────────│7. Datenmodell-Skizze
Abschnitt betitelt „7. Datenmodell-Skizze“Technologie: Drizzle ORM, PostgreSQL 17, RLS aktiv. Alle Mandanten-Tabellen haben
tenant_id+ RLS-Policy. Neue Tabellen für §3.12:
7.1 api_keys
Abschnitt betitelt „7.1 api_keys“import { pgTable, uuid, text, timestamp, inet, bytea, boolean } from 'drizzle-orm/pg-core';
export const apiKeys = pgTable('api_keys', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id').notNull().references(() => tenants.id), createdByUserId: uuid('created_by_user_id').notNull().references(() => users.id),
name: text('name').notNull(), // „Kanzlei Bauer DATEV-Sync" environment: text('environment', { enum: ['live', 'sandbox'] }).notNull(), prefix: text('prefix').notNull(), // „wz_live_k7" — erste 10 Chars, für UI-Anzeige keyHash: text('key_hash').notNull(), // Argon2id(volle Key) scopes: text('scopes').array().notNull(), // ['kern.time.read', 'kern.payroll.read']
ipAllowlist: inet('ip_allowlist').array(), // optional lastUsedAt: timestamp('last_used_at', { withTimezone: true }), lastUsedIp: inet('last_used_ip'),
status: text('status', { enum: ['active', 'revoked', 'expired'] }).notNull().default('active'), revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), expiresAt: timestamp('expires_at', { withTimezone: true }), // null = kein Ablauf; sandbox default 7d});RLS:
CREATE POLICY api_keys_tenant_isolation ON api_keys USING (tenant_id = current_setting('app.tenant_id')::uuid);CREATE POLICY api_keys_admin_only ON api_keys FOR ALL USING (current_setting('app.role') IN ('admin', 'owner'));7.2 oauth_clients · oauth_authorizations
Abschnitt betitelt „7.2 oauth_clients · oauth_authorizations“export const oauthClients = pgTable('oauth_clients', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id').notNull().references(() => tenants.id),
clientIdPub: text('client_id_pub').notNull().unique(), // „wz_oauth_client_…" clientSecretHash: text('client_secret_hash').notNull(), // Argon2id
name: text('name').notNull(), logoUrl: text('logo_url'), redirectUris: text('redirect_uris').array().notNull(), allowedScopes: text('allowed_scopes').array().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), revokedAt: timestamp('revoked_at', { withTimezone: true }),});
export const oauthAuthorizations = pgTable('oauth_authorizations', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id').notNull().references(() => tenants.id), clientId: uuid('client_id').notNull().references(() => oauthClients.id), userId: uuid('user_id').notNull().references(() => users.id),
scopes: text('scopes').array().notNull(), // tatsächlich eingewilligt (Teilmenge von allowed_scopes) refreshTokenHash: text('refresh_token_hash').notNull(), refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), revokedAt: timestamp('revoked_at', { withTimezone: true }), // User-Widerruf});7.3 webhook_endpoints · webhook_deliveries
Abschnitt betitelt „7.3 webhook_endpoints · webhook_deliveries“export const webhookEndpoints = pgTable('webhook_endpoints', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id').notNull().references(() => tenants.id),
url: text('url').notNull(), // muss https:// sein, geprüft in Zod-Schema events: text('events').array().notNull(), // ['time.entry.created', 'invoice.sent'] signingSecret: bytea('signing_secret').notNull(), // 32 Byte, in Secrets-Manager, hier Platzhalter signingSecretPrev: bytea('signing_secret_prev'), // während Rotation für 24h gültig signingSecretPrevValidUntil: timestamp('signing_secret_prev_valid_until', { withTimezone: true }),
status: text('status', { enum: ['active', 'paused', 'dead'] }).notNull().default('active'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),});
export const webhookDeliveries = pgTable('webhook_deliveries', { id: uuid('id').primaryKey().defaultRandom(), // zugleich Event-Id im Header tenantId: uuid('tenant_id').notNull().references(() => tenants.id), endpointId: uuid('endpoint_id').notNull().references(() => webhookEndpoints.id),
eventType: text('event_type').notNull(), // 'invoice.sent' eventTimestamp: timestamp('event_timestamp', { withTimezone: true }).notNull(), payload: jsonb('payload').notNull(), // komplettes Body
attemptNumber: integer('attempt_number').notNull().default(0), responseStatus: integer('response_status'), responseLatencyMs: integer('response_latency_ms'), errorMessage: text('error_message'),
status: text('status', { enum: ['queued', 'sent', 'failed', 'dead_letter'] }).notNull(), nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),});7.4 idempotency_keys (Redis-gespiegelt für Audit)
Abschnitt betitelt „7.4 idempotency_keys (Redis-gespiegelt für Audit)“export const idempotencyKeys = pgTable('idempotency_keys', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id').notNull().references(() => tenants.id), apiKeyId: uuid('api_key_id').references(() => apiKeys.id), // oder oauthAuthorizationId
key: text('key').notNull(), // UUID v7 vom Client requestHash: bytea('request_hash').notNull(), // SHA-256 des Request-Body responseStatus: integer('response_status').notNull(), responseBody: jsonb('response_body').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), // +24h}, (t) => ({ uniquePerTenant: uniqueIndex('idempotency_key_per_tenant').on(t.tenantId, t.key),}));7.5 api_audit_log
Abschnitt betitelt „7.5 api_audit_log“Append-only, alle sicherheitsrelevanten API-Ereignisse.
export const apiAuditLog = pgTable('api_audit_log', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id').notNull(),
eventType: text('event_type').notNull(), // 'api_key.created', 'oauth.consent.granted', 'webhook.delivery.failed' actorUserId: uuid('actor_user_id'), // der handelnde User targetId: uuid('target_id'), // betroffenes Objekt (Key-Id, Client-Id, Endpoint-Id)
ip: inet('ip'), userAgent: text('user_agent'), metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),});8. API-Endpunkte
Abschnitt betitelt „8. API-Endpunkte“Alle Endpoints werden über Zod-Schemas deklariert und generieren automatisch OpenAPI 3.1 + SDK-Typen.
8.1 Public Data API (Beispiele)
Abschnitt betitelt „8.1 Public Data API (Beispiele)“| Methode | Pfad | Zweck | Idempotent | Modul-Gate |
|---|---|---|---|---|
GET |
/v1/kern/time-entries |
Zeitbuchungen listen, scoped | ja | module.kern.time |
POST |
/v1/kern/time-entries |
Zeitbuchung anlegen | via Idempotency-Key | module.kern.time |
GET |
/v1/kern/time-entries/{id} |
Einzelabruf | ja | module.kern.time |
POST |
/v1/kern/invoices/{id}/send |
Rechnung versenden | via Idempotency-Key (Pflicht) | module.kern.invoice |
POST |
/v1/kern/reports/exports |
Report-Export anfordern | via Idempotency-Key | module.kern.reporting (§3.7) |
GET |
/v1/kern/reports/exports/{id} |
Export-Status | ja | module.kern.reporting |
GET |
/v1/handwerk/material/stock |
Material-Bestand | ja | module.handwerk.material |
8.2 Admin-API (API-Zugänge-Verwaltung)
Abschnitt betitelt „8.2 Admin-API (API-Zugänge-Verwaltung)“| Methode | Pfad | Zweck | Rolle |
|---|---|---|---|
GET |
/v1/admin/api-keys |
Keys auflisten | Admin |
POST |
/v1/admin/api-keys |
Neuen Key erzeugen (einmalige Key-Rückgabe) | Admin |
POST |
/v1/admin/api-keys/{id}/rotate |
Key rotieren (alter 24h gültig) | Admin |
DELETE |
/v1/admin/api-keys/{id} |
Key widerrufen | Admin |
GET |
/v1/admin/oauth-clients |
OAuth-Clients listen | Admin |
POST |
/v1/admin/oauth-clients |
OAuth-Client anlegen | Admin |
GET |
/v1/admin/oauth-authorizations |
aktive Einwilligungen | Admin |
DELETE |
/v1/admin/oauth-authorizations/{id} |
Einwilligung widerrufen | Admin / betroffener User |
GET |
/v1/admin/webhooks |
Webhooks listen | Admin |
POST |
/v1/admin/webhooks |
Webhook anlegen | Admin |
POST |
/v1/admin/webhooks/{id}/rotate-secret |
Signing-Secret rotieren | Admin |
POST |
/v1/admin/webhooks/{id}/test |
Test-Delivery auslösen | Admin |
GET |
/v1/admin/webhooks/{id}/deliveries |
Zustellungs-Historie | Admin |
GET |
/v1/admin/audit-log |
API-Audit-Log | Admin |
8.3 OAuth 2.1 Endpoints (auf separater Host-Origin)
Abschnitt betitelt „8.3 OAuth 2.1 Endpoints (auf separater Host-Origin)“| Methode | Pfad | Zweck |
|---|---|---|
GET |
https://auth.werkszeit.de/oauth/authorize |
Authorization-Endpoint |
POST |
https://auth.werkszeit.de/oauth/token |
Token-Endpoint (Exchange + Refresh) |
POST |
https://auth.werkszeit.de/oauth/revoke |
Revocation-Endpoint (RFC 7009) |
POST |
https://auth.werkszeit.de/oauth/introspect |
Introspection (RFC 7662, für Ressource-Server) |
GET |
https://auth.werkszeit.de/.well-known/oauth-authorization-server |
Discovery (RFC 8414) |
GET |
https://auth.werkszeit.de/.well-known/jwks.json |
JWKs für JWT-Verifikation |
8.4 Beispiel — POST /v1/kern/time-entries (cURL + Request/Response)
Abschnitt betitelt „8.4 Beispiel — POST /v1/kern/time-entries (cURL + Request/Response)“curl -X POST https://api-sandbox.werkszeit.de/v1/kern/time-entries \ -H "X-Werkszeit-API-Key: wz_sandbox_abc123…" \ -H "Idempotency-Key: 01870abc-4d5e-7890-a1b2-c3d4e5f67890" \ -H "Content-Type: application/json" \ -d '{ "project_id": "01872de0-9b3a-7c4d-8e1f-a2b3c4d5e6f7", "started_at": "2026-04-19T07:02:00+02:00", "source": "nfc" }'HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8RateLimit-Policy: 300;w=60RateLimit-Limit: 300RateLimit-Remaining: 289RateLimit-Reset: 28X-Werkszeit-Sandbox-Reset: 2026-04-20T03:00:00+02:00
{ "id": "01872f3b-1234-7abc-9def-5678901abcde", "tenant_id": "sandbox-werkszeit-shk", "user_id": "01870abc-…", "project_id": "01872de0-9b3a-7c4d-8e1f-a2b3c4d5e6f7", "started_at": "2026-04-19T07:02:00+02:00", "source": "nfc", "sha256": "c7a44e8d1f9e12…", "created_at": "2026-04-19T07:02:00.184+02:00"}8.5 Webhook-Beispiel — invoice.sent
Abschnitt betitelt „8.5 Webhook-Beispiel — invoice.sent“Request vom Werkszeit-Backend an den Kunden-Endpoint:
POST /werkszeit-webhook HTTP/1.1Host: kunde.deContent-Type: application/json; charset=utf-8X-Werkszeit-Signature: sha256=7c3f8a9e…X-Werkszeit-Timestamp: 1745073600X-Werkszeit-Event-Id: 01872f4a-abcd-7def-9012-3456789abcdeX-Werkszeit-Event-Type: invoice.sentX-Werkszeit-Tenant-Id: 01870tnt-…
{ "specversion": "1.0", "type": "invoice.sent", "id": "01872f4a-abcd-7def-9012-3456789abcde", "time": "2026-04-19T13:37:01+02:00", "source": "/werkszeit/musterbetrieb-maler", "datacontenttype": "application/json", "data": { "invoice_id": "01872ff0-…", "invoice_number": "2026-04-0042", "customer_id": "01870cust-…", "amount_cents": 847500, "currency": "EUR", "sent_at": "2026-04-19T13:37:00+02:00" }}Signatur-Verifikation (Node.js-Beispiel):
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWerkszeit(req, signingSecret) { const signature = req.headers['x-werkszeit-signature']?.split('=')[1]; const timestamp = req.headers['x-werkszeit-timestamp']; const body = req.rawBody;
// Replay-Schutz: ±5 Minuten const age = Math.abs(Date.now()/1000 - parseInt(timestamp, 10)); if (age > 300) throw new Error('Replay: timestamp too old');
const expected = createHmac('sha256', signingSecret) .update(`${timestamp}.${body}`) .digest('hex');
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { throw new Error('Signature mismatch'); }}8.6 Error-Format (Problem Details — RFC 9457)
Abschnitt betitelt „8.6 Error-Format (Problem Details — RFC 9457)“HTTP/1.1 429 Too Many RequestsContent-Type: application/problem+json; charset=utf-8Retry-After: 42RateLimit-Limit: 300RateLimit-Remaining: 0RateLimit-Reset: 42
{ "type": "https://docs.werkszeit.de/api/errors/rate-limited", "title": "Rate limit exceeded", "status": 429, "detail": "Der Starter-Plan erlaubt 300 Requests pro Minute. Erreicht um 13:37:42 CET.", "instance": "/v1/kern/time-entries", "retry_after_s": 42, "plan": "starter", "current_usage_per_min": 301}9. Offline-Profil
Abschnitt betitelt „9. Offline-Profil“Die API-Admin-UI ist ein reines Online-Tool (Einstellungen), daher kein Offline-Requirement. Für API-Clients hingegen gilt:
- FR-9.1 · Clients müssen
429+503graceful behandeln und mit Backoff wiederholen. SDK liefert Default-Retry-Middleware mit (exp-backoff, Jitter, max 3 Versuche). - FR-9.2 · Idempotency-Key ermöglicht, dass ein Client nach Netzabbruch denselben Request erneut absenden kann, ohne Doppeleffekte.
- FR-9.3 · Webhook-Empfänger müssen bei temporärem Ausfall (eigene DB down, Queue voll) mit 5xx antworten — der Werkszeit-Server retry’t dann automatisch (5 Versuche, 30s→6h Backoff).
- FR-9.4 · Status-Page (V1) zeigt auch planmäßige Wartungen 24h im Voraus, damit Clients ihre Scheduler anpassen können.
Der Werkszeit-Client (Flutter) ist offline-first (siehe §3.1); das ist aber keine Eigenschaft der Public API, sondern der internen Outbox-Architektur des Clients.
10. Compliance-Mapping
Abschnitt betitelt „10. Compliance-Mapping“10.1 GoBD (§147 AO)
Abschnitt betitelt „10.1 GoBD (§147 AO)“- Audit-Log (
api_audit_log) ist append-only, auditierbar, 10 Jahre aufbewahrt. - API-Key-Rotationen und Webhook-Deliveries sind Teil des GoBD-Nachweises „wer hat wann welche Daten abgerufen“.
- Die Spec-Versionierung dokumentiert, mit welcher API-Version Exporte erzeugt wurden (Export-PDF-Footer: „Werkszeit API v1.2, Endpoint /v1/kern/reports/exports“).
10.2 DSGVO
Abschnitt betitelt „10.2 DSGVO“- Art. 5 (Rechtmäßigkeit, Transparenz): Der Einwilligungs-Screen zeigt Scope-Liste menschlesbar, nicht als Scope-Strings.
- Art. 6 Abs. 1 b/f: Vertrag (Kunde ↔ Werkszeit) + berechtigtes Interesse (der Kunde möchte eine Integration).
- Art. 7 Abs. 3 (Widerruf): „Verbundene Apps“-Liste jederzeit, Widerruf 1-Klick. Wirkt nicht rückwirkend, aber ab Zeitpunkt-X.
- Art. 12 (Transparenz): Portal dokumentiert, welche Daten welche Endpoints liefern.
- Art. 15 (Auskunft) / Art. 20 (Datenübertragbarkeit): Über die API selbst (
GET /v1/users/{id}/export— out of scope dieses Feinkonzepts, siehe §3.11). - Art. 30 (Verzeichnis von Verarbeitungstätigkeiten): Pro OAuth-Client wird protokolliert, welche Scopes, welcher Partner. Admin kann
CSV-Exportfür DSB. - Art. 32 (Technisch-organisatorische Maßnahmen): TLS 1.3, API-Keys als Argon2id-Hash, Webhook-Signaturen HMAC-SHA-256, Rate-Limits, IP-Allowlist, Audit-Log.
10.3 BFSG / WCAG 2.2 AA (ab 28.06.2025)
Abschnitt betitelt „10.3 BFSG / WCAG 2.2 AA (ab 28.06.2025)“- Admin-UI (
Einstellungen → API-Zugänge) ist WCAG 2.2 AA-konform: Kontraste 4.5:1+, Tastatur-Navigation, Screenreader-Labels, Touch-Targets 48dp. - Swagger UI erhält ein Werkszeit-Theme mit AA-Kontrasten (Default-Theme genügt nicht).
- Code-Beispiele: Copy-Button mit
aria-label, Code-Blöcke alsrole="region". - Status-Page: Piktogramme zusätzlich zu Farbe (🟢/🟡/🔴 + „Verfügbar / Eingeschränkt / Ausfall“).
10.4 BetrVG §87 Abs. 1 Nr. 6 (Technik zur Leistungskontrolle)
Abschnitt betitelt „10.4 BetrVG §87 Abs. 1 Nr. 6 (Technik zur Leistungskontrolle)“- Jeder OAuth-Scope, der Mitarbeiter-Leistungsdaten zugänglich macht (z.B.
kern.time.read), erfordert eine Betriebsvereinbarung beim Handwerksbetrieb. Das Portal zeigt beim Anlegen eines solchen Scopes den Warn-Banner: „Dieser Scope betrifft Leistungskontrolle — bitte Betriebsrat informieren.“ - Der Admin muss in einem Dialogfeld bestätigen: „Die Betriebsvereinbarung liegt vor“ — dokumentiert im Audit-Log (
api_key.createdmitmetadata.betriebsvereinbarung_ack: true).
10.5 AGPL-3.0 (Lizenz werkszeit-server)
Abschnitt betitelt „10.5 AGPL-3.0 (Lizenz werkszeit-server)“- Werkszeit betreibt die Sandbox als Netzwerkdienst für Dritte → AGPL §13 verpflichtet zur Bereitstellung des Quellcodes der laufenden Version.
- Portal-Footer: „Sandbox läuft auf werkszeit-server v1.8.0 (AGPL-3.0). Quellcode:
github.com/werkszeit/werkszeit-server/tree/v1.8.0“. - OpenAPI-Spec ist nicht AGPL-infiziert (Spec ist Metadaten), aber Client-SDKs (falls Werkszeit-gepflegt) werden unter MIT ausgegeben.
10.6 OWASP API Security Top 10 (2023) — Mapping
Abschnitt betitelt „10.6 OWASP API Security Top 10 (2023) — Mapping“| OWASP | Adressiert durch |
|---|---|
| API1: Broken Object Level Authorization | RLS in Postgres, Scope-Check im Middleware |
| API2: Broken Authentication | OAuth 2.1 + PKCE, API-Keys Argon2id, JWT ES256 |
| API3: Broken Object Property Level Authorization | Zod-Response-Schema filtert Felder, keine SELECT *-Lecks |
| API4: Unrestricted Resource Consumption | Rate-Limits (FR-5.6) |
| API5: Broken Function Level Authorization | Modul-Gate in Route-Registrierung |
| API6: Unrestricted Access to Sensitive Business Flows | Idempotency-Key + Bot-Detection via Cloudflare |
| API7: Server Side Request Forgery | Webhook-URLs werden validiert (kein localhost, kein RFC-1918 private) |
| API8: Security Misconfiguration | CI-Gate gegen Spec-Drift, Terraform-verwaltete Cloudflare-Regeln |
| API9: Improper Inventory Management | Alle Routes in OpenAPI, keine “undokumentierten” Endpoints |
| API10: Unsafe Consumption of 3rd Party APIs | Der Client ist für Consumption zuständig, Werkszeit-SDK wrapt externes DWD/DATEV |
11. Edge-Cases
Abschnitt betitelt „11. Edge-Cases“- Sandbox-Reset während Request. Um 03:00:00 CET läuft der Reset-Job. Ein Client-Request, der um 02:59:58 startet und 5s dauert, trifft einen Zustandswechsel. → Reset-Job setzt bis 02:59:55 ein Feature-Flag
sandbox.reset.in_progress=true, Routes antworten503 Service UnavailablemitRetry-After: 60. - API-Key-Rotation Race. Client A hat den alten Key gecached, Client B rotiert. Alter Key 24h parallel gültig (FR-5.9.2). Beide Clients laufen. Nach 24h: Alter Key
401 Unauthorized, Log-Eintragapi_key.old_used_after_rotation. - Webhook-Empfänger dauerhaft tot. 5 Retries fehlschlagen → DLQ, Admin-E-Mail. Nach weiteren 24h DLQ-Aufbewahrung: Endpoint-Status auf
dead, keine neuen Deliveries mehr, Admin muss manuell reaktivieren. - Idempotency-Key mit abweichendem Body. Client schickt zweiten Call mit demselben Key, aber geändertem Body →
409 Conflict(FR-5.8.3), Log-Eintrag. - OAuth-Token nach User-Löschung. User wird in Tenant gelöscht. Aktive OAuth-Authorizations → automatisch widerrufen via Foreign-Key
ON DELETE CASCADE. Partner-App bekommt beim nächsten Refreshinvalid_grant. - Rate-Limit-Hop zwischen Keys. Ein Tenant hat 3 Keys (je 300/min Starter), könnte theoretisch 900/min fahren. → Tenant-Hard-Cap 5000/min (FR-5.6.2) greift bei Pro, bei Starter/Business greift der planbezogene Wert pro Key.
- Webhook-Signatur mit kleinstem Skew. Empfänger-Clock ist 6 Min in der Zukunft. Timestamp-Check schlägt fehl (
abs(now - ts) > 300). → Empfänger-Verantwortung (NTP). Dokumentiert im Portal. - Scope-Escalation-Versuch. Partner-App fragt initial nur
handwerk.material.read. Bei späterem Refresh fügt Partner-Appkern.invoice.writein den Request. → Werkszeit-Backend: Refresh-Token trägt nur die original eingewilligten Scopes, neue Scopes erfordern neuen Authorization-Code-Flow. - Zero-Width-Unicode in API-Key-Namen. Admin kopiert einen Namen aus Word mit Zero-Width-Zeichen → Zod-Schema
z.string().regex(/^[\p{L}\p{N}\s\-_.]{1,64}$/u)lehnt ab mit400 Bad Request. - OAuth-Client mit privater Redirect-URI.
redirect_uri=http://localhost:3000/cbist erlaubt (Dev-Setup),https://192.168.1.5/cbebenfalls (Intranet), aberredirect_uri=file:///…oderjavascript:…werden zurückgewiesen (invalid_redirect_uri). - Sandbox-Daten-Leak über HTTP-Referer. Partner-App öffnet in der Sandbox ein
<a href="https://example.com?token=abc">→ Referer leakt Token. → Portal setztReferrer-Policy: strict-origin-when-cross-origin, SDKs nutzen Bearer-Header, nie Query. - Idempotency-Key-Kollision zwischen Tenants. Client mit Zugang zu Tenant A und Tenant B nutzt zufällig denselben UUID-v7. → Key ist in
idempotencyKeyspro Tenant unique (uniquePerTenantIndex), keine Kollision.
12. Akzeptanzkriterien (Gherkin)
Abschnitt betitelt „12. Akzeptanzkriterien (Gherkin)“12.1 Happy Path — OAuth 2.1 + PKCE Partner-App
Abschnitt betitelt „12.1 Happy Path — OAuth 2.1 + PKCE Partner-App“Funktion: Partner-App erhält Zugriff auf Material-Bestand via OAuth 2.1 + PKCE
Szenario: BauMaterial24 zieht erstmalig Material-Bestand Angenommen ein Tenant "musterbetrieb-maler" mit aktivem Modul "handwerk.material" Und ein Admin "Thomas Schmidt" mit eingerichtetem OAuth-Client "BauMaterial24 Inventar-Sync" Und der Client hat Redirect-URI "https://baumaterial24.de/callback" Und allowed_scopes enthält "handwerk.material.read"
Wenn die Partner-App code_verifier generiert (43 Zeichen zufällig) Und code_challenge als SHA256(code_verifier) base64url-kodiert berechnet Und den Browser zu "https://auth.werkszeit.de/oauth/authorize?client_id=wz_oauth_client_abc&code_challenge=…&code_challenge_method=S256&scope=handwerk.material.read&state=csrf-xyz&redirect_uri=https%3A%2F%2Fbaumaterial24.de%2Fcallback&response_type=code" schickt
Dann zeigt Werkszeit einen Einwilligungs-Screen mit | Feld | Wert | | App-Name | BauMaterial24 Inventar-Sync | | Scope (de) | Material-Bestand lesen | | Tenant | musterbetrieb-maler |
Wenn Thomas "Erlauben" klickt Dann wird der Browser auf "https://baumaterial24.de/callback?code=…&state=csrf-xyz" redirektet Und die Partner-App tauscht code + code_verifier gegen einen access_token (JWT, ES256, 1h TTL) Und ein refresh_token (opaque, 30d TTL) aus
Wenn die Partner-App "GET /v1/handwerk/material/stock" mit "Authorization: Bearer <access_token>" aufruft Dann ist der Response-Status 200 Und die Response enthält nur Daten aus Tenant "musterbetrieb-maler" Und die Response-Header enthalten "RateLimit-Remaining" (RFC 9239) Und der Audit-Log-Eintrag "oauth.token.used" ist erzeugt12.2 Edge — Idempotenz bei Netzabbruch
Abschnitt betitelt „12.2 Edge — Idempotenz bei Netzabbruch“ Szenario: Rechnungs-Versand mit Netzabbruch wird idempotent retried Angenommen ein API-Client mit Key "wz_live_abc" und Scope "kern.invoice.send" Und eine Rechnung "inv-123" im Status "draft" Und der Client generiert Idempotency-Key "01870abc-4d5e-7890-a1b2-c3d4e5f67890"
Wenn der Client "POST /v1/kern/invoices/inv-123/send" mit diesem Key schickt Und die Antwort wegen Netzabbruch nicht beim Client ankommt Und der Client denselben Request 10 Sekunden später mit identischem Key wiederholt
Dann erhält der Client exakt dieselbe Antwort wie beim ersten Mal Und die Rechnung wurde nur einmal per E-Mail versendet Und der Audit-Log zeigt einen "invoice.sent"-Event, nicht zwei Und das Webhook "invoice.sent" wurde nur einmal ausgeliefert12.3 Edge — Rate-Limit mit Burst
Abschnitt betitelt „12.3 Edge — Rate-Limit mit Burst“ Szenario: Starter-Plan-Client überschreitet 300/min mit Burst Angenommen ein API-Client mit Key "wz_live_starter_xyz", Plan "starter" (300/min) Und ein Burst-Fenster von 10 Sekunden mit 2× Quota (600 req)
Wenn der Client in 8 Sekunden 400 Requests feuert Dann werden alle 400 Requests akzeptiert (innerhalb des Burst-Fensters) Und die Response-Header zeigen "RateLimit-Remaining" fallend
Wenn der Client weitere 250 Requests in den nächsten 5 Sekunden feuert Dann erhält er ab Request 601 den Status 429 Und die Response enthält "Retry-After: <sekunden bis fensterreset>" Und die Response enthält "RateLimit-Policy: 300;w=60" Und die Response ist "application/problem+json" mit RFC 9457-Body12.4 Edge — Webhook-Signatur-Verifikation
Abschnitt betitelt „12.4 Edge — Webhook-Signatur-Verifikation“ Szenario: Webhook mit manipuliertem Body wird abgelehnt Angenommen ein Webhook-Endpoint "https://kunde.de/hook" mit Signing-Secret "secret-abc" Und ein Event "invoice.sent" mit Body "{...original...}" Und ein berechneter Signature-Header HMAC-SHA256("secret-abc", timestamp + "." + body)
Wenn ein Angreifer den Body auf "{...manipuliert...}" ändert und den Header belässt Und der Empfänger die Werkszeit-Verify-Funktion aufruft
Dann wird "Signature mismatch" geworfen Und der Empfänger lehnt den Event ab (nicht prozessiert)
Szenario: Replay-Angriff 10 Minuten später Angenommen ein gültig signierter Webhook-Request mit Timestamp 1745073000 Und der Empfänger empfängt diesen Request 10 Minuten später (Timestamp now = 1745073600)
Wenn die Werkszeit-Verify-Funktion aufgerufen wird Dann ist abs(now - ts) = 600 > 300 Und die Funktion wirft "Replay: timestamp too old" Und der Empfänger lehnt den Event ab12.5 Edge — Sandbox-Reset-Fenster
Abschnitt betitelt „12.5 Edge — Sandbox-Reset-Fenster“ Szenario: Request während Sandbox-Reset wird korrekt abgewiesen Angenommen der Sandbox-Reset-Job läuft um 03:00:00 CET Und das Feature-Flag "sandbox.reset.in_progress" ist seit 02:59:55 aktiv
Wenn ein Client um 02:59:58 "POST /v1/kern/time-entries" an api-sandbox.werkszeit.de schickt Dann ist der Response-Status 503 Und die Response enthält "Retry-After: 60" Und die Response-Body enthält "sandbox_reset_in_progress"
Wenn der Client um 03:01:30 denselben Request wiederholt Dann ist der Response-Status 201 Und die Datenbank enthält den Seed-Stand von 03:00 CET (keine vorherigen Buchungen)12.6 Edge — OAuth-Token nach User-Löschung
Abschnitt betitelt „12.6 Edge — OAuth-Token nach User-Löschung“ Szenario: Gelöschter User invalidiert alle seine OAuth-Authorizations Angenommen ein User "Thomas Schmidt" mit 2 aktiven OAuth-Authorizations (BauMaterial24, DATEV-Sync) Und Thomas' User-Account wird (gem. DSGVO Art. 17 + Austritt) gelöscht
Wenn die DELETE-Transaktion committed ist Dann sind beide OAuth-Authorizations als "revoked_at = now()" markiert (via CASCADE)
Wenn die Partner-App "BauMaterial24" ihr refresh_token gegen ein neues access_token tauschen will Dann ist der Response-Status 400 Und der Body enthält "error: invalid_grant" Und ein Audit-Log-Eintrag "oauth.grant.invalidated_by_user_deletion" wird erzeugt13. Test-Cases
Abschnitt betitelt „13. Test-Cases“13.1 Unit
Abschnitt betitelt „13.1 Unit“- Zod-Schema-Roundtrip: Jedes Route-Schema serialisiert + deserialisiert verlustfrei.
- HMAC-Signatur: Bekannte Secret+Body+Timestamp → erwartete Signatur.
- API-Key-Hash: Argon2id mit bekannten Parametern produziert wiederholbar denselben Hash bei gleichem Salt.
- UUID-v7-Validator: Format-Check, Timestamp-Extraction, Monotonie.
- Rate-Limit-Counter: Sliding-Window-Logik, Burst-Token-Bucket, Reset.
13.2 Integration (Backend)
Abschnitt betitelt „13.2 Integration (Backend)“- Spec-Drift-Gate:
pnpm openapi:generateproduziert byteidentisches Artefakt zupackages/openapi-spec/openapi.json. - API-Key-Middleware: Gültiger Key → Request passes; widerrufener Key → 401; expired Key → 401; IP außerhalb Allowlist → 403.
- OAuth-Flow E2E: Authorize → Token → Refresh → Revoke. Alle Status-Codes, alle Error-Cases.
- Webhook-Delivery: Mock-Endpoint nimmt Request entgegen, Signatur wird verifiziert, Retry bei 500, DLQ nach 5 Fails.
- Idempotency: Zweiter Call mit identischem Key + Body → identische Antwort, Action nur einmal ausgeführt.
- RLS-Isolation: Key von Tenant A kann keine Daten von Tenant B lesen (Test via SQL-Injection-Versuche und echte Endpoint-Calls).
- Sandbox-Reset: Job truncated + seeded, Clock-Test simuliert 03:00 CET, Feature-Flag greift.
13.3 E2E (Portal-UI, Playwright)
Abschnitt betitelt „13.3 E2E (Portal-UI, Playwright)“- Swagger-UI „Try it out“: Besucher erzeugt Sandbox-Key, ruft POST-Endpoint auf, sieht Response.
- OAuth-Einwilligungs-Screen: Partner-App startet Flow, User loggt ein, klickt Erlauben, Partner-App erhält Token.
- Admin-UI API-Keys-Tab: Key erzeugen → Klartext einmalig angezeigt → DB-Entry als Hash → Widerruf.
- Webhook-Delivery-Historie: Mock-Endpoint empfängt Delivery, Tabelle zeigt Status+Latenz.
- Accessibility (axe-core): Portal-Seiten zero violations auf AA-Level. Tab-Order korrekt.
13.4 Contract / Consumer-Driven
Abschnitt betitelt „13.4 Contract / Consumer-Driven“- Referenzkunde Kanzlei Bauer liefert ihren Pact-Broker-Contract. CI bricht, wenn ein Deploy den Contract verletzt.
- SDKs (Dart/TS/Python) haben Generated-Tests aus OpenAPI-Spec.
13.5 Load / Chaos
Abschnitt betitelt „13.5 Load / Chaos“- k6-Skript: 1000 RPS gegen
/v1/kern/time-entriesaus 3 Tenants × 5 Keys, Rate-Limits greifen korrekt. - Chaos: Redis-Cluster-Node ausfallen lassen, Rate-Limits fallen auf “fail open” mit Warnung, Idempotency-Cache auf PG-Replay-Fallback.
13.6 Security
Abschnitt betitelt „13.6 Security“- OWASP API Top 10: je Kategorie mindestens 1 Test-Case (siehe §10.6).
- Webhook-SSRF: Registrierung von
http://169.254.169.254/…(EC2 Metadata) wird abgelehnt. - JWT-Tampering: Manipuliertes Access-Token → 401.
- Rate-Limit-Bypass-Versuch: Client setzt
X-Forwarded-For, wird ignoriert (Cloudflare-Header trusted, nicht User-Header).
14. Nicht-Ziele
Abschnitt betitelt „14. Nicht-Ziele“- NUS-1 GraphQL-API. (REST+OpenAPI deckt alle Use-Cases.)
- NUS-2 gRPC-API. (Keine aktuelle Kundenanfrage.)
- NUS-3 WebSockets/Server-Sent-Events für Live-Updates. (Webhooks reichen für B2B-Integrationen; Live-Daten im Werkszeit-Client laufen über interne Supabase-Realtime, nicht API.)
- NUS-4 API-Versionierung über Header (
Accept-Version: v2). (URL-Präfix/v1/,/v2/ist einfacher, für Debugger sichtbar.) - NUS-5 Eigenes API-Gateway (Kong, Apigee). (Cloudflare + Hono reichen bis ≥10k RPS.)
- NUS-6 SDKs in Ruby, Go, Java, C#. (Nur Dart/TS/Python/PHP im V1.5-Scope.)
- NUS-7 OpenAPI 3.0 (ohne
.1). (3.1 ist JSON-Schema-kompatibel, 3.0 nicht — wir brauchen JSON-Schema für Webhook-Payloads.) - NUS-8 API-Marketplace für Drittanbieter-Apps. (Out-of-scope bis V3; OAuth reicht für individuelle Partner-Integrationen.)
- NUS-9 API-Key-Self-Service für Endkunden des Handwerksbetriebs. (Nur Admin des Tenants legt Keys an.)
- NUS-10 AWS/Azure/GCP-API-Management-Produkte. (Wir bleiben EU-souverän auf Cloudflare + AWS Frankfurt.)
15. Risiken & offene Annahmen
Abschnitt betitelt „15. Risiken & offene Annahmen“15.1 Risiken
Abschnitt betitelt „15.1 Risiken“| # | Risiko | Wahrscheinl. | Impact | Mitigation |
|---|---|---|---|---|
| R1 | Spec-Drift trotz CI-Gate (manuelle Spec-Commits) | mittel | hoch | Git-Hook pre-push, verweigert Commit mit falscher Spec; PR-Template erinnert |
| R2 | Sandbox-Missbrauch (Scraping, Mining) | hoch | mittel | Rate-Limits auch in Sandbox, CAPTCHA beim Key-Erzeugen ab 10 Keys/Tag/IP |
| R3 | Webhook-Secret-Leak über Kundenseitige Logs | mittel | hoch | Secret-Rotation empfohlen alle 90 Tage, Portal-UI-Erinnerung |
| R4 | OAuth-Client-Impersonation via gestohlenem client_secret | niedrig | hoch | PKCE reduziert Impact, Refresh-Rotation, Client-Secret nur einmalig gezeigt |
| R5 | Rate-Limit-Fail-Open bei Redis-Ausfall | mittel | mittel | Fail-Open mit Warnung, Tenant-Hard-Cap als Postgres-Check zusätzlich |
| R6 | OpenAPI-Generator produziert invalides JSON-Schema | niedrig | mittel | CI-Validation mit @apidevtools/swagger-parser, E2E-Tests gegen SDK |
| R7 | BFSG-Audit-Fail im Swagger-UI-Default-Theme | mittel | mittel | Custom Werkszeit-Theme, vor Launch axe-core-Audit mit zero violations |
| R8 | Partner-App zieht Daten nach User-Deletion (Race) | niedrig | mittel | CASCADE ON DELETE + Token-Revocation-Cache (60s TTL) |
| R9 | Idempotency-Key-Kollision innerhalb Tenant |
sehr niedrig | mittel | UUID v7 empfohlen, jeder UUID akzeptiert; Request-Hash-Check fängt Missbrauch |
| R10 | Webhook-DLQ läuft voll (spammender dead Endpoint) | niedrig | niedrig | 24h-Aufbewahrung, dann Auto-Purge; Endpoint-Status auf dead → keine neuen Events |
15.2 Offene Annahmen
Abschnitt betitelt „15.2 Offene Annahmen“- A1 · Cloudflare Workers sind ausreichend performant für die Rate-Limit-Middleware (angenommen auf Basis §5.3 TECH-STACK). Zu validieren im Load-Test (§13.5).
- A2 · Referenzkunde Kanzlei Bauer akzeptiert, dass ihre Cron-Jobs auf Redis-gekappte 300/min laufen (Starter-Plan). Business-Upgrade jederzeit möglich.
- A3 · AWS Bedrock Frankfurt reicht für ggf. AI-Endpoints (nicht Teil §3.12, nur falls später Augenthema).
- A4 · AGPL-§13-Compliance wird ggf. vom Pflicht-Feature-Test noch als Rechtsprüfung bestätigt (DSB + IT-Recht-Anwalt).
- A5 · Der Werkszeit-Client nutzt denselben OAuth-Flow wie Partner-Apps nicht — er hat einen separaten First-Party-Flow mit PKCE und Passkey. Abgegrenzt im §3.11 Auth-Feinkonzept.
16. Abhängigkeiten
Abschnitt betitelt „16. Abhängigkeiten“16.1 Technische Abhängigkeiten
Abschnitt betitelt „16.1 Technische Abhängigkeiten“@hono/zod-openapi≥ 1.0 — Spec-Generierung.@asyncapi/parser≥ 3.0 — Webhook-Katalog-Validierung.argon2(Node-Native) — API-Key-Hash.jose(JWT-ES256) — OAuth-Tokens.- Cloudflare Workers + Redis-Cluster (ElastiCache Frankfurt) — Rate-Limits + Idempotency-Cache.
- PostgreSQL 17 mit
pgcrypto,uuid-ossp— IDs + Hashes. - BullMQ — Webhook-Delivery-Queue mit Retry.
- Swagger UI 5.x (mit Werkszeit-Theme-Overlay) — interaktive Doku.
- Redoc 2.x — druckfähige Referenz.
- Docusaurus oder Astro Starlight — Portal-Rahmen (Entscheidung offen, Spike empfohlen).
16.2 Feinkonzept-Abhängigkeiten
Abschnitt betitelt „16.2 Feinkonzept-Abhängigkeiten“- §3.1 Zeiterfassung → API-Endpoints
/v1/kern/time-entries/**. - §3.7 Reporting →
POST /v1/kern/reports/exports(Cross-Link), Webhookreport.export.ready. - §3.11 Auth → OAuth 2.1 + PKCE teilt Infrastruktur (auth.werkszeit.de), Passkey gilt nur First-Party.
- §3.8 Rechnungen →
POST /v1/kern/invoices/{id}/sendist DER Idempotency-Use-Case. - §3.6 Lohn-Export →
POST /v1/kern/payroll/exports(DATEV-CSV-Bauer).
16.3 Externe Abhängigkeiten
Abschnitt betitelt „16.3 Externe Abhängigkeiten“- DATEV-Schnittstellen (SmartLogin-API, Jahr 2) — zukünftige Partner-Integration über OAuth-Scope
integrations.datev.read. - Handwerks-ERP-Plugins (z.B. Lexware, Sage) — eigenverantwortliche Partner-Apps, OAuth-basiert.
17. Referenzkunde-Slot
Abschnitt betitelt „17. Referenzkunde-Slot“17.1 Primär-Referenzkunde
Abschnitt betitelt „17.1 Primär-Referenzkunde“Steuerberater-Kanzlei Bauer & Partner, München.
- Kontakt: Anna Bauer (Partnerin), Stefan Lang (IT-Dienstleister).
- Use-Case: Nächtlich 12 Werkszeit-Kunden abfragen (
/v1/kern/time-entries?since=…,/v1/kern/payroll/exports), in DATEV Lohnbuch importieren. - Volumen: ~200 Kundenwechsel/Monat, ~2000 Zeitbuchungen/Tag.
- Contract-Test: Anna stellt Pact-Broker bereit; Werkszeit-CI validiert gegen Kanzlei-Contract.
17.2 Sekundär-Referenzkunde
Abschnitt betitelt „17.2 Sekundär-Referenzkunde“BauMaterial24 GmbH, Dortmund.
- Kontakt: Tim Osterloh (Integration-Engineer).
- Use-Case: Plugin „BauMaterial24-Inventar-Sync“ für Handwerksbetriebe — OAuth-Authorization-Code-Flow, 2× täglich Material-Bestand lesen, Bestellungen pushen.
- Volumen: 500 Handwerksbetriebe (geplant), ~50 RPS Gesamt.
17.3 Tertiär-Referenzkunde
Abschnitt betitelt „17.3 Tertiär-Referenzkunde“Musterbetrieb Maler-Fischer, Augsburg.
- Kontakt: Franziska Fischer (Geschäftsführerin).
- Use-Case: Eigener Make.com-Workflow — Zapier-ähnlich. Webhook
time.entry.created→ Slack-Nachricht ans Büro. - Volumen: ~50 Webhooks/Tag.
18. Senior-Berater-Empfehlung
Abschnitt betitelt „18. Senior-Berater-Empfehlung“18.1 Strategische Positionierung
Abschnitt betitelt „18.1 Strategische Positionierung“Das Entwickler-Portal ist kein Feature neben dem Produkt, sondern die Produkt-Oberfläche selbst, nur in maschinenlesbar. Jede Beschränkung der API (fehlender Endpoint, schlechte Doku, undurchsichtiges Rate-Limit) wird von Kunden als Einschränkung des Produkts wahrgenommen.
Der Hebel: Das Altsystem hatte 70 undokumentierte Routen und kein öffentliches Portal. Werkszeit startet mit 80+ dokumentierten Endpoints, AsyncAPI-Webhook-Katalog, Sandbox mit One-Click-Key, RFC-konformen Error-Bodies. Das ist ein Verkaufsargument im Handwerksmarkt. In den Referenzkunden-Interviews (Anna Bauer, Tim Osterloh) kam jeder Satz auf “endlich eine API, die einfach funktioniert”.
18.2 Harte Empfehlungen
Abschnitt betitelt „18.2 Harte Empfehlungen“-
Spec-Driven, nicht Spec-documented. Die Spec ist der Code, nicht daneben.
@hono/zod-openapiist der einzige Weg, den ich verantworte. Manuelleopenapi.json-Pflege fällt innerhalb eines Quartals auseinander — das haben wir im Altsystem 3-mal gesehen. CI-Gate ist Pflicht ab Tag 1. -
Sandbox vor allem anderen. Die Sandbox ist kein V1.5-Feature, sondern der wichtigste DX-Moment: Jemand landet auf dem Portal, klickt „Sandbox-Key erzeugen“, macht in 90 Sekunden einen Request. Wenn das nicht im MVP steht, verlieren wir gegen Konkurrenz, die es hat (selbst wenn die Konkurrenz-API selbst schlechter ist).
-
OAuth 2.1 + PKCE auch wenn’s wehtut. API-Keys sind für Server-zu-Server-Integration („Kanzlei Bauer zieht nächtlich Daten“). Aber sobald eine Partner-App im Namen eines Endkunden spricht, muss OAuth 2.1 + PKCE her. Alles andere ist DSGVO-haftbar und erhöht Kundensupport-Aufwand („BauMaterial24 hat unseren API-Key verloren“ — niemand will diesen Anruf).
-
Idempotency-Key als Pflicht bei Money-Moving. Jeder Endpoint, der Geld bewegt (
invoices/send,payroll/runs), fordertIdempotency-Key— wer ihn weglässt, bekommt400. Lieber ein klarer Client-Fehler als ein Doppelversand einer Rechnung, der dann im Support landet. -
Webhooks mit HMAC + Timestamp, nichts anderes. Ich habe in 4 Jahren Beratung drei Incidents gesehen, wo Webhooks mit URL-Query-Token replay-bar waren oder mit Shared-Secret im Body. HMAC-SHA-256 über
ts + '.' + bodymit ±5-Min-Check ist der Stand der Technik — Stripe, GitHub, Linear machen es so. -
AGPL-Hinweis nicht vergessen. Sandbox ist AGPL-pflichtig zu taggen. Ein 10-Wort-Footer ist billiger als ein Abmahnungsrisiko.
-
Status-Page früh. Status-Page ist in V1, nicht V2. Sobald der erste Partner live geht, muss er
status.werkszeit.debookmarken können. Betterstack oder statuspage.io reichen — nicht selber bauen.
18.3 Was ich ablehne
Abschnitt betitelt „18.3 Was ich ablehne“- Eigene API-Key-Implementierung ohne Standard-Library. Argon2id ist nicht schwer, aber Leute rollen eigene Versionen. → Muss
argon2-Node-Modul sein, keine Eigenimplementierung. - Rate-Limits im Application-Code. Muss auf Cloudflare-Worker-Edge liegen, sonst belastet’s die Hono-Worker-Pool und Rate-Limited-Requests machen trotzdem DB-Queries.
- “API-Key im Query-Parameter” als Fallback. Niemals. Header-only, wird nicht verhandelt. Begründung: URL-Logs, Referer, Browser-History.
- OpenAPI 3.0 (ohne
.1). Webhook-Payloads brauchen JSON-Schema, OpenAPI 3.0 hat nur ein Subset. → 3.1 von Anfang an, sonst migrieren wir in 18 Monaten. - „API-Dokumentation als Confluence-Seite“. Portal ist Teil des Produkts, liegt im Repo, wird per CI deployed, Seniorenmoderation: nein.
18.4 Messung des Erfolgs (90 Tage nach Go-Live)
Abschnitt betitelt „18.4 Messung des Erfolgs (90 Tage nach Go-Live)“- ≥ 3 Referenzkunden-Integrationen in Production.
- Spec-Drift-Incidents: 0 (CI-Gate greift).
- OpenAPI-Spec-Downloads pro Woche: > 20 (als Proxy für Entwickler-Interesse).
- Sandbox-Key-Erzeugungen pro Woche: > 50.
- Webhook-Delivery-Success-Rate: > 99,5 % (5 Fails je 1000).
- Rate-Limit-429-Rate: < 0,5 % aller Requests (Proxy für sinnvolle Quotas).
- Portal-Lighthouse-Accessibility-Score: 100/100.
- DSB-Review auf „Verbundene Apps“-UI: grün.
- Support-Tickets „API-Key verloren“: < 2/Monat.
18.5 Offener Diskussionspunkt
Abschnitt betitelt „18.5 Offener Diskussionspunkt“Wir müssen entscheiden, ob der Sandbox-Tenant pro Seed-Paar oder shared zwischen allen Portal-Besuchern ist. Shared ist einfacher, aber ein böswilliger Besucher könnte die Sandbox trashen (kurzfristig, bis 03:00 CET). Pro-Paar ist sicherer, aber teurer (DB-Schema-Proliferation).
Meine Empfehlung: Shared im MVP, pro-Key-isoliert ab V1 (über Sub-Tenant pro Sandbox-Key). Begründung: 50 Sandbox-Keys/Woche × 1 Sub-Tenant = 2600 Sub-Tenants/Jahr — das ist postgres-handhabbar, aber erst, wenn der DB-Migration-Pfad für Sub-Tenants sauber steht (siehe §3.2 Multi-Tenant-Feinkonzept, Abschnitt 7).
Ende Feinkonzept §3.12 · Review durch: CTO · DSB · Referenzkunde · Platform-Team-Lead · DX-Lead. Nächste Schritte:
- Spike
@hono/zod-openapi— 2 Eng-Tage, Proof-of-Concept gegen 3 Endpoints. - DSB-Review der OAuth-Einwilligungs-Screen-Texte (DSGVO Art. 12 Transparenz).
- Anwalts-Review AGPL-§13-Hinweis (IT-Recht).
- Terraform-Modul für Cloudflare-Rate-Limit-Rules, als PR gegen
infra/.