Zum Inhalt springen

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.

Zwei getrennte APIs, unterschiedlich abgesichert:

Public APIAdmin-API
Pfad-Präfix/v1/api/v1
Authkeine (Challenge/PoW-Pipeline)JWT oder API-Key
ZielgruppeClient-Snippet auf KundenseitenAdmin-UI, Skripte, Monitoring
Antwortstilabsichtlich wortkargdetailliert, 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.

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:

Terminal-Fenster
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:

Terminal-Fenster
curl -s https://forms.example.de/api/v1/tenants \
-H "X-API-Key: fgw_9f8e7d6c5b4a3928170615243342536445362718"

Ausführlich in Formular einbinden beschrieben — hier nur die reine Schnittstelle.

Kein Auth. Query-Parameter site (Pflicht, der Site-Key).

Terminal-Fenster
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).

Kein Auth. Body: site, challenge (aus der Challenge-Antwort), pow_solution, hp (Honeypot, muss leer bleiben), fields.

Terminal-Fenster
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.

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

POST /api/v1/auth/login — kein Auth.

Terminal-Fenster
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.

EndpunktBerechtigungZweck
GET /usersusers:readListe aller Benutzer
GET /users/:idusers:readEinzelner Benutzer
POST /usersusers:writeAnlegen
PUT /users/:id/rolesroles:writeRollen ersetzen
DELETE /users/:idusers:writeLöschen
GET /rolesroles:readRollenkatalog (statisch, aus dem Code)

Beispiel — neuen Benutzer anlegen:

Terminal-Fenster
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"}).

Alle Routen brauchen apikeys:manage.

Beispiel — Key für eine CI-Pipeline anlegen, nur lesend, läuft in 90 Tagen ab:

Terminal-Fenster
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).

EndpunktBerechtigung
GET /tenants, GET /tenants/:idtenants:read
POST /tenants, PUT /tenants/:idtenants:write
POST /tenants/:id/activetenants:write — Kill-Switch
POST /tenants/:id/rotate-secrettenants:write
DELETE /tenants/:idtenants:write

Beispiel — Mandant per Skript anlegen (praktisch für automatisiertes Onboarding vieler Kundenseiten):

Terminal-Fenster
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):

Terminal-Fenster
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:

Terminal-Fenster
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:

Terminal-Fenster
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.

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.

routes:read zum Lesen, routes:write zum Schreiben.

Beispiel — Route anlegen und sofort testen:

Terminal-Fenster
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.

Alle Routen brauchen blocklist:manage.

Terminal-Fenster
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.

Beide ohne Auth, praktisch für Monitoring:

Terminal-Fenster
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", …}
  • 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 ⇒ 429 mit Header Retry-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 überall 400 {"error":"ungültige ID"}.