logicspike/docs

forms

Forms Service — API Specification (v1)

Last Updated: 2026-06-23 Status: Draft (v1 — Web3Forms parity) Source of truth: apps/forms-service/src/routes/* · apps/forms-service/src/index.ts Companion 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 with status: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 }
forms