Last Updated: 2026-06-28 Status: Active Source of truth:
apps/forms-service/src/routes/*·apps/forms-service/src/index.tsCompanion docs: architecture.md · database.md · permissions.md · webhooks.md · billing.md · embedding.md
All routes are reached through the gateway, which mounts the service at /forms and strips that prefix. Internal worker paths shown below. Public gateway base URL: https://api.vlozi.app/forms.
1. Auth
| Surface | Internal path | Auth |
|---|---|---|
| Public | /f/* |
None secret — the form_id in the URL is the public credential. 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. |
| MCP | /mcp/tools/* |
Same JWT + tenant context as admin. |
The worker rejects any request whose x-gateway-key doesn't match GATEWAY_SECRET (except /health and /ready).
2. Public — Widget & Schema
GET /f/embed.js
Returns the self-contained vanilla-JS embed widget (~180 lines, no dependencies). Cache-Control: 300 s.
Used via <script> tag — auto-mounts forms to [data-vlozi-form] elements. See embedding.md.
GET /f/:form_id/page
Server-rendered HTML hosted form page. Themes the form using settings.theme, inlines the schema as window.__VLOZI_FORM__, and loads ../embed.js. Includes <meta name="robots" content="noindex">.
404 if the form is deleted, archived, or paused.
GET /f/:form_id/schema
Public form definition for SDK / widget rendering. Also records a view in form_views_daily.
{
"id": "form_…",
"name": "Contact Us",
"schema": { "fields": [ { "name": "email", "type": "email", "label": "Email", "required": true } ] },
"successMessage": "Thanks! We'll be in touch.",
"captchaRequired": false,
"settings": { "theme": { "accent": "#FF5436", "buttonLabel": "Send" }, "turnstileSiteKey": "…" }
}404 if the form is deleted or archived (paused forms still return their schema).
3. 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 in data.
Credit cost: 0.25 credits per accepted (non-spam) submission. See billing.md.
Rate limit: 20 requests per 60 seconds per (form_id, IP).
Reserved / control fields (handled specially, stripped from stored data):
| Field | Behavior |
|---|---|
botcheck (or form.honeypotField) |
Honeypot — non-empty → stored status=spam + spamReason=honeypot, returns silent 200 {success:true} |
cf-turnstile-response |
Turnstile token, verified when captchaRequired=true |
redirect |
On a plain HTML form post, 303-redirect to this URL on success (overrides settings.redirectUrl) |
access_key |
Ignored (vlozi uses the URL form_id, not an access key) |
h-captcha-response, g-recaptcha-response |
Reserved for forward-compat; ignored |
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 form post with a redirect field or settings.redirectUrl → 303 redirect instead.
File fields in the data response:
{
"resume": {
"__file": true,
"mediaId": "med_01j…",
"name": "resume.pdf",
"size": 184320,
"type": "application/pdf",
"url": "https://cdn.vlozi.app/…/resume.pdf"
}
}Failures:
| Status | Code | When |
|---|---|---|
400 |
validation_error |
Payload fails the form's Zod schema. Body: { success:false, message:"Validation failed", errors: { fieldErrors, formErrors } } |
400 |
bad_request |
Captcha required and Turnstile verification fails. Body: { success:false, message:"Captcha verification failed" } |
402 |
— | Tenant has insufficient credits |
403 |
forbidden |
Origin not in a non-empty allowed_origins |
403 |
forbidden |
Form status=paused |
404 |
not_found |
Unknown, archived, or deleted form |
429 |
rate_limited |
Per-form/IP rate limit exceeded (20/60s) |
4. Admin — Forms (forms:read / forms:write)
Base path (via gateway): https://api.vlozi.app/forms/admin/forms
Form object
{
"id": "form_…",
"tenantId": "…",
"name": "Contact Us",
"slug": "contact-us",
"schema": { "fields": [ { "name": "email", "type": "email", "required": true } ] },
"settings": { "successMessage": "Thanks!", "redirectUrl": null, "theme": { "accent": "#FF5436", "buttonLabel": "Send" }, "turnstileSiteKey": null },
"notify": { "email": { "enabled": true, "to": "owner@example.com" }, "newsletterListId": null },
"webhookUrl": "https://hooks.example.com/vlozi",
"allowedOrigins": ["example.com"],
"honeypotField": "botcheck",
"captchaRequired": false,
"status": "active",
"createdBy": "usr_…",
"createdAt": "2026-06-01T…",
"updatedAt": "2026-06-28T…"
}Endpoints
| Method | Path | Perm | Body / Query | Response |
|---|---|---|---|---|
GET |
/admin/forms |
read | — | { data: [{ id, name, slug, status, captchaRequired, submissionCount, createdAt, updatedAt }] } |
POST |
/admin/forms |
write | See create body below | 201 full form object |
GET |
/admin/forms/overview |
read | — | Tenant-wide analytics (see §8) |
GET |
/admin/forms/:id |
read | — | Full form object |
PUT |
/admin/forms/:id |
write | Any subset of create fields + status? (≥1 field required) |
Updated form object |
DELETE |
/admin/forms/:id |
write | — | { status:"deleted", id } — soft-delete (sets deletedAt) |
POST |
/admin/forms/:id/duplicate |
write | — | 201 cloned form object (name suffixed, new id/slug) |
GET |
/admin/forms/:id/analytics |
read | ?days=7|30|90 (default 30) |
Per-form analytics (see §9) |
Create body (POST /admin/forms):
{
"name": "Contact Us",
"schema": { "fields": [] },
"settings": { "successMessage": "Thanks!", "redirectUrl": null, "theme": {}, "turnstileSiteKey": null },
"notify": { "email": { "enabled": false } },
"webhookUrl": null,
"allowedOrigins": [],
"honeypotField": "botcheck",
"captchaRequired": false
}Validation limits:
name: 1–200 charsschema.fields: max 100 fields; fieldname1–100,label/placeholder/helpmax 500,optionsmax 100 itemssettings.successMessage: max 500 chars;redirectUrl: max 2000, valid URLsettings.theme.accent: max 32 chars;buttonLabel: max 60 chars;turnstileSiteKey: max 100 charsnotify.email.to: max 320, valid emailwebhookUrl: max 2000, valid URLallowedOrigins: max 50 items, each max 255 chars
5. Admin — Submissions
Base path (via gateway): https://api.vlozi.app/forms/admin
Submission object
{
"id": "sub_…",
"formId": "form_…",
"tenantId": "…",
"data": { "email": "a@b.com", "message": "hi" },
"meta": { "referer": "https://example.com/contact", "utm_source": "newsletter" },
"status": "new",
"spamReason": null,
"note": null,
"sourceIp": "1.2.3.4",
"userAgent": "Mozilla/5.0 …",
"submittedAt": "2026-06-28T10:00:00Z",
"handledAt": null
}Endpoints
| Method | Path | Perm | Body / Query | Response |
|---|---|---|---|---|
GET |
/admin/forms/:id/submissions |
read | ?status=new|seen|handled|spam&page=1&limit=25 |
{ data: [submission], meta: { page, limit, total, totalPages } } |
GET |
/admin/forms/:id/submissions/export |
submissions.export | ?format=csv|json (default csv) |
CSV file download or { data: [submission] } for JSON |
GET |
/admin/submissions |
read | ?status=new|seen|handled|spam&page=1&limit=25 |
Global tenant inbox across all forms (same shape) |
GET |
/admin/submissions/:id |
read | — | Single submission object |
PATCH |
/admin/submissions/:id |
write | { status?: "new"|"seen"|"handled"|"spam", note?: string } |
Updated submission (handled sets handledAt) |
DELETE |
/admin/submissions/:id |
write | — | { status:"deleted", id } |
POST |
/admin/submissions/:id/reply |
write | { subject?: string, message: string } |
{ status:"sent" } — sends email to submitter's email field via Communication Service |
Export notes:
- CSV export: file fields render as their
url; non-file fields render as JSON stringified value - Mixed file/text fields in the same column may produce inconsistent types if a prior upload failed
6. Admin — Webhooks
| Method | Path | Perm | Body / Query | Response |
|---|---|---|---|---|
GET |
/admin/forms/:id/webhook-deliveries |
read | — | Last 20 delivery records for this form (newest first). See webhooks.md. |
Webhook delivery retry is currently available only via MCP tool retry-webhook-delivery (no REST endpoint). See §7.
7. MCP Tools (/mcp/tools/*)
All MCP tools require the same JWT auth as admin routes. Each tool is called via POST /mcp/tools/:tool-name with a JSON body matching the tool's input schema.
| Tool | Required input | Notes |
|---|---|---|
list-forms |
— | Returns all tenant forms (same as GET /admin/forms) |
get-form |
{ id } |
Full form object |
create-form |
{ name, schema?, settings?, notify?, ... } |
Same as POST /admin/forms |
update-form |
{ id, ...fields } |
Same as PUT /admin/forms/:id |
delete-form |
{ id } |
Soft-delete |
duplicate-form |
{ id } |
Clone with suffix |
get-analytics |
{ formId?, days? } |
formId = per-form analytics; omit = tenant overview |
list-submissions |
{ formId, status?, page?, limit? } |
Paginated, filterable |
get-submission |
{ id } |
Single submission |
update-submission |
{ id, status?, note? } |
Status + note update |
delete-submission |
{ id } |
Hard delete |
export-submissions |
{ formId, format? } |
Returns data array (JSON always, not CSV stream) |
reply-submission |
{ id, subject?, message } |
Email reply to submitter |
list-webhook-deliveries |
{ formId } |
Last 20 deliveries |
retry-webhook-delivery |
{ id } |
Retry a failed delivery; creates new delivery row |
8. Analytics — Tenant Overview (GET /admin/forms/overview)
Returned by both REST and get-analytics MCP tool (no formId).
{
"counts": {
"total": 142,
"new": 18,
"seen": 47,
"handled": 65,
"spam": 12
},
"deltas": {
"last7d": { "submissions": 23, "spam": 3 },
"last30d": { "submissions": 87, "spam": 11 }
},
"topForms": [
{ "id": "form_…", "name": "Contact Us", "submissions": 54 }
],
"series": [
{ "date": "2026-05-29", "submissions": 3, "spam": 0 }
],
"views": { "last30d": 1240 },
"conversion": { "last30d": 7.0 },
"usage": { "thisMonth": 87, "limit": -1 },
"credits": {
"balance": 42.5,
"spentThisMonth": 21.75,
"perSubmission": 0.25,
"pending": 500
}
}series: 30 continuous daily entries (zero-filled)conversion:(submissions / views) * 100for last 30d;nullif no viewsusage.limit:-1= no plan cap enforcedcredits.pending: unbilled thousandths (0–999) intenant_forms_usage
9. Analytics — Per-form (GET /admin/forms/:id/analytics?days=7|30|90)
{
"allTime": { "total": 54, "new": 5, "seen": 20, "handled": 25, "spam": 4 },
"window": {
"views": 320,
"submissions": 22,
"conversion": 6.9
},
"series": [
{ "date": "2026-06-01", "views": 15, "submissions": 1 }
]
}series:dayscontinuous daily entries (zero-filled)conversion:(window.submissions / window.views) * 100;nullif no views
10. Error Envelope
Non-2xx responses from admin and MCP routes:
{ "error": "Form not found", "code": "not_found", "request_id": "req_…" }Public submit uses a different envelope:
{ "success": false, "message": "Validation failed", "errors": { "fieldErrors": { "email": ["Invalid email"] }, "formErrors": [] } }Error codes:
| Code | HTTP | Meaning |
|---|---|---|
bad_request |
400 | Malformed input |
validation_error |
400 | Zod schema validation failure |
unauthorized |
401 | Missing or invalid credentials |
forbidden |
403 | Insufficient permissions or origin not allowed |
not_found |
404 | Resource not found |
conflict |
409 | Slug collision or duplicate resource |
rate_limited |
429 | Rate limit exceeded |
internal_error |
500 | Unexpected server error |
11. Health & Debug
| Method | Path | Auth | Response |
|---|---|---|---|
GET |
/health |
none | text/plain — FORMS SERVICE OK |
GET |
/ready |
none | { ready: true, checks: { formsDb: "ok" }, duration_ms: 12 } — or 503 with { ready: false } if forms DB unreachable |
GET |
/debug |
gateway-key | Service status + env info: FORMS_DATABASE_URL host, comms/media/rate-limiter/turnstile flags, DEBUG mode |
12. Embed Snippets
Plain HTML form (simplest, Web3Forms-compatible):
<form action="https://api.vlozi.app/forms/f/form_abc123" method="POST">
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<!-- honeypot: always include and keep hidden -->
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />
<button type="submit">Send</button>
</form>AJAX / JSON:
const res = await fetch("https://api.vlozi.app/forms/f/form_abc123", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "a@b.com", message: "hi" }),
});
const { success, message, data } = await res.json();Script embed widget:
<div data-vlozi-form="form_abc123"></div>
<script src="https://api.vlozi.app/forms/f/embed.js" async></script>See embedding.md for the full guide including SDK usage and hosted page.