Zum Inhalt springen

Feinkonzept §3.12 — Offene API & Entwickler-Portal

Beta — in Erprobung

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.html Cross-Link: §3.7 Reporting ↔ §3.12 API (Reports exportierbar via POST /v1/kern/reports/exports)


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)
  • 📱 Mobile · 🌐 Web · 🔄 beide · 🧑‍💻 Entwickler-Portal-spezifisch
  • 🟢 MVP · 🟡 V1 · 🔴 V1.5+
  • curl / wz_live_* / wz_sandbox_* als Monospace — echte Demo-Werte.
  • “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-werkszeit hinter api-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.

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.

  1. 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.
  2. „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.
  3. Rate-Limit-Transparenz. Altsystem-API warf 500 bei Überlast. Gegenmaßnahme: RFC 9239-konforme Header (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset), planabhängige Quotas, klare 429 Too Many Requests mit Retry-After.
  4. 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-Timestamp mit ±5 min Replay-Schutz, Signing-Secret pro Endpoint rotierbar.
  5. 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.
  6. 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.
  • 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.


  • 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 curl aus 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“.
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.


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.

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.

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“.

  • 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.

  • 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 mit deprecated: true, Sunset-Datum im Sunset-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.
  • FR-5.2.1 · 🌐 docs.werkszeit.de/api/ zeigt Swagger UI, docs.werkszeit.de/api/reference Redoc, api.werkszeit.de/openapi.json + /openapi.yaml die 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.
  • FR-5.3.1 · Tenant-ID sandbox-werkszeit. Hinter api-sandbox.werkszeit.de erreichbar (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.
  • FR-5.4.1 · Format: wz_live_ oder wz_sandbox_ + 32 Base62-Zeichen. Prefix erkennt Umgebung sofort, Leaks in Logs bleiben erkennbar (Log-Filter: regex wz_(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 mit actor_user_id, ip, user_agent.
  • 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).
  • 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=60
    RateLimit-Limit: 300
    RateLimit-Remaining: 247
    RateLimit-Reset: 42
  • FR-5.6.4 · 429 Too Many Requests mit Retry-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.
  • 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 als HMAC-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.
  • FR-5.8.1 · Header Idempotency-Key: <uuid-v7> für alle POST-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 Conflict mit {"error":"idempotency_key_mismatch"}.
  • FR-5.8.4 · UUID v7 wird bevorzugt (time-ordered), aber jeder UUID akzeptiert. Non-UUID-Werte → 400 Bad Request.
  • 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.
  • 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> mit role="region" und aria-label="Code-Beispiel cURL für POST /v1/kern/time-entries". Copy-Button mit aria-label="In die Zwischenablage kopieren".
  • FR-5.10.4 · Farb-agnostische Status-Darstellung (Status-Page): Piktogramme zusätzlich zu Farbe.

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. │
└──────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ 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 ⟲] │
└────────────────────────────────────────────────────────────────────┘
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) │
│<──────────────────────────────────────────────────────────│

Technologie: Drizzle ORM, PostgreSQL 17, RLS aktiv. Alle Mandanten-Tabellen haben tenant_id + RLS-Policy. Neue Tabellen für §3.12:

packages/db/src/schema/api_keys.ts
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'));
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
});
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),
}));

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(),
});

Alle Endpoints werden über Zod-Schemas deklariert und generieren automatisch OpenAPI 3.1 + SDK-Typen.

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
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)“
Terminal-Fenster
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 Created
Content-Type: application/json; charset=utf-8
RateLimit-Policy: 300;w=60
RateLimit-Limit: 300
RateLimit-Remaining: 289
RateLimit-Reset: 28
X-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"
}

Request vom Werkszeit-Backend an den Kunden-Endpoint:

