formling docs

The form builder for your agent — tell your agent you need a form and it creates a styled, accessible, server-rendered one in a single API call or curl. No account, no editor, no dashboard. Free for individuals and teams.

Reading this as an AI agent? Build from https://formling.dev/llms.txt — the complete machine-readable reference (every operation, field type, the brand/theme styling guide, error codes and limits) in one file. This page mirrors it for humans.

Create a form in one call

A plain REST API. A created form is live instantly at https://formling.dev/f/<form_id> — server-rendered, accessible, no client JS required.

curl -s -X POST https://formling.dev/api/v1/forms \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Customer NPS",
    "fields": [
      { "key": "nps", "type": "rating", "label": "How likely are you to recommend us?", "min": 0, "max": 10, "required": true },
      { "key": "why", "type": "long_text", "label": "Why?" },
      { "key": "email", "type": "email", "label": "Your email" }
    ]
  }'

The response includes form_url (live immediately), responses_url (a token-gated human view of every reply at /r/<id>?t=<admin_token> — share it with no login, with a one-click CSV export), and admin_token (shown once — it is the bearer for every admin operation).

Point your agent at the API

Tell your agent to read the full reference at /llms.txt and call the API base at https://formling.dev/api/v1. No SDK, no setup — just HTTP.

Operations

