API-Referenz
Vollständige Referenz beider APIs des Gateways. Wer nur ein Formular einbinden will, braucht meist nur Formular einbinden — diese Seite ist für alles darüber hinaus: Skripte gegen die Admin-API, Monitoring, eigene Automatisierung.
Überblick
Abschnitt betitelt „Überblick“Zwei getrennte APIs, unterschiedlich abgesichert:
| Public API | Admin-API | |
|---|---|---|
| Pfad-Präfix | /v1 | /api/v1 |
| Auth | keine (Challenge/PoW-Pipeline) | JWT oder API-Key |
| Zielgruppe | Client-Snippet auf Kundenseiten | Admin-UI, Skripte, Monitoring |
| Antwortstil | absichtlich wortkarg | detailliert, mit Fehlertext |
Fehler-Format der Admin-API, immer dasselbe Envelope:
{ "error": "Beschreibung des Fehlers" }Ein unerwarteter interner Fehler liefert immer 500 mit
{"error": "interner Serverfehler"} — die echte Ursache steht nur im
Server-Log, nie in der Antwort.
Authentifizierung
Abschnitt betitelt „Authentifizierung“Zwei gleichberechtigte Wege, einen davon pro Request:
Authorization: Bearer <jwt>X-API-Key: fgw_...JWTs kommen aus POST /api/v1/auth/login und laufen nach
access_token_ttl_minutes ab (Default 60). API-Keys entstehen im
Admin-UI (Bedienung) und laufen im
Rechte-Kontext ihres Erstellers. Ein Key mit scope: "read" lehnt jede
schreibende Methode (alles außer GET/HEAD/OPTIONS) mit 403 ab,
noch bevor die eigentliche Berechtigung geprüft wird.
Beispiel — Login und direkt weiterverwenden:
TOKEN=$(curl -s -X POST https://forms.example.de/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"…"}' | jq -r .token)
curl -s https://forms.example.de/api/v1/tenants \ -H "Authorization: Bearer $TOKEN"Mit einem API-Key entfällt der Login-Schritt:
curl -s https://forms.example.de/api/v1/tenants \ -H "X-API-Key: fgw_9f8e7d6c5b4a3928170615243342536445362718"Public API (/v1)
Abschnitt betitelt „Public API (/v1)“Ausführlich in Formular einbinden beschrieben — hier nur die reine Schnittstelle.
GET /v1/challenge
Abschnitt betitelt „GET /v1/challenge“Kein Auth. Query-Parameter site (Pflicht, der Site-Key).
curl -s "https://forms.example.de/v1/challenge?site=site_abc123" \ -H "Origin: https://www.example.de"{ "challenge": "eyJzaXRlIjoi...", "pow_difficulty": 12 }400 {"ok":false,"reason":"rejected"} bei jeder Ablehnung — unbekannter
Site-Key, Mandant inaktiv, Origin nicht erlaubt, oder Rate-Limit
(30 Challenge-Abrufe pro IP und Minute).
POST /v1/submit
Abschnitt betitelt „POST /v1/submit“Kein Auth. Body: site, challenge (aus der Challenge-Antwort),
pow_solution, hp (Honeypot, muss leer bleiben), fields.
curl -s -X POST https://forms.example.de/v1/submit \ -H "Content-Type: application/json" \ -H "Origin: https://www.example.de" \ -d '{ "site": "site_abc123", "challenge": "eyJzaXRlIjoi...", "pow_solution": "48291", "hp": "", "fields": { "name": "Max Mustermann", "nachricht": "Interesse an Ihrem Angebot.", "_gw_reply": "max@example.com" } }'Erfolg ist immer 200 {"ok": true} — auch bei Honeypot-Treffer oder
Quarantäne (kein Oracle für Bots). Jede Ablehnung ist
400 {"ok":false,"reason":"rejected"}; der tatsächliche Grund (Rate-Limit,
abgelaufene Challenge, ungültiger Proof-of-Work, Tageskontingent
erschöpft, …) steht nur im Server-Log und im Admin. Details zur
Feld-Härtung (Limits, reservierte Namen): Formular einbinden §4.
GET /form-gateway.js
Abschnitt betitelt „GET /form-gateway.js“Kein Auth. Liefert das Client-Snippet, gecacht mit ETag und
Cache-Control: public, max-age=300, must-revalidate. Varianten:
/form-gateway.min.js (identisch), /form-gateway.src.js (lesbar, zum
Debuggen).
Admin-API (/api/v1)
Abschnitt betitelt „Admin-API (/api/v1)“POST /api/v1/auth/login — kein Auth.
curl -s -X POST https://forms.example.de/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"s3hrGeheimesPasswort!"}'{ "token": "eyJhbGciOiJIUzI1NiIs...", "user": { "id": "0f8fad5b-d9cb-469f-a165-70867728950e", "username": "admin", "display_name": "Admin", "roles": ["admin"], "permissions": ["users:read", "users:write", "tenants:read", "tenants:write", "…"] }}401 {"error":"ungültige Anmeldedaten"} bei falschem Passwort oder
unbekanntem Benutzer — bewusst dieselbe Meldung für beide Fälle (kein
User-Enumeration-Leak). Deaktivierte Accounts und der interne
system-User können sich nicht einloggen.
GET /api/v1/auth/me — Auth: eingeloggt (kein Permission nötig).
Liefert dasselbe user-Objekt wie oben, für den aktuellen Token/Key.
Benutzer (/api/v1/users, /api/v1/roles)
Abschnitt betitelt „Benutzer (/api/v1/users, /api/v1/roles)“| Endpunkt | Berechtigung | Zweck |
|---|---|---|
GET /users | users:read | Liste aller Benutzer |
GET /users/:id | users:read | Einzelner Benutzer |
POST /users | users:write | Anlegen |
PUT /users/:id/roles | roles:write | Rollen ersetzen |
DELETE /users/:id | users:write | Löschen |
GET /roles | roles:read | Rollenkatalog (statisch, aus dem Code) |
Beispiel — neuen Benutzer anlegen:
curl -s -X POST https://forms.example.de/api/v1/users \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{ "username": "j.mueller", "email": "j.mueller@example.com", "password": "einSicheresPasswort123", "first_name": "Julia", "last_name": "Müller", "roles": ["viewer"] }'username wird auf 3–32 Zeichen (a-z0-9_-) geprüft, password
braucht mindestens 10 Zeichen (gehasht mit argon2id), roles müssen
existierende Rollennamen sein (admin, viewer, system).
422 {"error":"benutzername ist bereits vergeben"} bei Konflikt.
System-Benutzer und der admin-Account selbst lassen sich nicht löschen
oder umrollen (403 {"error":"system-benutzer können nicht verändert oder gelöscht werden"}).
API-Keys (/api/v1/apikeys)
Abschnitt betitelt „API-Keys (/api/v1/apikeys)“Alle Routen brauchen apikeys:manage.
Beispiel — Key für eine CI-Pipeline anlegen, nur lesend, läuft in 90 Tagen ab:
curl -s -X POST https://forms.example.de/api/v1/apikeys \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"name": "CI-Pipeline", "scope": "read", "expires_in_days": 90}'{ "key": "fgw_9f8e7d6c5b4a3928170615243342536445362718", "api_key": { "id": "3d2e1f0a-...", "name": "CI-Pipeline", "prefix": "fgw_9f8e7d6c5b4a", "scope": "read", "expires_at": "2026-10-16T12:00:00Z", "revoked": false }}Das Feld key steht nur in dieser einen Antwort — danach ist nur
noch der Hash gespeichert. scope: "readwrite" ist der Default (leerer
String); expires_in_days: 0 heißt unbegrenzt gültig.
DELETE /api/v1/apikeys/:id widerruft einen Key sofort (204).
Mandanten (/api/v1/tenants)
Abschnitt betitelt „Mandanten (/api/v1/tenants)“| Endpunkt | Berechtigung |
|---|---|
GET /tenants, GET /tenants/:id | tenants:read |
POST /tenants, PUT /tenants/:id | tenants:write |
POST /tenants/:id/active | tenants:write — Kill-Switch |
POST /tenants/:id/rotate-secret | tenants:write |
DELETE /tenants/:id | tenants:write |
Beispiel — Mandant per Skript anlegen (praktisch für automatisiertes Onboarding vieler Kundenseiten):
curl -s -X POST https://forms.example.de/api/v1/tenants \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{ "name": "Beispiel GmbH — Kontaktformular", "recipient": "kontakt@beispiel.de", "origins": ["https://www.beispiel.de"], "daily_quota": 100, "pow_difficulty": 12, "spam_threshold": 8, "min_fill_seconds": 3, "route_id": "", "active": true }'route_id: "" heißt „geteilte Default-Route”; für eine eigene Route die
UUID der Route eintragen. Die Antwort enthält den generierten
site_key — der Rest (Signing-Secret) bleibt serverseitig.
422 {"error":"ungültiger origin (erwartet: https://example.tld): …"}
bei fehlerhaftem Origin-Format.
Beispiel — Kill-Switch per Skript (z. B. aus einem Monitoring-Alarm heraus, ohne den Admin öffnen zu müssen):
curl -s -X POST https://forms.example.de/api/v1/tenants/$TENANT_ID/active \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"active": false}'Submissions & Quarantäne (/api/v1/tenants/:id/submissions…, .../stats)
Abschnitt betitelt „Submissions & Quarantäne (/api/v1/tenants/:id/submissions…, .../stats)“Alle unter submissions:read, außer Freigeben/Verwerfen (quarantine:manage).
Beispiel — die letzten fünf quarantänisierten Absendungen eines Mandanten:
curl -s "https://forms.example.de/api/v1/tenants/$TENANT_ID/submissions?status=quarantined&limit=5" \ -H "Authorization: Bearer $TOKEN"[ { "id": "b3f1c9a0-...", "received_at": "2026-07-18T10:03:22Z", "fields": { "name": "Max Mustermann", "nachricht": "…" }, "reply_to": "max@example.com", "score": 9, "score_reasons": ["viele_links"], "status": "quarantined" }]Beispiel — freigeben:
curl -s -X POST https://forms.example.de/api/v1/tenants/$TENANT_ID/submissions/$SUB_ID/release \ -H "Authorization: Bearer $TOKEN"409 {"error":"submission ist nicht in quarantäne"}, wenn der Status
nicht (mehr) quarantined ist — etwa weil sie schon jemand anders
freigegeben hat. GET .../history liefert die vollständige
Ereigniskette (Audit-Log); GET .../stats die Kennzahlen
(total/sent/quarantined/blocked/failed) für ein Dashboard.
Dashboard-Zeitreihe (GET /api/v1/stats/traffic)
Abschnitt betitelt „Dashboard-Zeitreihe (GET /api/v1/stats/traffic)“submissions:read. Lückenlose Tageszeitreihe über alle Mandanten —
die Datenquelle des Diagramms auf der Übersichtsseite.
?days=7|30 (Default 30, geklemmt auf 1–90):
[ { "date": "2026-07-28", "sent": 12, "spam": 3 }, { "date": "2026-07-29", "sent": 9, "spam": 1 }]sent zählt zugestellte Mails, spam alles Aussortierte (Quarantäne,
Blocked, verworfen). Angenommene-aber-noch-nicht-versendete und
fehlgeschlagene Zustellungen zählen in keine der beiden Reihen.
Geo-Felder im Mandanten (geo_mode, geo_countries):
{ "name": "Kundenseite", "recipient": "kontakt@kunde.de", "origins": ["https://www.kunde.de"], "geo_mode": "allow", // "off" (Default) | "allow" | "deny" "geo_countries": ["DACH"] // Codes (DE) oder Gruppen (DACH, EU, EEA, EUROPE)}Eingangsbestätigung an den Einsender (confirm_enabled, confirm_body):
{ "confirm_enabled": true, "confirm_body": "Danke für Ihre Nachricht! Wir melden uns."}confirm_enabled: true ohne Text ergibt 422 — eine leere Mail beim
Kunden ist keine Bestätigung. Der Text ist auf 5.000 Zeichen begrenzt.
Versendet wird nur, wenn das Formular zusätzlich _gw_reply mitliefert.
422 bei unbekanntem Modus, ungültigem Ländercode oder allow ohne
Länder (das würde alles durchlassen). Werte werden auf Großbuchstaben
normalisiert, Duplikate entfernt. Als Blocklist-Art gibt es zusätzlich
country — dieselben Werte, aber global für alle Mandanten.
SMTP-Routen (/api/v1/routes)
Abschnitt betitelt „SMTP-Routen (/api/v1/routes)“routes:read zum Lesen, routes:write zum Schreiben.
Beispiel — Route anlegen und sofort testen:
curl -s -X POST https://forms.example.de/api/v1/routes \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{ "name": "default-smtp", "host": "smtp.beispiel.de", "port": 587, "tls_mode": "starttls", "username": "gateway@beispiel.de", "password": "smtp-passwort", "from_address": "no-reply@beispiel.de", "shared": true }'
curl -s -X POST https://forms.example.de/api/v1/routes/$ROUTE_ID/test \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"to": "admin@beispiel.de"}'Höchstens eine Route darf shared: true tragen —
422 {"error":"es gibt bereits eine geteilte default-route"} sonst.
Beim Bearbeiten (PUT) lässt ein leeres password-Feld das
gespeicherte Passwort unverändert. Der Test-Endpunkt antwortet bei
SMTP-Fehlern bewusst mit Klartext (502 {"ok":false,"error":"dial tcp smtp.beispiel.de:587: i/o timeout"}) — zum Debuggen, nicht zum
Verstecken. DELETE scheitert mit 409 {"error":"route wird noch von mandanten verwendet"}, solange ein Mandant sie referenziert.
Blocklist (/api/v1/blocklist)
Abschnitt betitelt „Blocklist (/api/v1/blocklist)“Alle Routen brauchen blocklist:manage.
curl -s -X POST https://forms.example.de/api/v1/blocklist \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"kind": "cidr", "value": "203.0.113.0/24", "note": "bekanntes Spam-Netz"}'kind ist eines von ip, cidr, asn (z. B. "AS12345" oder
"12345"), email (Teilstring/Suffix-Match, z. B. "@spamdomain.tld").
Jeder Eintrag wirkt sofort — Sync spiegelt ihn in den
Reputationsspeicher.
System (/api/v1/health, /api/v1/system/info)
Abschnitt betitelt „System (/api/v1/health, /api/v1/system/info)“Beide ohne Auth, praktisch für Monitoring:
curl -s https://forms.example.de/api/v1/health# {"status":"ok"}
curl -s https://forms.example.de/api/v1/system/info# {"version":"0.1.0","build":"12","built_at":"2026-07-15T08:00:00Z", …}Rate-Limits & Fehler, die überall gelten
Abschnitt betitelt „Rate-Limits & Fehler, die überall gelten“- API-Key-Rate-Limit:
api_key_rate_limit_per_minute(Default 120,0= aus). Greift pro Key und Minute, auch für ungültige Keys (Brute-Force-Schutz). Überschritten ⇒429mit HeaderRetry-After: <sekunden>. - Unbekannter Pfad unter
/api/v1:404 {"error":"unbekannter API-Endpunkt"}. - Ungültige ID: Jeder
:id-Pfadparameter ist eine UUID; ein nicht parsbarer Wert ergibt überall400 {"error":"ungültige ID"}.