POST /werkszeit-webhook HTTP/1.1
Host: kunde.de
Content-Type: application/json; charset=utf-8
X-Werkszeit-Signature: sha256=7c3f8a9e…
X-Werkszeit-Timestamp: 1745073600
X-Werkszeit-Event-Id: 01872f4a-abcd-7def-9012-3456789abcde
X-Werkszeit-Event-Type: invoice.sent
X-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",
"recipient_email": "[email protected]",
"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');
}
}
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json; charset=utf-8
Retry-After: 42
RateLimit-Limit: 300
RateLimit-Remaining: 0
RateLimit-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
}

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 + 503 graceful 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.


  • 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“).
  • 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-Export fü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.
  • 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 als role="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.created mit metadata.betriebsvereinbarung_ack: true).
  • 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.
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

  1. 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 antworten 503 Service Unavailable mit Retry-After: 60.
  2. 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-Eintrag api_key.old_used_after_rotation.
  3. 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.
  4. Idempotency-Key mit abweichendem Body. Client schickt zweiten Call mit demselben Key, aber geändertem Body → 409 Conflict (FR-5.8.3), Log-Eintrag.
  5. 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 Refresh invalid_grant.
  6. 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.
  7. 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.
  8. Scope-Escalation-Versuch. Partner-App fragt initial nur handwerk.material.read. Bei späterem Refresh fügt Partner-App kern.invoice.write in den Request. → Werkszeit-Backend: Refresh-Token trägt nur die original eingewilligten Scopes, neue Scopes erfordern neuen Authorization-Code-Flow.
  9. 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 mit 400 Bad Request.
  10. OAuth-Client mit privater Redirect-URI. redirect_uri=http://localhost:3000/cb ist erlaubt (Dev-Setup), https://192.168.1.5/cb ebenfalls (Intranet), aber redirect_uri=file:///… oder javascript:… werden zurückgewiesen (invalid_redirect_uri).
  11. Sandbox-Daten-Leak über HTTP-Referer. Partner-App öffnet in der Sandbox ein <a href="https://example.com?token=abc"> → Referer leakt Token. → Portal setzt Referrer-Policy: strict-origin-when-cross-origin, SDKs nutzen Bearer-Header, nie Query.
  12. Idempotency-Key-Kollision zwischen Tenants. Client mit Zugang zu Tenant A und Tenant B nutzt zufällig denselben UUID-v7. → Key ist in idempotencyKeys pro Tenant unique (uniquePerTenant Index), keine Kollision.

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 erzeugt
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 ausgeliefert
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-Body
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 ab
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)
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 erzeugt

  • 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.
  • Spec-Drift-Gate: pnpm openapi:generate produziert byteidentisches Artefakt zu packages/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.
  • 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.
  • 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.
  • k6-Skript: 1000 RPS gegen /v1/kern/time-entries aus 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.
  • 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).

  • 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.)

# 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
  • 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.

  • @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).
  • §3.1 Zeiterfassung → API-Endpoints /v1/kern/time-entries/**.
  • §3.7 ReportingPOST /v1/kern/reports/exports (Cross-Link), Webhook report.export.ready.
  • §3.11 Auth → OAuth 2.1 + PKCE teilt Infrastruktur (auth.werkszeit.de), Passkey gilt nur First-Party.
  • §3.8 RechnungenPOST /v1/kern/invoices/{id}/send ist DER Idempotency-Use-Case.
  • §3.6 Lohn-ExportPOST /v1/kern/payroll/exports (DATEV-CSV-Bauer).
  • 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.

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.

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.

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.

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”.

  1. Spec-Driven, nicht Spec-documented. Die Spec ist der Code, nicht daneben. @hono/zod-openapi ist der einzige Weg, den ich verantworte. Manuelle openapi.json-Pflege fällt innerhalb eines Quartals auseinander — das haben wir im Altsystem 3-mal gesehen. CI-Gate ist Pflicht ab Tag 1.

  2. 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).

  3. 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).

  4. Idempotency-Key als Pflicht bei Money-Moving. Jeder Endpoint, der Geld bewegt (invoices/send, payroll/runs), fordert Idempotency-Key — wer ihn weglässt, bekommt 400. Lieber ein klarer Client-Fehler als ein Doppelversand einer Rechnung, der dann im Support landet.

  5. 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 + '.' + body mit ±5-Min-Check ist der Stand der Technik — Stripe, GitHub, Linear machen es so.

  6. AGPL-Hinweis nicht vergessen. Sandbox ist AGPL-pflichtig zu taggen. Ein 10-Wort-Footer ist billiger als ein Abmahnungsrisiko.

  7. Status-Page früh. Status-Page ist in V1, nicht V2. Sobald der erste Partner live geht, muss er status.werkszeit.de bookmarken können. Betterstack oder statuspage.io reichen — nicht selber bauen.

  • 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.
  • ≥ 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.

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:

  1. Spike @hono/zod-openapi — 2 Eng-Tage, Proof-of-Concept gegen 3 Endpoints.
  2. DSB-Review der OAuth-Einwilligungs-Screen-Texte (DSGVO Art. 12 Transparenz).
  3. Anwalts-Review AGPL-§13-Hinweis (IT-Recht).
  4. Terraform-Modul für Cloudflare-Rate-Limit-Rules, als PR gegen infra/.