API Reference
Complete reference for both of the gateway’s APIs. Anyone who just wants to embed a form usually only needs Embedding a form — this page is for everything beyond that: scripting against the Admin API, monitoring, custom automation.
Overview
Section titled “Overview”Two separate APIs, secured differently:
| Public API | Admin API | |
|---|---|---|
| Path prefix | /v1 | /api/v1 |
| Auth | none (challenge/PoW pipeline) | JWT or API key |
| Audience | client snippet on customer sites | Admin UI, scripts, monitoring |
| Response style | deliberately terse | detailed, with error text |
Error format of the Admin API, always the same envelope:
{ "error": "description of the error" }An unexpected internal error always returns 500 with
{"error": "interner Serverfehler"} — the real cause appears only in
the server log, never in the response.
Authentication
Section titled “Authentication”Two equally valid methods, one per request:
Authorization: Bearer <jwt>X-API-Key: fgw_...JWTs come from POST /api/v1/auth/login and expire after
access_token_ttl_minutes (default 60). API keys are created in the
Admin UI (Usage) and
operate within their creator’s permission context. A key with
scope: "read" rejects every write method (anything other than
GET/HEAD/OPTIONS) with 403, before the actual permission is even
checked.
Example — log in and use the token right away:
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"With an API key, the login step is skipped:
curl -s https://forms.example.de/api/v1/tenants \ -H "X-API-Key: fgw_9f8e7d6c5b4a3928170615243342536445362718"Public API (/v1)
Section titled “Public API (/v1)”Described in detail in Embedding a form — here just the raw interface.
GET /v1/challenge
Section titled “GET /v1/challenge”No auth. Query parameter site (required, the 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"} for every rejection — unknown
site key, tenant inactive, origin not allowed, or rate limit (30
challenge requests per IP per minute).
POST /v1/submit
Section titled “POST /v1/submit”No auth. Body: site, challenge (from the challenge response),
pow_solution, hp (honeypot, must stay empty), 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": "John Doe", "message": "Interested in your offer.", "_gw_reply": "max@example.com" } }'Success is always 200 {"ok": true} — even on a honeypot hit or
quarantine (no oracle for bots). Every rejection is
400 {"ok":false,"reason":"rejected"}; the actual reason (rate limit,
expired challenge, invalid proof of work, daily quota exhausted, …)
appears only in the server log and in the admin. Details on field
hardening (limits, reserved names): Embedding a form §4.
GET /form-gateway.js
Section titled “GET /form-gateway.js”No auth. Serves the client snippet, cached with ETag and
Cache-Control: public, max-age=300, must-revalidate. Variants:
/form-gateway.min.js (identical), /form-gateway.src.js (readable,
for debugging).
Admin API (/api/v1)
Section titled “Admin API (/api/v1)”POST /api/v1/auth/login — no 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"} for a wrong password or
unknown user — deliberately the same message for both cases (no
user-enumeration leak). Disabled accounts and the internal system
user cannot log in.
GET /api/v1/auth/me — auth: logged in (no permission required).
Returns the same user object as above, for the current token/key.
Users (/api/v1/users, /api/v1/roles)
Section titled “Users (/api/v1/users, /api/v1/roles)”| Endpoint | Permission | Purpose |
|---|---|---|
GET /users | users:read | List of all users |
GET /users/:id | users:read | Single user |
POST /users | users:write | Create |
PUT /users/:id/roles | roles:write | Replace roles |
DELETE /users/:id | users:write | Delete |
GET /roles | roles:read | Role catalog (static, from the code) |
Example — create a new user:
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 is validated to 3–32 characters (a-z0-9_-), password
needs at least 10 characters (hashed with argon2id), roles must be
existing role names (admin, viewer, system).
422 {"error":"benutzername ist bereits vergeben"} on conflict.
System users and the admin account itself cannot be deleted or have
their roles changed (403 {"error":"system-benutzer können nicht verändert oder gelöscht werden"}).
API keys (/api/v1/apikeys)
Section titled “API keys (/api/v1/apikeys)”All routes require apikeys:manage.
Example — create a key for a CI pipeline, read-only, expiring in 90 days:
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 }}The key field appears only in this one response — after that,
only the hash is stored. scope: "readwrite" is the default (empty
string); expires_in_days: 0 means it never expires.
DELETE /api/v1/apikeys/:id revokes a key immediately (204).
Tenants (/api/v1/tenants)
Section titled “Tenants (/api/v1/tenants)”| Endpoint | Permission |
|---|---|
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 |
Example — create a tenant via script (handy for automated onboarding of many customer sites):
curl -s -X POST https://forms.example.de/api/v1/tenants \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{ "name": "Example GmbH — Contact form", "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: "" means “shared default route”; for a dedicated route,
enter the route’s UUID. The response contains the generated
site_key — the rest (signing secret) stays server-side.
422 {"error":"ungültiger origin (erwartet: https://example.tld): …"}
on a malformed origin format.
Example — kill switch via script (e.g. triggered from a monitoring alert, without having to open the admin):
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 & quarantine (/api/v1/tenants/:id/submissions…, .../stats)
Section titled “Submissions & quarantine (/api/v1/tenants/:id/submissions…, .../stats)”All under submissions:read, except release/discard (quarantine:manage).
Example — the last five quarantined submissions of a tenant:
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": "John Doe", "message": "…" }, "reply_to": "max@example.com", "score": 9, "score_reasons": ["viele_links"], "status": "quarantined" }]Example — release:
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"} if the status is
no longer quarantined — for example because someone else already
released it. GET .../history returns the complete event chain
(audit log); GET .../stats returns the metrics
(total/sent/quarantined/blocked/failed) for a dashboard.
Dashboard time series (GET /api/v1/stats/traffic)
Section titled “Dashboard time series (GET /api/v1/stats/traffic)”submissions:read. Gapless daily time series across all tenants —
the data source for the chart on the overview page.
?days=7|30 (default 30, clamped to 1–90):
[ { "date": "2026-07-28", "sent": 12, "spam": 3 }, { "date": "2026-07-29", "sent": 9, "spam": 1 }]sent counts delivered mails, spam everything filtered out
(quarantine, blocked, discarded). Accepted-but-not-yet-sent and failed
deliveries count towards neither series.
Geo fields on a tenant (geo_mode, geo_countries):
{ "name": "Customer site", "recipient": "contact@customer.tld", "origins": ["https://www.customer.tld"], "geo_mode": "allow", // "off" (default) | "allow" | "deny" "geo_countries": ["DACH"] // codes (DE) or groups (DACH, EU, EEA, EUROPE)}Confirmation email to the submitter (confirm_enabled, confirm_body):
{ "confirm_enabled": true, "confirm_body": "Thanks for your message! We'll be in touch."}confirm_enabled: true without text returns 422 — an empty mail is no
confirmation. The text is capped at 5,000 characters. It is only sent if
the form also supplies _gw_reply.
422 on an unknown mode, an invalid country code, or allow without
any countries (that would let everything through). Values are
uppercased and de-duplicated. The blocklist additionally accepts kind
country — same values, but global for all tenants.
SMTP routes (/api/v1/routes)
Section titled “SMTP routes (/api/v1/routes)”routes:read to read, routes:write to write.
Example — create a route and test it right away:
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"}'At most one route may carry shared: true —
422 {"error":"es gibt bereits eine geteilte default-route"} otherwise.
When editing (PUT), leaving the password field empty keeps the
stored password unchanged. The test endpoint deliberately responds
with plain-text detail on SMTP errors (502 {"ok":false,"error":"dial tcp smtp.beispiel.de:587: i/o timeout"}) — for debugging, not to hide
anything. DELETE fails with 409 {"error":"route wird noch von mandanten verwendet"} as long as a tenant references it.
Blocklist (/api/v1/blocklist)
Section titled “Blocklist (/api/v1/blocklist)”All routes require 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": "known spam network"}'kind is one of ip, cidr, asn (e.g. "AS12345" or "12345"),
email (substring/suffix match, e.g. "@spamdomain.tld"). Every entry
takes effect immediately — Sync mirrors it into the reputation store.
System (/api/v1/health, /api/v1/system/info)
Section titled “System (/api/v1/health, /api/v1/system/info)”Both without auth, handy for 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 & errors that apply everywhere
Section titled “Rate limits & errors that apply everywhere”- API key rate limit:
api_key_rate_limit_per_minute(default 120,0= off). Applies per key per minute, even for invalid keys (brute-force protection). Exceeded ⇒429with headerRetry-After: <seconds>. - Unknown path under
/api/v1:404 {"error":"unbekannter API-Endpunkt"}. - Invalid ID: every
:idpath parameter is a UUID; an unparsable value produces400 {"error":"ungültige ID"}everywhere.