Skip to content

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.

Two separate APIs, secured differently:

Public APIAdmin API
Path prefix/v1/api/v1
Authnone (challenge/PoW pipeline)JWT or API key
Audienceclient snippet on customer sitesAdmin UI, scripts, monitoring
Response styledeliberately tersedetailed, 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.

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:

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

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

Described in detail in Embedding a form — here just the raw interface.

No auth. Query parameter site (required, the site key).

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

No auth. Body: site, challenge (from the challenge response), pow_solution, hp (honeypot, must stay empty), fields.

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

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

POST /api/v1/auth/login — no auth.

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

EndpointPermissionPurpose
GET /usersusers:readList of all users
GET /users/:idusers:readSingle user
POST /usersusers:writeCreate
PUT /users/:id/rolesroles:writeReplace roles
DELETE /users/:idusers:writeDelete
GET /rolesroles:readRole catalog (static, from the code)

Example — create a new user:

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

All routes require apikeys:manage.

Example — create a key for a CI pipeline, read-only, expiring in 90 days:

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

EndpointPermission
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

Example — create a tenant via script (handy for automated onboarding of many customer sites):

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

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

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

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

routes:read to read, routes:write to write.

Example — create a route and test it right away:

Terminal window
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: true422 {"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.

All routes require blocklist:manage.

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

Terminal window
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 ⇒ 429 with header Retry-After: <seconds>.
  • Unknown path under /api/v1: 404 {"error":"unbekannter API-Endpunkt"}.
  • Invalid ID: every :id path parameter is a UUID; an unparsable value produces 400 {"error":"ungültige ID"} everywhere.