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.
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).
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.
| name | title | what it does | hints |
|---|---|---|---|
| create_form | Create form | Create 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_form | Get form | Fetch 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_form | Update form | Modify 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_responses | Get responses | Read 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_form | Close form | Stop 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_form | Delete form | Soft-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 |
| method & path | auth | response |
|---|---|---|
| POST /api/v1/forms | none (IP rate-limited, 20/hour) | 201 { form_id, form_url, responses_url, admin_token, claim_url, expires_at } |
| GET /api/v1/forms/:id | Bearer admin_token | 200 { form_id, title, description, fields, theme, settings, status, created_at, expires_at, stats } |
| PATCH /api/v1/forms/:id | Bearer admin_token | 200 (same shape as GET) |
| POST /api/v1/forms/:id/close | Bearer admin_token | 200 { form_id, status:'closed' } (idempotent) |
| DELETE /api/v1/forms/:id | Bearer admin_token | 200 { form_id, status:'deleted', purge_after } (soft delete, idempotent) |
| GET /api/v1/forms/:id/responses | Bearer admin_token | 200 { responses[], next_cursor } — or CSV text when format=csv |
| GET /f/:id | none (public) | 200 rendered HTML form (410 expired, closed page when closed/at capacity, 404 missing). Always noindex. |
| POST /f/:id/submit | none (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 param | 200 text/csv as an attachment (filename="<form_id>.csv"). |
Every field has key, type, label, optional placeholder and required. Type-specific props:
| type | props | description |
|---|---|---|
| short_text | min_length?, max_length?, pattern? (anchored regex) | Single-line text input. |
| long_text | min_length?, max_length? | Multi-line textarea. |
| — | 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. |
| number | min?, max?, step?, display?: "input"|"slider" (slider needs min+max) | Numeric input (any finite number within constraints). |
| select | options: (string|{label,image_url})[] (≥1), display?: "dropdown"|"radio" | Single choice from a list; image options render as picture choice. |
| multi_select | options: (string|{label,image_url})[] (≥1) | Zero or more choices from a list; image options render as picture choice. |
| rating | min: 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). |
| matrix | rows: string[] (1–20), columns: string[] (1–10) | Grid: one column choice per row. Answer: {row: column}. |
| ranking | options: 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. |
| statement | body? (no placeholder/required) | Display-only text block; collects no data, excluded from responses. |
| hidden | value? (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.
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..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:root{} via custom_css): --bg --card --text --muted --accent --accent-text --border --error --radius --field-radius --font --field-gap --input-pad --card-padcolor-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 --easeWorked 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.
Every form ships an embed loader at /f/<id>/embed.js — drop one tag where the form should appear:
<script src="https://formling.dev/f/<id>/embed.js"></script><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.
create_form: 20 per hour per IP.submit: 10 per minute per IP per form.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.
| code | HTTP | meaning |
|---|---|---|
| validation_error | 400 | Request or submission failed schema validation. |
| unauthorized | 401 | Missing or malformed Authorization header. |
| forbidden | 403 | Admin token does not match this form. |
| not_found | 404 | Form does not exist or was deleted. |
| form_closed | 409 | Form is closed or at its max_responses. |
| form_expired | 410 | Unclaimed form is past its expires_at. |
| payload_too_large | 413 | Request body exceeds the 100 KB limit. |
| rate_limited | 429 | Per-IP rate limit exceeded. |
| email_unavailable | 503 | Magic-link email could not be sent (provider unconfigured or down). |
| not_implemented | 501 | Endpoint is stubbed. |
| internal_error | 500 | Unexpected server error. |
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.