logicspike/docs

Forms

Forms Service — API Specification

Last Updated: 2026-06-28 Status: Active Source of truth: apps/forms-service/src/routes/* · apps/forms-service/src/index.ts Companion 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.redirectUrl303 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 chars
  • schema.fields: max 100 fields; field name 1–100, label/placeholder/help max 500, options max 100 items
  • settings.successMessage: max 500 chars; redirectUrl: max 2000, valid URL
  • settings.theme.accent: max 32 chars; buttonLabel: max 60 chars; turnstileSiteKey: max 100 chars
  • notify.email.to: max 320, valid email
  • webhookUrl: max 2000, valid URL
  • allowedOrigins: 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) * 100 for last 30d; null if no views
  • usage.limit: -1 = no plan cap enforced
  • credits.pending: unbilled thousandths (0–999) in tenant_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: days continuous daily entries (zero-filled)
  • conversion: (window.submissions / window.views) * 100; null if 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/plainFORMS 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.

Forms