nametitlewhat it doeshints
create_formCreate formCreate a live, shareable form in one call and get back its URL plus a one-time admin_token. Use this to start collecting responses immediately — e.g. fields: [{"key":"email","type":"email","label":"Your email","required":true}]. Returns form_id, form_url, responses_url (a share-nothing human view of replies — a token-gated link to all submissions, no login), admin_token, claim_url, expires_at. IMPORTANT — after creating, ALWAYS show the user these as visible links/values, not just a summary: (1) form_url to share; (2) responses_url to read replies (no login); (3) admin_token — shown ONCE, authorizes every later get/update/close/delete, not stored by the client and unrecoverable, so tell them to save it; (4) claim_url — this matters as much as the token: the form auto-expires on expires_at (~30 days) unless claimed, and opening claim_url makes it permanent. Always paste the actual claim_url, never just mention that one exists. THEME BY DEFAULT: unless the user asks for plain, infer a visual style from the form's purpose or any named brand and pass `theme` (plus `custom_css` for an exact match) — e.g. a form “for YouTube” should use a white background, a #FF0000 accent and a matching font. Call the get_reference tool first for field types and the exact brand-styling hooks — don't guess CSS selectors.readOnlyHint: false
get_formGet formFetch a form's full definition (fields, theme, settings) plus live stats (response_count, last_response_at). Use to inspect a form you created; reports status:'expired' for aged-out unclaimed forms. Requires admin_token.readOnlyHint: true
update_formUpdate formModify an existing form with a JSON merge patch over {title, description, fields, theme, settings}: null removes a key and a fields array replaces ALL fields. Use to rename, add/remove fields, or restyle (theme/custom_css — call the get_reference tool for the exact brand-styling hooks; don't guess CSS selectors). Requires admin_token.readOnlyHint: false
get_responsesGet responsesRead submitted responses newest-first, keyed by field key. Pass format:'csv' for a spreadsheet-ready string, or since/limit/cursor to page. Use to collect what people submitted. Requires admin_token.readOnlyHint: true
close_formClose formStop a form from accepting new responses while keeping it and its data readable. Idempotent. Use when a survey ends. Requires admin_token.destructiveHint: true
delete_formDelete formSoft-delete a form (24-hour purge grace) and stop serving its page. Idempotent; returns purge_after. Use to permanently remove a form and its responses. Requires admin_token.destructiveHint: true

REST endpoints

method & pathauthresponse
POST /api/v1/formsnone (IP rate-limited, 20/hour)201 { form_id, form_url, responses_url, admin_token, claim_url, expires_at }
GET /api/v1/forms/:idBearer admin_token200 { form_id, title, description, fields, theme, settings, status, created_at, expires_at, stats }
PATCH /api/v1/forms/:idBearer admin_token200 (same shape as GET)
POST /api/v1/forms/:id/closeBearer admin_token200 { form_id, status:'closed' } (idempotent)
DELETE /api/v1/forms/:idBearer admin_token200 { form_id, status:'deleted', purge_after } (soft delete, idempotent)
GET /api/v1/forms/:id/responsesBearer admin_token200 { responses[], next_cursor } — or CSV text when format=csv
GET /f/:idnone (public)200 rendered HTML form (410 expired, closed page when closed/at capacity, 404 missing). Always noindex.
POST /f/:id/submitnone (honeypot + IP rate-limited, 10/min/form)200 { ok:true } (JSON) or success HTML (form-encoded); 400 validation, 410 expired, 409 closed
GET /r/:id?t=<admin_token>admin_token in the t query param (same SHA-256 check as Bearer)200 server-rendered human view of all replies (status badge, table, CSV link). 401/403 access-denied page, 404 if deleted. Always noindex + no-store.
GET /r/:id/export.csv?t=<admin_token>admin_token in the t query param200 text/csv as an attachment (filename="<form_id>.csv").

Field types

Every field has key, type, label, optional placeholder and required. Type-specific props:

typepropsdescription
short_textmin_length?, max_length?, pattern? (anchored regex)Single-line text input.
long_textmin_length?, max_length?Multi-line textarea.
email—Email input, validated server-side.
phone—Phone number; lenient validation (7–15 digits, optional +, separators allowed).
url—Web address; must be a valid http(s) URL.
numbermin?, max?, step?, display?: "input"|"slider" (slider needs min+max)Numeric input (any finite number within constraints).
selectoptions: (string|{label,image_url})[] (≥1), display?: "dropdown"|"radio"Single choice from a list; image options render as picture choice.
multi_selectoptions: (string|{label,image_url})[] (≥1)Zero or more choices from a list; image options render as picture choice.
ratingmin: int (def 1), max: int (def 5), min<max, labels?: {min?, max?}Integer rating between min and max; optional anchor labels (covers NPS/opinion scale/Likert).
yes_no—Boolean yes/no choice.
checkbox—Single consent checkbox; required means it MUST be checked.
date—Calendar date (YYYY-MM-DD).
time—Time of day (HH:MM, 24h).
matrixrows: string[] (1–20), columns: string[] (1–10)Grid: one column choice per row. Answer: {row: column}.
rankingoptions: string[] (2–20)Order ALL options. Answer: array, a full permutation of options.
address—Structured address {street, line2?, city, region?, postal, country}; required applies to street/city/postal/country.
statementbody? (no placeholder/required)Display-only text block; collects no data, excluded from responses.
hiddenvalue? (def "")Invisible field submitted with the form; per-visitor override via ?key=value URL prefill.

Answer shapes: matrix → {row: column}, address → {street, line2?, city, region?, postal, country}, ranking → array (best first), multi_select → array, checkbox/yes_no → boolean.

URL prefill: append ?<key>=<value> to the form URL to pre-populate any flat field (repeat the param for multi_select). Combine with a hidden field to pipe tracking context (source, session id) through a form.

Theme & custom CSS

Optional theme JSON: font (system | serif | Google Font), colors ({bg, card, text, accent, muted, border} as hex), radius (none|sm|md|xl), density (compact|comfortable), logo_url (https), layout (card|plain|fullscreen), and custom_css (raw CSS ≤20 KB). custom_css is stored raw and sanitized at render: @import stripped, non-https url() removed, HTML/script breakout blocked.

Matching a brand exactly.

Start with theme.colors (bg, card, text, accent, muted, border), font and radius for ~80% of a brand — that alone recolors the text, surfaces, accent and every outline, no CSS needed. For full fidelity, target these STABLE hooks from custom_css (a supported, versioned contract — they won't be renamed without notice):

  • data-part attributes — target with the LITERAL attribute selector, e.g. [data-part="card"]. Values: card, title, description, field, label, input, control (radio/checkbox/range), submit. It is [data-part="card"], NOT [data-card]. Most of these also carry a plain class you can target instead: .card, .field, .flabel, .choice, .desc.
  • classes: .field .flabel .choice .pchoice .pchoices .choices .rating .rating-anchors table.matrix .matrix-wrap .range-row .range-track-wrap .range-bubble .rank .rank-pos .addr-grid .sublabel .field-error .error-summary .logo .success .foot
  • Contract CSS variables (override in :root{} via custom_css): --bg --card --text --muted --accent --accent-text --border --error --radius --field-radius --font --field-gap --input-pad --card-pad
  • Derived CSS variables (auto-computed from the contract vars with color-mix, so a custom --accent/--card flows into focus rings, hovers, soft fills and field backgrounds for free — override only for fine control): --field-bg --field-bg-focus --hover --ring --accent-soft --shadow-card --shadow-pop --shadow-strength --chevron --ease

Worked example — brand the card and the submit button: [data-part="card"]{ border:1px solid #ece7ff } button[type=submit]{ background:var(--accent); border-radius:999px }. By default the form card has NO hard outline (a soft shadow carries the edge, with a near-invisible hairline), so a visible card border appears only if you add one here. Selectors that silently match nothing — don't use them: [data-card], .form-card, [data-submit], [data-title]. The real hooks are [data-part="…"] (or the plain class).

Borders run soft by default — every outline is a low-contrast tint of --text over --card, so a dark or saturated text color won't produce a hard gray box. Recolor them all at once with theme.colors.border (a hex), or per-element with the --border variable / the hooks above.

Structure note — .choice vs .pchoice are NOT the same shape, don't style them as a pair: .choice is a full-width option row with its own padding and a hover/checked background wash (it uses a negative horizontal margin so the wash bleeds to the card's padding edge); .pchoice is a bordered card in a grid. To turn .choice rows into separated, bordered option cards, drop the negative margin and add a border: .choice{ margin:0; border:1px solid var(--border); border-radius:var(--field-radius) } — the checked state .choice:has(input:checked){ border-color:var(--accent) } already lights up. Don't add a border without also zeroing the margin, or the outline runs edge-to-edge.

Full-page backgrounds: set the backdrop on body (or via --bg) — body already grows with the form and paints across the ENTIRE scroll height, so an image or gradient there covers the page on any screen size. Do NOT give body, html, or a wrapper height:100vh/100dvh/height:100% to "fill the screen": that clamps the painted box to one viewport, so a form taller than the screen shows the backdrop cutting off as you scroll. If you want the backdrop to stay put while content scrolls over it, add background-attachment: fixed — never a fixed height. Example: body{ background:linear-gradient(180deg,#0b1020,#1a2540); background-attachment:fixed }.

Brand fonts: @font-face is allowed — host the .woff2 over https and reference it, e.g. custom_css: "@font-face{font-family:Brand;src:url(https://cdn.brand.com/font.woff2)} :root{--font:Brand,sans-serif}". (Google Fonts also work via theme.font by name.)

Native controls are color-scheme-pinned to the theme automatically, and radio/checkbox/slider are theme-drawn — any palette renders correctly without extra CSS.

Best practices (the built-in styles already follow these — match them in custom_css): focus controls with a box-shadow ring (box-shadow:0 0 0 3.5px var(--ring)) and outline:none, never a hard outline on inputs/radios/checkboxes; keep radio/checkbox marks drawn via the accent fill (don't re-enable native appearance); preserve a visible focus state for keyboard users; keep the submit label large+bold for white-on-accent contrast; and prefer the derived vars (--ring, --hover, --accent-soft, --field-bg) over hardcoded colors so light/dark and themed palettes stay consistent.

Workflow tip: a coding agent can read a target site's computed colors, font + font URL, and border-radius, map them onto theme.colors/font/radius, and add a few custom_css rules against the hooks above — one create_form call yields a hosted form that matches the brand.

Embedding

Every form ships an embed loader at /f/<id>/embed.js — drop one tag where the form should appear:

  • Inline, auto-resizing iframe: <script src="https://formling.dev/f/<id>/embed.js"></script>
  • Popup launcher + modal: <script src="https://formling.dev/f/<id>/embed.js" data-mode="popup" data-button="Give feedback"></script>

The inline iframe sizes itself to its content (no inner scrollbar); the popup closes after a successful submit. The form keeps its theme, so brand it with the hooks above to match the host page. A plain <iframe src="https://formling.dev/f/<id>?embed=1"> also works if you size it yourself.

Rate limits

  • create_form: 20 per hour per IP.
  • submit: 10 per minute per IP per form.

Errors

Every error is { error: { code, field?, message } }. REST errors also include a top-level docs URL (this reference) and a Link: <…/llms.txt>; rel="help" header.

codeHTTPmeaning
validation_error400Request or submission failed schema validation.
unauthorized401Missing or malformed Authorization header.
forbidden403Admin token does not match this form.
not_found404Form does not exist or was deleted.
form_closed409Form is closed or at its max_responses.
form_expired410Unclaimed form is past its expires_at.
payload_too_large413Request body exceeds the 100 KB limit.
rate_limited429Per-IP rate limit exceeded.
email_unavailable503Magic-link email could not be sent (provider unconfigured or down).
not_implemented501Endpoint is stubbed.
internal_error500Unexpected server error.

Lifecycle

  • Unclaimed forms expire 30 days after creation; the public page/submit then return 410. The creator can still read data with the admin token during the grace window.
  • A daily cron purges soft-deleted forms 24h after deletion and unclaimed forms 7 days after expiry (with their responses).
  • Claiming makes a form permanent. Open the claim_url from create_form (the admin token also works as the t parameter), enter an email, and confirm the emailed single-use magic link. The form attaches to that email's account, never expires again, and the admin token keeps working. Signed-in owners see all their forms at /account.

More

  • /llms.txt — the complete plain-text reference for agents (every endpoint, operation, field type, theme prop, error code and limit).