Last Updated: 2026-06-23 Status: Draft (v1 — Web3Forms parity) Source of truth:
apps/forms-service/src/routes/*·apps/forms-service/src/index.tsCompanion docs: architecture.md · database.md · implementation-plan.md
All routes are reached through the gateway, which mounts the service at /forms and strips that prefix. Internal worker paths are /f/* (public) and /admin/* (admin). Public gateway URL in v1: https://api.vlozi.app/forms/f/:form_id.
1. Auth
| Surface | Internal path | Auth |
|---|---|---|
| Public submit | /f/* |
None secret — the form_id in the URL is the public credential. Gateway adds x-gateway-key; abuse contained by allowed_origins + honeypot + Turnstile + rate limit. |
| Admin | /admin/* |
Gateway JWT flow → x-gateway-key + x-tenant-id + x-user-permissions. Guarded by forms:read / forms:write / forms:submissions.export. |
The worker rejects any request whose x-gateway-key doesn't match GATEWAY_SECRET (except /health, /ready).
2. Public — Submit
POST /f/:form_id
Accepts application/json (AJAX/SPA) and application/x-www-form-urlencoded / multipart/form-data (plain HTML <form>). Every non-reserved field is stored verbatim.
Reserved / control fields (handled specially, stripped from stored data):
| Field | Meaning |
|---|---|
botcheck (or the form's honeypot_field) |
Honeypot. Non-empty → stored as spam (reason honeypot), silent 200. |
cf-turnstile-response |
Turnstile token, verified when captcha_required. |
redirect |
On a plain HTML post, redirect (303) to this URL on success. |
access_key |
Ignored (vlozi uses the URL form_id, not an access key). |
h-captcha-response, g-recaptcha-response |
Reserved for forward-compat; ignored in v1. |
Conventionally-honored content fields kept in data and reused: email (used as Reply-To on the notification), subject, from_name, name, message.
Success — 200 (Web3Forms-compatible):
{ "success": true, "message": "Thank you! Your submission has been received.", "data": { "email": "a@b.com", "message": "hi" } }On a plain HTML post with a redirect (or the form's settings.redirectUrl) → 303 to that URL instead.
Failures
| Status | Body | When |
|---|---|---|
400 |
{ success:false, message:"Validation failed", errors } |
Payload fails the form's compiled Zod schema |
400 |
{ success:false, message:"Captcha verification failed" } |
captcha_required and Turnstile fails (also stored as spam) |
403 |
{ success:false, message:"Origin not allowed" } |
Origin not in a non-empty allowed_origins |
403 |
{ success:false, message:"This form is not accepting submissions" } |
Form paused |
404 |
{ success:false, message:"Form not found" } |
Unknown/archived/deleted form |
429 |
{ success:false, message:"Too many requests. Please try again later." } |
Per-form/IP rate limit (20/min default) |
Honeypot hits return a normal
200 { success:true }so bots can't detect the trap; the row is stored withstatus:spam.
GET /f/:form_id/schema
Public form definition for SDK/widget rendering.
{ "id": "form_…", "name": "Contact", "schema": { "fields": [...] }, "successMessage": "Thanks!", "captchaRequired": false }404 if the form is missing or not active.
3. Admin — Forms (forms:read / forms:write)
| Method | Path (via gateway) | Perm | Body / Query | Response |
|---|---|---|---|---|
GET |
/forms/admin/forms |
read | — | { data: [{ id, name, slug, status, captchaRequired, submissionCount, createdAt, updatedAt }] } |
POST |
/forms/admin/forms |
write | { name, schema?, settings?, notify?, webhookUrl?, allowedOrigins?, honeypotField?, captchaRequired? } |
201 full form row |
GET |
/forms/admin/forms/:id |
read | — | full form row |
PUT |
/forms/admin/forms/:id |
write | any subset of the create fields + status? (≥1 required) |
updated form row |
DELETE |
/forms/admin/forms/:id |
write | — | { status:"deleted", id } (soft-delete) |
POST |
/forms/admin/forms/:id/duplicate |
write | — | 201 cloned form row |
4. Admin — Submissions (forms:read / forms:write / forms:submissions.export)
| Method | Path (via gateway) | Perm | Body / Query | Response |
|---|---|---|---|---|
GET |
/forms/admin/forms/:id/submissions |
read | ?status=new|seen|handled|spam&page=1&limit=25 |
{ data: [submission], meta: { page, limit, total, totalPages } } |
GET |
/forms/admin/submissions/:id |
read | — | submission row |
PATCH |
/forms/admin/submissions/:id |
write | { status: "new"|"seen"|"handled"|"spam" } |
updated submission (handled sets handledAt) |
GET |
/forms/admin/forms/:id/submissions/export |
submissions.export | ?format=csv|json (default csv) |
CSV download (or { data } for json) |
Submission row
{
"id": "sub_…", "formId": "form_…", "tenantId": "…",
"data": { "email": "a@b.com", "message": "hi" },
"meta": { "referer": "...", "utm_source": "..." },
"status": "new", "spamReason": null,
"sourceIp": "…", "userAgent": "…",
"submittedAt": "2026-06-23T…", "handledAt": null
}5. Admin error envelope
Non-2xx admin responses use the shared envelope (src/utils/errors.ts):
{ "error": "Form not found", "code": "not_found", "request_id": "…" }Codes: bad_request, validation_error, unauthorized, forbidden, not_found, conflict, rate_limited, internal_error. Zod validation failures from @hono/zod-validator return its standard 400 shape.
6. Health
| Method | Path | Auth | Response |
|---|---|---|---|
GET |
/health |
none | text/plain FORMS SERVICE OK |
GET |
/ready |
none | { ready, checks: { formsDb }, duration_ms } (200/503) |
7. Embed snippet (the 30-second demo)
<form action="https://api.vlozi.app/forms/f/form_abc123" method="POST">
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />
<button type="submit">Send</button>
</form>AJAX equivalent:
await fetch("https://api.vlozi.app/forms/f/form_abc123", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, message }),
}).then(r => r.json()); // { success, message, data }