# AI Form API: full reference for AI agents > Your AI agent makes a form. People fill it, or hand it to their own AI. Your agent reads the answers. REST API and MCP. Base URL: https://aiformapi.com Machine-readable spec: https://aiformapi.com/openapi.json (OpenAPI 3.1) ## What an agent can do 1. Create a form (create_form or POST /v1/forms). The result has public_url: share it. 2. People fill the form on the web page, or hand it to their own AI agent (agent fill). 3. Read the responses later (get_responses or GET /v1/forms/{id}/responses) as JSON rows or CSV. The service never summarises: read the rows and summarise them yourself. 4. Optionally get a signed webhook for every new response. Every step is done or not done: the form exists, the link is live, the response is stored (the receipt id proves it), the data comes back. ## Authentication Send `Authorization: Bearer afk_<32 letters and digits>`. Create the first key by signing in with an email address at https://aiformapi.com/login (magic link, no password). Only a hash of the key is stored. ## Limits (free plan) - API key (REST and creator MCP together): 30 requests per minute, 1000 per day - Open forms per account: 3 - Responses per account per UTC calendar month, all sources: 100. After that: 403 quota_exceeded - Public form schema GET: 60 per minute per IP - Human submit: 5 per minute and 30 per hour per IP; 60 per minute per form - Form backend POST /submit: 5 per minute and 30 per hour per IP; 60 per minute per form; at most 50 fields, names up to 100 and values up to 5000 characters - Fill code mint: 5 per minute and 30 per day per IP; 200 per day per form - Fill code: single successful use, valid 60 minutes, at most 5 POST attempts (the 6th gets 410 token_expired with details.reason "max_attempts"), GET 30 per hour - Fill POST: 10 per minute per IP. Fill MCP: 20 calls per minute per IP - Signup: 3 per hour per IP, 3 per day per email - Request body: 64 KB - Webhooks: at most 3 per form, https only, 5 s timeout, 1 retry Rate-limited endpoints send `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds). A 429 adds `Retry-After` (seconds). Pro plan (coming soon, not on sale, no billing yet): $19 per month. Per API key 120 requests per minute and 20000 per day, unlimited open forms, 10000 responses per month. The other limits above stay the same. When Pro launches, the API, MCP, agent fill and CSV export stay on the free plan. ## Form definition Field object: `{id, label, type, required?, help?, options?, min?, max?, max_length?}`. `id` is snake_case, unique, at most 40 characters. At most 50 fields per form. Types: - `short_text`: Text up to 500 characters. - `long_text`: Text up to 5,000 characters. - `email`: Email address. - `number`: Number. Optional min and max. - `url`: http or https address. - `phone`: Phone number, 5 to 15 digits. - `single_choice`: One of options. - `multi_choice`: A list of options. Optional min and max count. - `checkbox`: true or false. - `date`: ISO date YYYY-MM-DD. - `rating`: Whole number from min to max (default 1 to 5, at most 10). Settings: `{agent_fill?: boolean (default true), agent_line?: boolean (default true for new forms: the static agent line), agent_code?: string|null (write-only secret for the agent line, stored hashed), close_at?: ISO 8601, max_responses?: integer, confirmation_message?: string, notify_email?: boolean (default false)}`. There is no redirect after submit on the free plan. Free checker for a form definition (runs in the browser, sends nothing): https://aiformapi.com/tools/form-json-validator. It uses the same validator as POST /v1/forms. Blocked fields (anti-phishing): a field whose id, label or help matches any of these (case-insensitive, English and German) is rejected with 422 `blocked_field`: password, passwort, passcode, PIN, CVV, CVC, card number, credit card, Kreditkarte, IBAN, seed phrase, recovery phrase, private key, 2FA, one-time code, OTP, social security, SSN, passport, Reisepass, Personalausweis, national ID, Aadhaar, Steuer-ID, NIN. Answers: an object that maps field ids to values. Unknown ids are rejected. Empty values are treated as missing. A required checkbox must be true. Numbers and ratings may be sent as numeric strings. ## REST API ### POST /v1/signup Send a sign-in link. Sends a magic link to the address. The link signs in to the dashboard, which shows the first API key once. The answer is the same for known and unknown addresses. - Auth: none - Body: SignupInput - Success: 202 Link sent (or the address is ignored) - Errors: `validation_failed`, `rate_limited`, `forbidden`, `service_unavailable` ### POST /v1/forms Create a form. Creates an open form and returns its public URL. Free plan: 3 open forms. Fields asking for passwords, codes, card or account numbers, and passport or national ID numbers are rejected with 422 blocked_field. - Auth: Bearer API key - Body: FormInput - Success: 201 Form created - Errors: `unauthorized`, `validation_failed`, `blocked_field`, `quota_exceeded`, `payload_too_large`, `rate_limited` ### POST /v1/forms/import Copy a form from Google Forms (draft). Send url (a public Google Forms link: docs.google.com/forms/... or forms.gle/...) or fields (a list of {label, type, required, options}). Returns a draft you can send to POST /v1/forms. Nothing is saved. Questions we cannot copy (sections, images, file upload, grids) and questions that ask for passwords, codes, card or account numbers, and passport or national ID numbers are listed in skipped. The server only reads Google hosts. Per account: 6 link imports per minute. - Auth: Bearer API key - Body: ImportInput - Success: 200 A draft form, the skipped questions and notes - Errors: `unauthorized`, `validation_failed`, `payload_too_large`, `rate_limited`, `service_unavailable` ### GET /v1/forms List your forms. Newest first, cursor pagination. - Auth: Bearer API key - Query: `limit` (Page size, 1 to 100 (default 20)); `cursor` (next_cursor from the previous page) - Success: 200 A page of forms - Errors: `unauthorized`, `validation_failed`, `rate_limited` ### GET /v1/forms/{id} Read a form. - Auth: Bearer API key - Success: 200 The form with its webhooks (secrets are never returned again) - Errors: `unauthorized`, `not_found`, `rate_limited` ### PATCH /v1/forms/{id} Change title, description, fields or settings. Send only what changes. fields replaces the whole list. settings merges key by key and null removes a key. - Auth: Bearer API key - Body: FormPatch - Success: 200 The updated form - Errors: `unauthorized`, `not_found`, `validation_failed`, `blocked_field`, `rate_limited` ### DELETE /v1/forms/{id} Delete a form and all its responses. - Auth: Bearer API key - Success: 200 Deleted - Errors: `unauthorized`, `not_found`, `rate_limited` ### POST /v1/forms/{id}/close Stop accepting responses. - Auth: Bearer API key - Success: 200 The closed form - Errors: `unauthorized`, `not_found`, `rate_limited` ### POST /v1/forms/{id}/reopen Accept responses again. Counts against the open-form quota like a new form. - Auth: Bearer API key - Success: 200 The reopened form - Errors: `unauthorized`, `not_found`, `quota_exceeded`, `rate_limited` ### GET /v1/forms/{id}/responses Read responses as JSON or CSV. Oldest first by default. CSV has one column per field id, then response_id, source, submitted_at. When more rows exist the CSV response carries an X-Next-Cursor header. - Auth: Bearer API key - Query: `format` (json (default) or csv); `since` (Only responses after this ISO 8601 time); `limit` (JSON: 1 to 1000 (default 100). CSV: 1 to 10000 (default 10000)); `cursor` (next_cursor from the previous page); `order` (asc (default) or desc) - Success: 200 A page of responses (JSON) or a CSV file - Errors: `unauthorized`, `not_found`, `validation_failed`, `rate_limited` ### GET /v1/forms/{id}/responses/{response_id} Read one response. - Auth: Bearer API key - Success: 200 The response - Errors: `unauthorized`, `not_found`, `rate_limited` ### POST /v1/forms/{id}/webhooks Call a URL for each new response. POST {event:"response.created", form_id, response} with header X-AIFormAPI-Signature: t=,v1=. 5 s timeout, 1 retry. At most 3 per form. The secret is returned once. - Auth: Bearer API key - Body: WebhookInput - Success: 201 Webhook added - Errors: `unauthorized`, `not_found`, `validation_failed`, `quota_exceeded`, `rate_limited` ### DELETE /v1/forms/{id}/webhooks/{webhook_id} Remove a webhook. - Auth: Bearer API key - Success: 200 Removed - Errors: `unauthorized`, `not_found`, `rate_limited` ### GET /v1/usage Plan, limits and current usage. - Auth: Bearer API key - Success: 200 Usage - Errors: `unauthorized`, `rate_limited` ### GET /v1/public/forms/{slug} Public form schema. No owner data. Per IP: 60 per minute. - Auth: none - Success: 200 The public form - Errors: `not_found`, `rate_limited` ### POST /v1/public/forms/{slug}/responses Submit a response as a person. Per IP: 5 per minute and 30 per hour. Per form: 60 per minute. When Turnstile is on, send turnstile_token. - Auth: none - Body: HumanSubmitInput - Success: 201 Stored - Errors: `invalid_answers`, `form_closed`, `quota_exceeded`, `not_found`, `forbidden`, `rate_limited`, `payload_too_large` ### POST /v1/public/forms/{slug}/fill-tokens Get a fill code and prompt for an AI agent. Returns a single-use code (valid 60 minutes, 5 POST attempts) and the ready-to-paste prompt. Per IP: 5 per minute and 30 per day. Per form: 200 per day. - Auth: none - Body: FillTokenMintInput (optional) - Success: 201 Token minted - Errors: `not_found`, `forbidden`, `form_closed`, `quota_exceeded`, `rate_limited` ### GET /v1/fill/{token} Read the form and instructions for a fill code. For agents that can only GET. Does not use up the code. 30 per hour per code. - Auth: fill code in the URL - Success: 200 Schema and instructions - Errors: `not_found`, `token_expired`, `token_used`, `rate_limited` ### POST /v1/fill/{token} Submit answers with a fill code. One successful use per code. At most 5 POST attempts: the 6th gets 410 token_expired with details.reason "max_attempts". Per IP: 10 per minute. - Auth: fill code in the URL - Body: FillSubmitInput - Success: 201 Stored - Errors: `invalid_answers`, `token_expired`, `token_used`, `form_closed`, `quota_exceeded`, `not_found`, `rate_limited` ### GET /v1/agent/{slug} Read a form for the static agent line. The address inside the one-line prompt an owner puts above a form. No fill code and no human check. Works only when the owner turned on agent_line (default on for new forms) and agent_fill. A form with a secret code needs it as the query parameter c. Does not store anything. Per IP: 30 per minute. - Auth: none - Query: `c` (The owner's secret code, when the form has one.) - Success: 200 Questions, instructions, submit address and review link - Errors: `not_found`, `forbidden`, `blocked_field`, `rate_limited`, `rate_limiter_unavailable` ### POST /v1/agent/{slug} Submit answers through the static agent line. Needs the owner's switch (agent_line), the secret code if one is set, an open form and room in the owner's monthly quota. Fields asking for passwords, codes, card or account numbers, and passport or national ID numbers are never accepted. The same answers from the same network within 60 minutes return 200 with the first receipt and duplicate true; nothing new is stored. Per IP: 5 per minute and 30 per hour; per form: 30 per minute and 300 per hour. Fails closed (503) in production when the rate limiter is missing. - Auth: none - Body: AgentLineSubmitInput - Query: `c` (The owner's secret code, when the form has one. It can also go in the body as code.) - Success: 200 Duplicate: the first receipt; 201 Stored - Errors: `invalid_answers`, `form_closed`, `quota_exceeded`, `forbidden`, `blocked_field`, `not_found`, `rate_limited`, `rate_limiter_unavailable` ### POST /v1/public/abuse-reports Report a form. - Auth: none - Body: AbuseReportInput - Success: 201 Report stored - Errors: `not_found`, `validation_failed`, `rate_limited` ## Errors Shape: `{"error": {"code", "message", "details"?}}`. - `unauthorized`: 401. Missing, malformed or revoked API key. - `forbidden`: 403. Not allowed (for example agent fill is off, or the human check failed). - `not_found`: 404. The resource does not exist or belongs to another account. - `validation_failed`: 422 (400 for malformed JSON). The request is not valid. details.errors lists path and message. - `blocked_field`: 422. A field asks for a password, code, card or account number, or a passport or national ID number. details.fields lists them. - `invalid_answers`: 422. Answers do not match the form. details.fields lists id and problem. - `form_closed`: 409. The form is closed, past its closing time or at its response limit. - `quota_exceeded`: 403. A plan limit is reached (open forms, responses per month, webhooks). - `rate_limited`: 429. Too many requests. Wait Retry-After seconds. - `token_expired`: 410. The fill code expired or used up its attempts. - `token_used`: 410. The fill code was already used. - `payload_too_large`: 413. The body is larger than 64 KB. - `rate_limiter_unavailable`: 503. The rate limiter is not available, so the endpoint is closed. - `service_unavailable`: 503. A required service (for example email) is not available. - `internal_error`: 500. Unexpected error on our side. ## Agent fill Every public form page offers: "Want your AI agent to fill this?". The visitor copies a prompt that holds the whole form. Path A: the agent has tools and POSTs the answers to /v1/fill/{token} or uses the fill MCP. Path B: the agent replies with a JSON block, the visitor pastes it into the form page, checks it and presses Send. Static agent line (owner switch settings.agent_line, default on for new forms): one short line for any AI chat: Fill this form for me: {site}/a/{slug}. GET /a/{slug} returns plain text with the questions and two ways to send. POST /v1/agent/{slug} takes the answers. No per-visitor token; an AI that cannot send requests gives the person a link /f/{slug}#fill= to check and send. The prompt is created by `POST /v1/public/forms/{slug}/fill-tokens`. Example for a two-field form: ```text You are helping me fill in a form. Please do it carefully. Form: "Friday RSVP" Dinner on Friday. Sent through aiformapi.com · form id frm_example Questions: 1. [name] Your name — short text, required, max 500 characters 2. [attending] Are you coming? — single choice, one of: "Yes", "No", required Rules: - Use what you already know about me from this chat only when you are sure. Ask me for anything missing, one short question at a time. - Never invent personal details. Leave out optional questions I skip. - Before you submit, show me all answers and ask "Send it?". Submit only after I say yes. How to submit (use the first option you can): A) If you can make HTTP requests, or you have the aiformapi fill connector (https://aiformapi.com/mcp/fill): POST https://aiformapi.com/v1/fill/fil_ Content-Type: application/json {"answers": {: , ...}} 201 = done: tell me the receipt. 422 = fix the listed fields and send again (max 5 tries). 429 = wait Retry-After seconds. B) Otherwise reply with ONLY this block, filled in, so I can paste it back into the form: ```json {"form": "frm_example", "answers": {"name": "", "attending": ""}} ``` This code expires at 2026-01-01 12:00 UTC. ``` ## MCP Remote MCP over Streamable HTTP (stateless, JSON responses). Creator server: https://aiformapi.com/mcp with header `Authorization: Bearer afk_...`. Fill server: https://aiformapi.com/mcp/fill, no auth. Creator calls count against the same rate limit as REST calls for the key. | Server | Tool | Arguments | Does | |---|---|---|---| | creator | `create_form` | title, description?, fields[], settings? | POST /v1/forms. Returns the form with public_url. | | creator | `list_forms` | limit?, cursor? | GET /v1/forms. | | creator | `get_form` | form_id | GET /v1/forms/{id}, with webhooks. | | creator | `update_form` | form_id, title?, description?, fields?, settings? | PATCH /v1/forms/{id}. | | creator | `close_form` | form_id | POST /v1/forms/{id}/close. | | creator | `reopen_form` | form_id | POST /v1/forms/{id}/reopen. | | creator | `get_responses` | form_id, format? (json|csv), since?, limit?, cursor?, order? (asc|desc) | GET /v1/forms/{id}/responses. Returns rows, never a summary. | | creator | `add_webhook` | form_id, url | POST /v1/forms/{id}/webhooks. Returns the secret once. | | creator | `get_usage` | (none) | GET /v1/usage. | | fill | `get_fill_form` | fill_token | GET /v1/fill/{token}. Does not use up the code. | | fill | `submit_fill` | fill_token, answers | POST /v1/fill/{token}. Same validation and token rules. | Tool errors come back with `isError: true` and the same JSON error body as REST. ## Webhooks Each stored response is POSTed to every webhook URL: `{"event": "response.created", "form_id", "response": {id, form_id, answers, source, submitted_at}}`. Header `X-AIFormAPI-Signature: t=,v1=` where hex = HMAC-SHA256(secret, t + "." + raw body). Reject timestamps older than 5 minutes. --- # Guides ## Quickstart: create a form, share it and read the responses Source: https://aiformapi.com/docs/quickstart You need 4 steps and about 5 minutes. Get an API key. Send one POST to create a form. Share the link that comes back. Call one GET to read the responses as JSON or CSV. Every example works as written once you set your key. The base URL for every request is `https://aiformapi.com/v1`. All bodies are JSON. ## 1. Get an API key 1. Open [aiformapi.com](/) and enter your email. You can also call `POST /v1/signup` with `{"email": "you@example.com"}`. 2. We send you a magic link. There is no password. 3. The link signs you in to the dashboard. The dashboard shows your first API key once. Copy it now. Your key starts with `afk_`. Put it in an environment variable. Never paste it into a file you commit. ```bash export AIFORMAPI_KEY="afk_paste_your_key_here" ``` If you lose the key, rotate it in the dashboard settings and copy the new one. See [Authentication](/docs/authentication). ## 2. Create a form This request creates a small RSVP form. It has 3 fields and agent fill turned on. See [Forms and fields](/docs/forms-and-fields) for every field type. ### curl ```bash curl -X POST https://aiformapi.com/v1/forms \ -H "Authorization: Bearer $AIFORMAPI_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Friday dinner RSVP", "description": "Tell us if you can come. It takes 20 seconds.", "fields": [ {"id": "full_name", "label": "Your name", "type": "short_text", "required": true}, {"id": "attending", "label": "Can you come?", "type": "single_choice", "required": true, "options": ["Yes", "No", "Maybe"]}, {"id": "guests", "label": "How many people are you bringing?", "type": "number", "min": 0, "max": 5} ], "settings": {"agent_fill": true, "confirmation_message": "Thanks. See you Friday."} }' ``` ### JavaScript (Node 18 or newer) ```js const BASE = "https://aiformapi.com/v1"; const KEY = process.env.AIFORMAPI_KEY; if (!KEY) throw new Error("Set AIFORMAPI_KEY first."); async function api(path, options = {}) { const res = await fetch(`${BASE}${path}`, { ...options, headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...options.headers, }, }); const isJson = (res.headers.get("content-type") || "").includes("json"); const body = isJson ? await res.json() : await res.text(); if (!res.ok) { const e = body.error || {}; throw new Error(`${res.status} ${e.code}: ${e.message}`); } return body; } const form = await api("/forms", { method: "POST", body: JSON.stringify({ title: "Friday dinner RSVP", description: "Tell us if you can come. It takes 20 seconds.", fields: [ { id: "full_name", label: "Your name", type: "short_text", required: true }, { id: "attending", label: "Can you come?", type: "single_choice", required: true, options: ["Yes", "No", "Maybe"], }, { id: "guests", label: "How many people are you bringing?", type: "number", min: 0, max: 5 }, ], settings: { agent_fill: true, confirmation_message: "Thanks. See you Friday." }, }), }); console.log(form.public_url); ``` Save this as `create.mjs` and run `node create.mjs`. ### Python (requests) ```python import os import requests BASE = "https://aiformapi.com/v1" KEY = os.environ.get("AIFORMAPI_KEY") if not KEY: raise SystemExit("Set AIFORMAPI_KEY first.") session = requests.Session() session.headers.update({"Authorization": f"Bearer {KEY}"}) resp = session.post( f"{BASE}/forms", json={ "title": "Friday dinner RSVP", "description": "Tell us if you can come. It takes 20 seconds.", "fields": [ {"id": "full_name", "label": "Your name", "type": "short_text", "required": True}, { "id": "attending", "label": "Can you come?", "type": "single_choice", "required": True, "options": ["Yes", "No", "Maybe"], }, {"id": "guests", "label": "How many people are you bringing?", "type": "number", "min": 0, "max": 5}, ], "settings": {"agent_fill": True, "confirmation_message": "Thanks. See you Friday."}, }, timeout=30, ) resp.raise_for_status() form = resp.json() print(form["public_url"]) ``` ### What comes back A successful call returns `201`. ```json { "id": "frm_4k9QeV2mTc8Xa1", "slug": "k3p9x2m7qa", "public_url": "https://aiformapi.com/f/k3p9x2m7qa", "status": "open", "created_at": "2026-10-30T09:00:00Z" } ``` Keep the `id`. You need it to read responses. ## 3. Share the link Send the `public_url` to anyone. They need no account. The page works on a phone. Because `agent_fill` is true (it is the default), the page also shows a card that says "Want your AI agent to fill this?". A visitor can copy a prompt, give it to Claude, ChatGPT or any agent, and the agent fills the form for them. Read [Agent fill](/docs/agent-fill) to see how it works. You can also put the form on your own page. See [Embed](/docs/embed). ## 4. Read the responses After someone submits, ask for the responses. Replace `FORM_ID` with the `id` from step 2. ### curl, JSON ```bash curl "https://aiformapi.com/v1/forms/FORM_ID/responses?format=json" \ -H "Authorization: Bearer $AIFORMAPI_KEY" ``` ### curl, CSV ```bash curl "https://aiformapi.com/v1/forms/FORM_ID/responses?format=csv" \ -H "Authorization: Bearer $AIFORMAPI_KEY" \ -o responses.csv ``` ### JavaScript ```js // Uses the api() helper from step 2. const page = await api(`/forms/${form.id}/responses?format=json`); for (const row of page.data) { console.log(row.submitted_at, row.source, row.answers); } ``` ### Python ```python r = session.get(f"{BASE}/forms/{form['id']}/responses", params={"format": "json"}, timeout=30) r.raise_for_status() for row in r.json()["data"]: print(row["submitted_at"], row["source"], row["answers"]) ``` A response looks like this: ```json { "data": [ { "id": "rsp_9Zt3mWq7Lx2KbN", "form_id": "frm_4k9QeV2mTc8Xa1", "source": "agent_api", "submitted_at": "2026-10-30T09:14:02Z", "answers": {"full_name": "Ada Lovelace", "attending": "Yes", "guests": 1} } ], "next_cursor": null } ``` The `source` tells you how the answer arrived: `human`, `paste`, `agent_api` or `agent_mcp`. See [Responses](/docs/responses) for paging, `since` and CSV columns. ## Or let your AI make the form You can skip the API. Press "Set up with your AI" on the dashboard home. Copy the line: ```text Make me a form with aiformapi: https://aiformapi.com/s/your-setup-code ``` Paste it into your own AI chat. Tell it what the form is for. It drafts the questions and shows them. After you say yes, it sends one request and gives you the form link and the fill line. The setup link works once. It expires after 24 hours. An AI that cannot send requests gives you a link to the form builder instead. ## What the free plan gives you - 3 open forms per account. - 100 responses per account per calendar month (UTC). - 30 requests per minute and 1,000 per day for each API key. The full table is in [Rate limits and quotas](/docs/rate-limits). ## Next steps - Ask your AI agent to do all of this for you with the [MCP server](/docs/mcp). - Get a call on your server for each new response with [webhooks](/docs/webhooks). - Start from one of the [form templates](/templates). - Look up an error code in [Errors](/docs/errors). ## Authentication: one API key, sent as a Bearer token Source: https://aiformapi.com/docs/authentication You sign in with an email link and copy one API key. Send it in the `Authorization` header. Keys start with `afk_`. We show the full key once. We store only a hash. One key works for the REST API and the creator MCP server. ## Get a key 1. Enter your email on the home page, or call `POST /v1/signup`. 2. Open the magic link we send you within 15 minutes. It signs you in to the dashboard. 3. Copy the key the dashboard shows. This is the only time you see all of it. ```bash curl -X POST https://aiformapi.com/v1/signup \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com"}' ``` Signup is limited to 3 requests per hour for each IP address and 3 per day for each email address. When spam protection is on, the request also carries a `turnstile_token`. The dashboard form handles that for you. ## Send the key Put the key in the `Authorization` header with the word `Bearer`. ```bash curl https://aiformapi.com/v1/forms \ -H "Authorization: Bearer afk_your_key_here" ``` ```js const res = await fetch("https://aiformapi.com/v1/forms", { headers: { Authorization: `Bearer ${process.env.AIFORMAPI_KEY}` }, }); ``` ```python import os, requests r = requests.get( "https://aiformapi.com/v1/forms", headers={"Authorization": f"Bearer {os.environ['AIFORMAPI_KEY']}"}, timeout=30, ) ``` If the key is missing or wrong you get `401` with the code `unauthorized`. Every error uses the same JSON shape. See [Errors](/docs/errors). ## What the key looks like | Part | Value | |---|---| | Prefix | `afk_` | | Body | 32 characters, letters and digits (base62) | | Stored by us | A SHA-256 hash and the first 8 characters, so the dashboard can show you which key is active | | Shown to you | In full, once | ## Which endpoints need a key | Endpoint group | Credential | |---|---| | `/v1/forms` and everything under it, `/v1/usage` | API key | | Creator MCP at `/mcp` | API key in the header | | `/v1/public/forms/{slug}` and its responses | None. These are for people filling a form. | | `/v1/public/forms/{slug}/fill-tokens` | None | | `/v1/fill/{token}` and the fill MCP at `/mcp/fill` | The fill token is the credential | | `/openapi.json` | None | A fill token is not an API key. It works for one form, one successful submit and 60 minutes. See [Fill API](/docs/fill-api). ## Keep the key safe - Read it from an environment variable. Do not write it into code. - Never put it in a web page, a mobile app or any code that runs in a browser. Call the API from a server. - Do not commit it. Add the file that holds it to `.gitignore`. - Give it to your AI agent only through the MCP client config. See [MCP](/docs/mcp). - Watch your usage with `GET /v1/usage` or the usage bars in the dashboard settings. If a key leaks, rotate it in the dashboard settings and update every place that uses it. Do this first. Look for damage after. ## Limits that follow the key The free plan allows 30 requests per minute and 1,000 per day for each key. REST calls and creator MCP calls count together. Every response from a rate-limited endpoint carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. See [Rate limits and quotas](/docs/rate-limits). ## Next - Make your first call: [Quickstart](/docs/quickstart). - Connect an agent: [MCP](/docs/mcp). ## Forms and fields: the 11 field types and every setting Source: https://aiformapi.com/docs/forms-and-fields A form is a title, an optional description, up to 50 fields and a few settings. Send it as JSON to `POST /v1/forms`. This page lists every field type and form setting. It lists the endpoints that manage forms. It lists the fields we refuse to store. ## Create a form ```bash curl -X POST https://aiformapi.com/v1/forms \ -H "Authorization: Bearer $AIFORMAPI_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Customer feedback", "description": "Two minutes. We read every answer.", "fields": [ {"id": "name", "label": "Your name", "type": "short_text"}, {"id": "rating", "label": "How was it?", "type": "rating", "required": true} ], "settings": {"agent_fill": true} }' ``` `201` returns `id`, `slug`, `public_url`, `status` (always `open` at first) and `created_at`. ## The field object | Property | Type | Notes | |---|---|---| | `id` | string | Required. snake_case: lower-case letters, digits and underscores, starting with a letter. Unique in the form, 40 characters or fewer. It is the key in answers and the column name in CSV. | | `label` | string | Required. The question the person sees. Up to 200 characters. | | `type` | string | Required. One of the 11 types below. | | `required` | boolean | Optional. Default `false`. | | `help` | string | Optional. A short hint under the question. Up to 500 characters. | | `options` | array of strings | Required for `single_choice` and `multi_choice`: 2 to 50 distinct options. Not allowed on other types. | | `min`, `max` | number | For `number`: the smallest and largest value. For `rating`: the lowest and highest score (default 1 to 5, within 0 to 10). For `multi_choice`: the fewest and most choices. | | `max_length` | integer | For `short_text` and `long_text`. Cannot be higher than the type limit. | A form needs at least 1 field and can hold at most 50. The title can be up to 200 characters and the description up to 2,000. Pick ids you will keep. If you rename an id later, the CSV column and the answer key change with it. The API rejects properties it does not know. ## Field types Each example shows the field and a valid answer. ### `short_text` One line, up to 500 characters. ```json {"id": "full_name", "label": "Your name", "type": "short_text", "required": true, "max_length": 80} ``` Answer: `"Ada Lovelace"` ### `long_text` Several lines, up to 5,000 characters. ```json {"id": "message", "label": "How can we help?", "type": "long_text", "max_length": 2000} ``` Answer: `"I would like to change my booking to Friday."` ### `email` ```json {"id": "email", "label": "Email address", "type": "email", "required": true} ``` Answer: `"ada@example.com"` ### `number` ```json {"id": "guests", "label": "How many guests?", "type": "number", "min": 0, "max": 5} ``` Answer: `2` (a JSON number, not a string) ### `url` ```json {"id": "portfolio", "label": "Link to your work", "type": "url"} ``` Answer: `"https://example.com/work"` ### `phone` ```json {"id": "mobile", "label": "Mobile number", "type": "phone", "help": "Include the country code."} ``` Answer: `"+44 7700 900123"` ### `single_choice` The person picks one option. ```json {"id": "meal", "label": "Main course", "type": "single_choice", "required": true, "options": ["Chicken", "Fish", "Vegetarian"]} ``` Answer: `"Fish"` (must match one option exactly) ### `multi_choice` The person picks any number of options. ```json {"id": "days", "label": "Which days can you work?", "type": "multi_choice", "options": ["Mon", "Tue", "Wed", "Thu", "Fri"]} ``` Answer: `["Mon", "Wed"]` ### `checkbox` A single yes or no box. A required checkbox must be ticked. ```json {"id": "agree_to_rules", "label": "I read the house rules", "type": "checkbox", "required": true} ``` Answer: `true` ### `date` ```json {"id": "start_date", "label": "Start date", "type": "date"} ``` Answer: `"2026-11-15"` (ISO format, `YYYY-MM-DD`) ### `rating` A whole number from `min` to `max`. The default range is 1 to 5. You can set a range anywhere from 0 to 10. ```json {"id": "satisfaction", "label": "How happy are you?", "type": "rating", "min": 1, "max": 10} ``` Answer: `8` ## How answers are checked The same checks run for the form page, the paste box, `POST /v1/fill/{token}` and the fill MCP server. - An optional field can be left out. An empty string, `null` or an empty list also counts as skipped. - A required field must have a value. A required `checkbox` must be `true`. - Text is trimmed. A `single_choice` or `multi_choice` value must match an option. Letter case does not matter. - A `number` can arrive as a JSON number or as a numeric string. It is stored as a number. - A `phone` number needs 5 to 15 digits. A `url` must start with `http://` or `https://`. A `date` must be a real calendar date. - An answer for a field id the form does not have is an error. Send only ids that exist in the form. Each problem comes back as `{"id": "field_id", "problem": "..."}`. See [Fill API](/docs/fill-api). ## Form settings Send `settings` when you create a form or change it with `PATCH`. | Setting | Type | What it does | |---|---|---| | `agent_fill` | boolean | Shows the "Want your AI agent to fill this?" card and allows fill tokens. Default `true`. Set it to `false` to turn agent fill off. See [Agent fill](/docs/agent-fill). | | `close_at` | date and time | The form closes by itself at this time. Use an ISO 8601 value such as `2026-12-01T00:00:00Z`. `null` clears it. | | `max_responses` | integer | The form closes to new answers after this many responses. `null` clears it. | | `confirmation_message` | string | The text a person sees after a successful submit. Up to 500 characters. | | `notify_email` | boolean | Send the account owner an email for each new response. Default `false`. | Free plan: there is no redirect after submit. If you send a redirect setting, the API tells you it is not available. The person always sees the confirmation message. Forms cannot hold custom HTML or JavaScript. ## Manage forms All of these need your API key. | Method and path | What it does | |---|---| | `GET /v1/forms?limit=&cursor=` | Lists your forms. Pages with a cursor: 20 per page by default, 100 at most. | | `GET /v1/forms/{id}` | Reads one form: fields, settings, status, response count, public link and its webhooks with the last delivery status. | | `PATCH /v1/forms/{id}` | Changes the title, description, fields or settings. | | `POST /v1/forms/{id}/close` | Stops new responses. The public page says the form is closed. | | `POST /v1/forms/{id}/reopen` | Accepts responses again. | | `DELETE /v1/forms/{id}` | Deletes the form and all its responses. This cannot be undone. It returns `{"id": "...", "deleted": true}`. | Examples: ```bash # List curl "https://aiformapi.com/v1/forms?limit=20" -H "Authorization: Bearer $AIFORMAPI_KEY" # Rename and change the confirmation text curl -X PATCH https://aiformapi.com/v1/forms/FORM_ID \ -H "Authorization: Bearer $AIFORMAPI_KEY" -H "Content-Type: application/json" \ -d '{"title": "Customer feedback, November", "settings": {"confirmation_message": "Thank you."}}' # Close, then reopen curl -X POST https://aiformapi.com/v1/forms/FORM_ID/close -H "Authorization: Bearer $AIFORMAPI_KEY" curl -X POST https://aiformapi.com/v1/forms/FORM_ID/reopen -H "Authorization: Bearer $AIFORMAPI_KEY" ``` On the free plan you can keep 3 forms open at once. Close a form to free a slot. Closed forms keep their responses. ## Blocked fields We refuse to store forms that ask for secrets. A form is a link anyone can send, so a form that asks for a password looks the same as a phishing page. Instead of trying to guess intent, we block the words. The API returns `422 blocked_field` when a field `id`, `label` or `help` matches any of these, in English or German, in any letter case: `password`, `passwort`, `passcode`, `PIN`, `CVV`, `CVC`, `card number`, `credit card`, `Kreditkarte`, `IBAN`, `seed phrase`, `recovery phrase`, `private key`, `2FA`, `one-time code`, `OTP`, `social security`, `SSN`, `passport`, `Reisepass`, `Personalausweis`, `national ID`, `Aadhaar`, `Steuer-ID`, `NIN`. The last 7 ban government identity numbers: - `passport` matches passport and passport number. - `Reisepass` matches the German passport, also as `Reisepassnummer`. - `Personalausweis` matches the German identity card, also as `Personalausweisnummer`. - `national ID` matches national ID, national identity number and national identification number. - `Aadhaar` matches the Indian identity number, also spelled Aadhar. - `Steuer-ID` matches Steuer-ID, Steueridentifikationsnummer and steuerliche Identifikationsnummer. - `NIN` matches the abbreviation NIN and the phrase National Insurance number. Spaces, dashes and underscores do not matter, so `card_number` and `card-number` match too. Short codes such as PIN match as whole words only, so a field about shipping is fine. The word `national identity` on its own is allowed, but a field for a national identity number is not. ```json { "error": { "code": "blocked_field", "message": "Field \"account_pin\" asks for \"PIN\" (in its id). Forms cannot ask for passwords, codes, card or account numbers, and passport or national ID numbers.", "details": { "fields": [{"id": "account_pin", "where": "id", "matched": "PIN"}] } } } ``` If your use case is legitimate, ask for the information another way. Collect a contact address and follow up in a tool that is built for secrets. The rules are in [Acceptable use](/acceptable-use). ## Validation errors A body that breaks the rules above gets `422 validation_failed`. The most common causes are an id with capital letters or spaces, a duplicate id, a choice field without `options`, an unknown property and more than 50 fields. The list of problems is in `error.details.errors`, each with a `path` and a `message`. ```json { "error": { "code": "validation_failed", "message": "fields[1].options: single_choice needs at least 2 options.", "details": { "errors": [{"path": "fields[1].options", "message": "single_choice needs at least 2 options."}] } } } ``` Each error code is explained in [Errors](/docs/errors). ## Next - Read answers back: [Responses](/docs/responses). - Start from a ready-made definition: [form templates](/templates). ## Read form responses as JSON or CSV Source: https://aiformapi.com/docs/responses Call `GET /v1/forms/{id}/responses` with your API key. Choose `format=json` or `format=csv`. Page with `limit` and `cursor`. Add `since` to get only newer rows. Each option below has code you can run. ## The request ```http GET /v1/forms/{id}/responses?format=json&since=&limit=&cursor=&order= Authorization: Bearer afk_... ``` | Parameter | What it does | |---|---| | `format` | `json` or `csv`. JSON is the default. | | `order` | `asc` (oldest first, the default) or `desc` (newest first). | | `since` | Only responses submitted after this ISO 8601 UTC time, for example `2026-10-30T09:00:00Z`. | | `limit` | Rows per page. JSON: 100 by default, 1,000 at most. CSV: up to 10,000 rows per file. | | `cursor` | The `next_cursor` from the previous page. | To read one row, call `GET /v1/forms/{id}/responses/{response_id}` with the `id` of a row. ## JSON ```bash curl "https://aiformapi.com/v1/forms/FORM_ID/responses?format=json&limit=50" \ -H "Authorization: Bearer $AIFORMAPI_KEY" ``` ```json { "data": [ { "id": "rsp_9Zt3mWq7Lx2KbN", "form_id": "frm_4k9QeV2mTc8Xa1", "source": "paste", "submitted_at": "2026-10-30T09:14:02Z", "answers": { "full_name": "Ada Lovelace", "attending": "Yes", "guests": 1 } } ], "next_cursor": "c_2Hn7" } ``` When `next_cursor` is `null` there are no more pages. Answers use the value types from [Forms and fields](/docs/forms-and-fields): strings, numbers, booleans and lists for `multi_choice`. ## CSV ```bash curl "https://aiformapi.com/v1/forms/FORM_ID/responses?format=csv" \ -H "Authorization: Bearer $AIFORMAPI_KEY" \ -o responses.csv ``` The CSV has one column for each field id. It also has three extra columns: `response_id`, `source` and `submitted_at`. ```csv full_name,attending,guests,response_id,source,submitted_at Ada Lovelace,Yes,1,rsp_9Zt3mWq7Lx2KbN,paste,2026-10-30T09:14:02Z ``` A CSV download returns up to 10,000 rows. If there are more, the response carries an `X-Next-Cursor` header. Send that value as `cursor` to get the next file. Rows are separated by CRLF, as RFC 4180 asks. The file downloads as `SLUG-responses.csv`, where `SLUG` is your form's slug. Some details about the cells: - A `multi_choice` answer sits in one cell, with the choices separated by `; `. - A `checkbox` shows as `true` or `false`. - If you removed a field from the form, answers that were saved under its id still get their own column, before `response_id`. - Answers come from strangers. A text cell that starts with `=`, `+`, `-` or `@` gets an apostrophe in front, so a spreadsheet does not run it as a formula. Phone numbers that start with a plus sign stay as they are. Read columns by name, not by position. Open the file in Excel, Numbers or Google Sheets. See [Form to CSV](/form-to-csv), [Form to Excel](/form-to-excel) and [Form to Google Sheets](/form-to-google-sheets). A browser link or a spreadsheet formula cannot send the `Authorization` header. Download the file with a script or curl, then open it. In the dashboard, the **Download CSV** button on the form page does this for you while you are signed in. The responses table in the dashboard shows the latest 200 responses. The API and the CSV give all of them. ## Page through everything ### JavaScript ```js const BASE = "https://aiformapi.com/v1"; const KEY = process.env.AIFORMAPI_KEY; async function allResponses(formId) { const rows = []; let cursor = null; do { const url = new URL(`${BASE}/forms/${formId}/responses`); url.searchParams.set("format", "json"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } }); if (!res.ok) throw new Error(`${res.status} ${(await res.json()).error?.code}`); const page = await res.json(); rows.push(...page.data); cursor = page.next_cursor; } while (cursor); return rows; } console.log((await allResponses("FORM_ID")).length, "responses"); ``` ### Python ```python import os import requests BASE = "https://aiformapi.com/v1" HEADERS = {"Authorization": f"Bearer {os.environ['AIFORMAPI_KEY']}"} def all_responses(form_id): rows, cursor = [], None while True: params = {"format": "json", "limit": 100} if cursor: params["cursor"] = cursor r = requests.get(f"{BASE}/forms/{form_id}/responses", headers=HEADERS, params=params, timeout=30) r.raise_for_status() page = r.json() rows.extend(page["data"]) cursor = page["next_cursor"] if not cursor: return rows print(len(all_responses("FORM_ID")), "responses") ``` If you page through many rows, watch the limit of 30 requests per minute on the free plan. See [Rate limits and quotas](/docs/rate-limits). ## Get only new responses with since Keep the `submitted_at` of the newest row you have seen. Next time, ask for rows after it. ```bash curl "https://aiformapi.com/v1/forms/FORM_ID/responses?format=json&since=2026-10-30T09:14:02Z" \ -H "Authorization: Bearer $AIFORMAPI_KEY" ``` Polling works, but a webhook is better. It tells you the moment a response arrives and uses none of your request budget. See [Webhooks](/docs/webhooks). ## What source means | Value | How the answer arrived | |---|---| | `human` | A person typed it into the form page. | | `paste` | A person pasted an AI agent's JSON block into the form page, reviewed it and pressed Submit. This is Path B. | | `agent_api` | An agent sent `POST /v1/fill/{token}` over HTTP. This is Path A. | | `agent_mcp` | An agent called `submit_fill` on the fill MCP server. This is Path A. | | `web` | A website posted to `/submit` with a form backend access key. See [Form backend](/docs/form-backend). | Use `source` to see how many of your answers come from agents. See [Agent fill](/docs/agent-fill). ## Quota Responses count against your account for the calendar month (UTC), whatever the source. On the free plan the limit is 100. After that, new submits get `403 quota_exceeded` and the form page says "This form is full for this month". You can still read the responses you already have. ## Delete Deleting a form with `DELETE /v1/forms/{id}` also deletes its responses. Download a CSV first if you need a copy. ## Agent fill: how a visitor hands your form to their own AI agent Source: https://aiformapi.com/docs/agent-fill Every public form page can show a card: "Want your AI agent to fill this?". The visitor copies one prompt. Their agent asks for missing details. It shows the answers. After a yes, it submits. Agents with tools use Path A. Plain chats use Path B. ## Why two paths Many people use Claude or ChatGPT in a plain chat. A plain chat cannot send a POST request. So Path B is a full path and not a fallback. The agent answers with a JSON block. The person pastes it into the form page. The page checks it, fills the fields and waits for the person to press Submit. | | Path A | Path B | |---|---|---| | Agent needs | HTTP requests, or the fill connector | Nothing. A plain chat is enough. | | Who submits | The agent, after the person says yes | The person, on the form page | | Response `source` | `agent_api` or `agent_mcp` | `paste` | | Works in | Claude Code, Cursor, Claude Desktop, n8n, and Claude.ai or ChatGPT once the fill connector URL is added | Any chat that can return text | ## What the person sees 1. They open your form at `https://aiformapi.com/f/your-slug`. 2. At the top is a card: **Want your AI agent to fill this?** They press it. 3. The page asks the server for a fill token. A modal opens with four tabs. - **Copy prompt**: one button that copies the full prompt. - **Agents with tools**: the POST request and a curl example. - **Connector**: the fill connector URL, `https://aiformapi.com/mcp/fill`, with a one-line setup. - **Paste your agent's answer**: a box for Path B. 4. They give the prompt to their agent and answer its questions. 5. The agent shows all answers and asks "Send it?". Only a yes sends anything. Nothing about the person's chat comes to us. We only see the final answers. ## The exact prompt The server fills this template when it mints the token. The same text appears in the `prompt` field of the response to `POST /v1/public/forms/{slug}/fill-tokens`. ````text You are helping me fill in a form. Please do it carefully. Form: "{title}" {description} Sent through aiformapi.com · form id {form_id} Questions: 1. [{field.id}] {label} — {type description}{, one of: options}{, required | optional}{, limits} ... Rules: - Use what you already know about me from this chat only when you are sure. Ask me for anything missing, one short question at a time. - Never invent personal details. Leave out optional questions I skip. - Before you submit, show me all answers and ask "Send it?". Submit only after I say yes. How to submit (use the first option you can): A) If you can make HTTP requests, or you have the aiformapi fill connector ({site}/mcp/fill): POST {site}/v1/fill/{token} Content-Type: application/json {"answers": {: , ...}} 201 = done: tell me the receipt. 422 = fix the listed fields and send again (max 5 tries). 429 = wait Retry-After seconds. B) Otherwise reply with ONLY this block, filled in, so I can paste it back into the form: ```json {"form": "{form_id}", "answers": { ...every field id with an empty value... }} ``` This code expires at {expires_at} UTC. ```` Each question line is built from the field: the id in square brackets, the label, the type in words, the options in quotes, `required` or `optional`, then limits such as `max 500 characters` or `from 0 to 5`. For a multiple-choice field the text says `one or more of:`. A field's `help` text is added at the end of its line after `Note:`. The expiry time is written as `YYYY-MM-DD HH:MM UTC`. Here is a rendered example for a small form. In the Path B block, every field id appears with an empty value that fits its type: an empty string for text, `null` for numbers and ratings, `[]` for multiple choice and `false` for a checkbox. ````text You are helping me fill in a form. Please do it carefully. Form: "Friday dinner RSVP" Tell us if you can come. It takes 20 seconds. Sent through aiformapi.com · form id frm_4k9QeV2mTc8Xa1 Questions: 1. [full_name] Your name — short text, required, max 500 characters 2. [attending] Can you come? — single choice, one of: "Yes", "No", "Maybe", required 3. [guests] How many people are you bringing? — number, optional, from 0 to 5 Rules: - Use what you already know about me from this chat only when you are sure. Ask me for anything missing, one short question at a time. - Never invent personal details. Leave out optional questions I skip. - Before you submit, show me all answers and ask "Send it?". Submit only after I say yes. How to submit (use the first option you can): A) If you can make HTTP requests, or you have the aiformapi fill connector (https://aiformapi.com/mcp/fill): POST https://aiformapi.com/v1/fill/fil_8Xk2mQ9vTz4LbN7cRp1WdYs5HgJf3aEu Content-Type: application/json {"answers": {: , ...}} 201 = done: tell me the receipt. 422 = fix the listed fields and send again (max 5 tries). 429 = wait Retry-After seconds. B) Otherwise reply with ONLY this block, filled in, so I can paste it back into the form: ```json {"form": "frm_4k9QeV2mTc8Xa1", "answers": {"full_name": "", "attending": "", "guests": null}} ``` This code expires at 2026-10-30 10:14 UTC. ```` ## Path A: the agent has tools The agent submits with one request: ```bash curl -X POST https://aiformapi.com/v1/fill/fil_8Xk2mQ9vTz4LbN7cRp1WdYs5HgJf3aEu \ -H "Content-Type: application/json" \ -d '{"answers": {"full_name": "Ada Lovelace", "attending": "Yes", "guests": 1}}' ``` `201` returns a receipt: ```json {"response_id": "rsp_9Zt3mWq7Lx2KbN", "receipt": "AF-7K3Q-9XMD", "submitted_at": "2026-10-30T09:14:02Z"} ``` The agent can also use the fill MCP server instead of raw HTTP. It has two tools: `get_fill_form` and `submit_fill`. Setup for Claude.ai, ChatGPT and other clients is in [MCP](/docs/mcp). All status codes are in [Fill API](/docs/fill-api). ## Path B: plain chat, paste it back The agent replies with only a JSON block: ```json { "form": "frm_4k9QeV2mTc8Xa1", "answers": { "full_name": "Ada Lovelace", "attending": "Yes", "guests": 1 } } ``` The person copies it and pastes it into **Paste your agent's answer**. The page then: 1. Finds the first fenced block that holds JSON in the text, or the first top-level `{...}` object. It also copes with curly quotes that some chat apps add. 2. Checks that `form` matches this form's id. 3. Checks `answers` with the same validator the API uses. 4. Fills the fields and highlights any problem. 5. Stops. It never submits for the person. The person reads the form and presses **Submit**. This is why a response from Path B has the source `paste`. ## The agent line: one short line, no token The card above makes a fresh token for each visitor. That fits a visitor on your form page. The agent line is one fixed sentence. A visitor can paste it into any AI chat, even from your own site or an email. It is on by default for new forms. 1. Make a form. The first thing you see on the form page is the line, with a Copy button. 2. Copy the line and put it above your form: `Fill this form for me: https://aiformapi.com/a/your-slug` 3. A visitor pastes it into their AI chat. The AI opens the address. It gets a plain text page: the questions and two ready ways to send. 4. The AI fills what it knows. It asks the person for the rest in one message. It shows all answers and waits for a yes. 5. **Option A, the AI can send requests.** It sends one POST of JSON to `/v1/agent/your-slug`. The response `source` is `agent_api`. 6. **Option B, the AI can only open links.** It gives the person a link to your form page with the answers after the `#` sign: `https://aiformapi.com/f/your-slug#fill=`. The form page fills itself. The person checks it and presses Send. The part after `#` stays in the browser until the person presses Send. The page removes it from the address bar when it loads. The response `source` is `paste`. An AI that cannot open links cannot read the page. It tells the person. Good uses: forms where the answers are facts about the person, such as job applications, supplier and project intake, event and membership forms, and bookings. Do not use it for surveys, quizzes or NPS. An AI answer is not the person's opinion. Forms cannot ask for passwords, card numbers or ID numbers, and an agent never fills them. A form backend has a line too. It shows once the backend has received a submission, because the page lists the field names it has seen. The address ends in the access key: `https://aiformapi.com/a/fbk_...`. The AI sends one POST of JSON to `/submit`. ### How the line is protected There is no token and no human check on this path, so other limits apply: - **Your switch.** `settings.agent_line` must be `true` and `settings.agent_fill` must be `true`. Default for `agent_line`: `true` for new forms. A form made before 2026-11-06 keeps its stored value, which is off. - **Rate limits.** `GET`: 30 per minute per IP. `POST`: 5 per minute and 30 per hour per IP, and 30 per minute and 300 per hour per form. In production the endpoint answers `503 rate_limiter_unavailable` when the limiter is missing. - **Quota.** Each response counts toward your monthly quota like any other. A full form answers `403 quota_exceeded`. - **Blocked fields.** The same rules as for every form. - **Duplicate guard.** The same answers from the same network within 60 minutes return `200` with the first receipt and `"duplicate": true`. Nothing new is stored. - **Confirm first.** The page tells the agent to show the answers and get a yes before it sends. - **Read only.** `GET /a/{slug}` never stores anything. - **Optional secret code.** Send `settings.agent_code` (6 to 64 letters, digits, `-` or `_`) with a PATCH. We store a hash and never return the code. The line then ends the address with `?c=YOUR_CODE`, and a request without the right code gets `403`. Send `null` to remove it. In the dashboard it sits under **More options**. ### Endpoints `GET https://aiformapi.com/a/{slug}` returns plain text: the questions, the POST address and the review link. It stores nothing. `GET https://aiformapi.com/v1/agent/{slug}` returns the same facts as JSON: the form, the instructions, the submit address and the review link template. `POST https://aiformapi.com/v1/agent/{slug}` takes `{"answers": {...}}` and returns `201 {"response_id", "receipt", "submitted_at"}`, or `200` with `"duplicate": true`. Errors: `422 invalid_answers`, `403 forbidden` (line off, wrong code), `409 form_closed`, `403 quota_exceeded`, `429`, `503`. ## Token rules | Rule | Value | |---|---| | Lifetime | 60 minutes | | Successful uses | 1 | | POST attempts | 5 at most. The next one gets `410 token_expired`. | | `GET /v1/fill/{token}` | 30 per hour | | Mint a token, per IP | 5 per minute and 30 per day | | Mint a token, per form | 200 per day | | POST, per IP | 10 per minute | A token belongs to one form. Treat it like a secret for an hour. When it expires or is used, the person presses the card again to get a new one. Details are in [Rate limits and quotas](/docs/rate-limits). ## Turn it off Agent fill is a form setting, and it is on by default. Set `agent_fill` to `false`: ```bash curl -X PATCH https://aiformapi.com/v1/forms/FORM_ID \ -H "Authorization: Bearer $AIFORMAPI_KEY" \ -H "Content-Type: application/json" \ -d '{"settings": {"agent_fill": false}}' ``` You can also use the **agent fill** switch on the form page in the dashboard. People can still fill the form by hand. Turn it back on the same way. ## Be honest with yourself about agents We cannot control someone else's agent. The prompt asks it to ask first, never invent details and wait for a yes. A good agent follows that. Some will not. That is why Path B never submits on its own, and why every response carries a `source`. See [Responses](/docs/responses). Agent answers count toward your monthly response quota in the same way as human answers. ## Related - [Fill API](/docs/fill-api): status codes and examples. - [MCP](/docs/mcp): the fill connector. - [agent-fill.js](/docs/agent-fill-js): the same idea for forms on any website. - Read why this matters on [agent-fillable forms](/agent-fillable-forms). ## Fill API: submit answers to a form with a fill token Source: https://aiformapi.com/docs/fill-api A fill token lets an agent read one form and send one set of answers. No API key is needed. Use `GET /v1/fill/{token}` to read the form. Use `POST /v1/fill/{token}` to submit. A token works for 60 minutes, one successful submit and 5 attempts. ## Get a token People get a token from the form page. The page calls this public endpoint: ```http POST /v1/public/forms/{slug}/fill-tokens ``` It returns `201`: ```json { "token": "fil_8Xk2mQ9vTz4LbN7cRp1WdYs5HgJf3aEu", "fill_url": "https://aiformapi.com/v1/fill/fil_8Xk2mQ9vTz4LbN7cRp1WdYs5HgJf3aEu", "prompt": "You are helping me fill in a form. ...", "expires_at": "2026-10-30T10:14:00Z", "form_id": "frm_4k9QeV2mTc8Xa1" } ``` A fill token starts with `fil_`. If the owner turned agent fill off, this call returns `403 forbidden` with `details.reason` set to `agent_fill_disabled`. When spam protection is on, the request carries a `turnstile_token`. Tokens are meant for a person who asked their agent to help, so the agent receives the token inside the prompt. See [Agent fill](/docs/agent-fill). ## `GET /v1/fill/{token}` Some agents can only read a URL. This call returns the form and instructions as JSON. A token allows 30 reads per hour. ```bash curl https://aiformapi.com/v1/fill/fil_8Xk2mQ9vTz4LbN7cRp1WdYs5HgJf3aEu ``` ```json { "form": { "id": "frm_4k9QeV2mTc8Xa1", "title": "Friday dinner RSVP", "description": "Tell us if you can come. It takes 20 seconds.", "fields": [ {"id": "full_name", "label": "Your name", "type": "short_text", "required": true}, {"id": "attending", "label": "Can you come?", "type": "single_choice", "required": true, "options": ["Yes", "No", "Maybe"]}, {"id": "guests", "label": "How many people are you bringing?", "type": "number", "min": 0, "max": 5} ] }, "expires_at": "2026-10-30T10:14:00Z", "attempts_left": 5, "instructions": [ "Ask the person for any answer you do not know, one short question at a time. Never invent personal details.", "Before you submit, show all answers and ask \"Send it?\". Submit only after they say yes.", "Then POST {\"answers\": {...}} to https://aiformapi.com/v1/fill/{token}. 201 = done (tell the person the receipt). 422 = fix the listed fields. 429 = wait Retry-After seconds." ], "submit": { "method": "POST", "url": "https://aiformapi.com/v1/fill/fil_8Xk2mQ9vTz4LbN7cRp1WdYs5HgJf3aEu", "content_type": "application/json", "body_example": {"answers": {"full_name": "", "attending": "", "guests": null}} } } ``` Reading the form does not use up the token. ## `POST /v1/fill/{token}` Send the answers as an object. Each key is a field id. ```bash curl -X POST https://aiformapi.com/v1/fill/fil_8Xk2mQ9vTz4LbN7cRp1WdYs5HgJf3aEu \ -H "Content-Type: application/json" \ -d '{"answers": {"full_name": "Ada Lovelace", "attending": "Yes", "guests": 1}}' ``` Rules for `answers`: - Use the field id as the key. - Send the right JSON type. A `number` is a number, a `checkbox` is `true` or `false`, a `multi_choice` is a list of strings, a `date` is `YYYY-MM-DD`. - A `single_choice` value must match one option exactly. - Leave out optional questions the person skipped. An empty string, `null` or an empty list also counts as skipped. - Include every required field. A required checkbox must be `true`. - Send only ids that exist in the form. An unknown id is reported as a problem. Field types are in [Forms and fields](/docs/forms-and-fields). The body can be 64 KB at most. ## Responses ### 201 Created The answers are stored. Tell the person the receipt. ```json { "response_id": "rsp_9Zt3mWq7Lx2KbN", "receipt": "AF-7K3Q-9XMD", "submitted_at": "2026-10-30T09:14:02Z" } ``` The response has the source `agent_api`. ### 422 invalid_answers Some answers do not pass. Nothing is stored. Fix the listed fields and send again. You have 5 attempts in total. ```json { "error": { "code": "invalid_answers", "message": "Some answers need a fix.", "details": { "fields": [ {"id": "guests", "problem": "must be at most 5"}, {"id": "attending", "problem": "must be one of: Yes, No, Maybe"} ], "attempts_left": 4 } } } ``` ### 410 token_expired or token_used The token is older than 60 minutes or has used its 5 attempts (`token_expired`), or it already stored a response (`token_used`). Ask the person to press the card on the form page and copy a new prompt. ```json {"error": {"code": "token_expired", "message": "This fill code has expired. Ask for a new one."}} ``` ### 429 rate_limited You sent too many requests. Wait the number of seconds in the `Retry-After` header, then try again. ```http HTTP/1.1 429 Too Many Requests Retry-After: 12 RateLimit-Limit: 10 RateLimit-Remaining: 0 RateLimit-Reset: 12 ``` ```json {"error": {"code": "rate_limited", "message": "Too many requests. Try again in 12 seconds."}} ``` The limit is 10 POST requests per minute for each IP address. ### Other errors | Code | Meaning | |---|---| | `form_closed` (409) | The owner closed the form, or it reached its closing time or response cap. Nothing can be sent. | | `quota_exceeded` (403) | The form owner reached the monthly response limit. | | `payload_too_large` | The body is over 64 KB. | | `not_found` (404) | No such token, or the token is not in the right format. | | `rate_limiter_unavailable` | We could not check the limit and refuse to guess. Try again later. | See [Errors](/docs/errors) for the full list. ## Complete examples ### JavaScript ```js const TOKEN = process.env.FILL_TOKEN; // from the prompt const ENDPOINT = `https://aiformapi.com/v1/fill/${TOKEN}`; async function submit(answers) { const res = await fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ answers }), }); if (res.status === 201) return res.json(); // { response_id, receipt, submitted_at } const body = await res.json(); const code = body.error?.code; if (res.status === 429) { const wait = Number(res.headers.get("Retry-After") || 5); await new Promise((r) => setTimeout(r, wait * 1000)); return submit(answers); } if (res.status === 422) { // Show body.error.details.fields to the person, fix the answers, then call submit() again. // A token allows 5 attempts. throw new Error("Fix these fields: " + JSON.stringify(body.error.details.fields)); } if (res.status === 410) throw new Error(`${code}: ask for a new fill code`); throw new Error(`${res.status} ${code}`); } const receipt = await submit({ full_name: "Ada Lovelace", attending: "Yes", guests: 1 }); console.log("Done. Receipt:", receipt.receipt); ``` ### Python ```python import os import time import requests TOKEN = os.environ["FILL_TOKEN"] # from the prompt ENDPOINT = f"https://aiformapi.com/v1/fill/{TOKEN}" def submit(answers): while True: r = requests.post(ENDPOINT, json={"answers": answers}, timeout=30) if r.status_code == 201: return r.json() # response_id, receipt, submitted_at if r.status_code == 429: time.sleep(int(r.headers.get("Retry-After", "5"))) continue error = r.json().get("error", {}) if r.status_code == 422: # Show these to the person, fix the answers, call submit() again (5 attempts in total). raise ValueError(f"Fix these fields: {error['details']['fields']}") if r.status_code == 410: raise RuntimeError(f"{error.get('code')}: ask for a new fill code") raise RuntimeError(f"{r.status_code} {error.get('code')}") receipt = submit({"full_name": "Ada Lovelace", "attending": "Yes", "guests": 1}) print("Done. Receipt:", receipt["receipt"]) ``` ## Use the fill MCP instead If the agent has the fill connector, it does not need HTTP. The tools `get_fill_form` and `submit_fill` apply the same validation and the same token rules. See [MCP](/docs/mcp). ## MCP server setup for AI Form API Source: https://aiformapi.com/docs/mcp AI Form API has 2 MCP servers. The creator server at `https://aiformapi.com/mcp` creates forms and reads responses. It needs your API key in a header. The fill server at `https://aiformapi.com/mcp/fill` needs no key. It lets an agent fill in a form for a person. Both use Streamable HTTP. | Server | URL | Auth | For | |---|---|---|---| | Creator | `https://aiformapi.com/mcp` | `Authorization: Bearer afk_...` | You, the form owner | | Fill | `https://aiformapi.com/mcp/fill` | None. A fill token is an argument. | The person filling a form | ## Creator server ### Tools The tools return rows and form definitions. They do not summarise. Your own model reads the rows and writes the summary. | Tool | Arguments | Does the same as | |---|---|---| | `create_form` | `title`, `description` (optional), `fields`, `settings` (optional) | `POST /v1/forms` | | `list_forms` | `limit` (optional), `cursor` (optional) | `GET /v1/forms` | | `get_form` | `form_id` | `GET /v1/forms/{id}` | | `update_form` | `form_id`, and any of `title`, `description`, `fields`, `settings` | `PATCH /v1/forms/{id}` | | `close_form` | `form_id` | `POST /v1/forms/{id}/close` | | `reopen_form` | `form_id` | `POST /v1/forms/{id}/reopen` | | `get_responses` | `form_id`, and any of `format`, `since`, `limit`, `cursor`, `order` | `GET /v1/forms/{id}/responses` | | `add_webhook` | `form_id`, `url` | `POST /v1/forms/{id}/webhooks` | | `get_usage` | none | `GET /v1/usage` | The arguments are the same as the body or query of the REST call, with one difference: the form id is the argument `form_id`, not a path part. `format` is `json` (the default) or `csv`, and `order` is `asc` or `desc`. In `update_form`, `fields` replaces the whole list and `settings` merges key by key. A tool returns its result as JSON text. A list tool returns `data` and `next_cursor`. `get_responses` with `format` set to `csv` returns the CSV text, with a last line `# next_cursor: ...` when there are more rows. A failed call comes back as a tool error that holds the same JSON error body as the REST API, so the `code` is the one listed in [Errors](/docs/errors). There is no tool to delete a form. Use the API or the dashboard for that. Calls through MCP count against the same limit as REST calls for your key: 30 per minute and 1,000 per day on the free plan. See [Rate limits and quotas](/docs/rate-limits). ### Which apps work today | App | Creator server | Fill server | |---|---|---| | Claude Code | Yes, with a header | Yes | | Cursor | Yes, with a header | Yes | | VS Code | Yes, with a header | Yes | | Claude Desktop | Yes, through `mcp-remote` with a header | Yes, as a custom connector | | n8n | Yes, with a header | Yes | | Claude.ai | Not yet. It needs OAuth, which is planned. | Yes, as a custom connector | | ChatGPT | Not yet. It needs OAuth, which is planned. | Yes, as a custom connector | ### Claude Code ```bash claude mcp add --transport http aiformapi https://aiformapi.com/mcp \ --header "Authorization: Bearer $AIFORMAPI_KEY" ``` This stores your key in your local Claude Code config. To keep the key out of any file you share, use a project `.mcp.json` that reads an environment variable: ```json { "mcpServers": { "aiformapi": { "type": "http", "url": "https://aiformapi.com/mcp", "headers": { "Authorization": "Bearer ${AIFORMAPI_KEY}" } } } } ``` Run `/mcp` inside Claude Code to see the connection. A wrong key shows as failed with status 401. Then ask: "List my AI Form API forms." ### Cursor Put this in `.cursor/mcp.json` for one project, or `~/.cursor/mcp.json` for all projects. Set `AIFORMAPI_KEY` in your shell profile first. ```json { "mcpServers": { "aiformapi": { "url": "https://aiformapi.com/mcp", "headers": { "Authorization": "Bearer ${env:AIFORMAPI_KEY}" } } } } ``` ### VS Code Put this in `.vscode/mcp.json`, or run **MCP: Open User Configuration** for a profile-wide file. VS Code uses the key `servers`, not `mcpServers`. It asks for the key the first time the server starts and stores it. ```json { "inputs": [ { "type": "promptString", "id": "aiformapi-key", "description": "AI Form API key", "password": true } ], "servers": { "aiformapi": { "type": "http", "url": "https://aiformapi.com/mcp", "headers": { "Authorization": "Bearer ${input:aiformapi-key}" } } } } ``` ### Claude Desktop (through mcp-remote) Claude Desktop reads local servers from `claude_desktop_config.json`. The `mcp-remote` package turns a remote server with a header into a local one. You need Node.js installed. 1. Open the file. On macOS it is `~/Library/Application Support/Claude/claude_desktop_config.json`. On Windows it is `%APPDATA%\Claude\claude_desktop_config.json`. In Claude Desktop you can reach it through Settings, Developer, Edit Config. 2. Add the server. Keep the space inside the environment variable, not inside `args`, so it works on Windows too. ```json { "mcpServers": { "aiformapi": { "command": "npx", "args": [ "-y", "mcp-remote", "https://aiformapi.com/mcp", "--header", "Authorization:${AIFORMAPI_AUTH}" ], "env": { "AIFORMAPI_AUTH": "Bearer afk_your_key_here" } } } } ``` 3. Quit Claude Desktop completely and open it again. This file holds your key in plain text. Do not share it or commit it. ### n8n Use the **MCP Client Tool** node and connect it to an AI Agent node. 1. Set the endpoint to `https://aiformapi.com/mcp`. 2. Set the transport to HTTP Streamable. 3. Under Authentication, choose Bearer and paste your key. If you use a header credential instead, name the header `Authorization` and set the value to `Bearer afk_your_key_here`. 4. Under Tools to Include, pick all tools or only the ones you need. ## Fill server The fill server has no login. It has 2 tools. | Tool | Arguments | Does | |---|---|---| | `get_fill_form` | `fill_token` | Returns the form and instructions. Same as `GET /v1/fill/{token}`. | | `submit_fill` | `fill_token`, `answers` | Stores the answers. Same as `POST /v1/fill/{token}`. | `fill_token` is the code from the prompt, which starts with `fil_`. `answers` is an object that maps field ids to values. A successful `submit_fill` returns `response_id`, `receipt` and `submitted_at`. An invalid answer comes back as a tool error with the code `invalid_answers`. The token rules are the same as for HTTP: 60 minutes, 1 successful use, 5 attempts. The server allows 20 calls per minute for each IP address. The person gets the token by pressing the card on the form page, and the agent finds it inside the prompt. See [Agent fill](/docs/agent-fill) and [Fill API](/docs/fill-api). ### Claude.ai and Claude Desktop Claude adds remote servers as custom connectors. 1. Go to Customize, then Connectors. 2. Press **+ Add**, then **Add custom connector**. 3. Enter a name, for example `AI Form API fill`. 4. Enter the URL `https://aiformapi.com/mcp/fill`. 5. Choose **No sign in**, because this server has no login. 6. Press **Add**. 7. In a chat, press **+**, then **Connectors**, and switch the connector on for that chat. Anthropic's page says custom connectors are available on Free, Pro, Max, Team and Enterprise plans, that Free plans are limited to one custom connector, and that only an owner can add one on Team and Enterprise plans. ### ChatGPT ChatGPT adds remote servers in developer mode. Plain MCP servers with no sign in are allowed. 1. Turn on developer mode in ChatGPT settings. 2. Create a connector. 3. Enter a name and the URL `https://aiformapi.com/mcp/fill`. 4. Choose **No authentication**. 5. Confirm that you trust the connector and create it. 6. In a new chat, enable the connector from the **+** menu. ### Claude Code, Cursor, VS Code and n8n Add the fill server the same way as the creator server, without the header. ```bash claude mcp add --transport http aiformapi-fill https://aiformapi.com/mcp/fill ``` ```json { "mcpServers": { "aiformapi-fill": { "url": "https://aiformapi.com/mcp/fill" } } } ``` ## Try it After you connect the creator server, say this to your agent: > Make an RSVP form for Friday's dinner. Ask for name, whether they can come (yes, no, maybe) and how many guests. Give me the link. The agent calls `create_form` and replies with the `public_url`. Later you can ask: "Who said yes?" The agent calls `get_responses` and reads the rows. ## Troubleshooting | What you see | What to do | |---|---| | 401 or "failed" in the client | The key is wrong or has a typo. Copy it again or rotate it in the dashboard. | | `rate_limited` | Wait for the `Retry-After` seconds. The creator server shares the 30 per minute limit with REST. | | The tool list is empty in Claude Desktop | Quit the app fully and reopen it. Check that Node.js is installed and that the JSON is valid. | | `blocked_field` from `create_form` | A field asks for a secret such as a password or card number. See [Forms and fields](/docs/forms-and-fields). | ## Sources (read 2026-10-30) - [Claude Code MCP documentation](https://code.claude.com/docs/en/mcp) - [Cursor MCP documentation](https://cursor.com/docs/context/mcp) - [VS Code MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration) - [Model Context Protocol: connect Claude Desktop to servers](https://modelcontextprotocol.io/docs/develop/connect-local-servers) - [mcp-remote on GitHub](https://github.com/geelen/mcp-remote) - [Anthropic: custom connectors using remote MCP](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) - [n8n MCP Client Tool node](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp/) - ChatGPT (third-party guides, not OpenAI): [Speakeasy](https://www.speakeasy.com/docs/mcp/build/integrate/clients/using-chatgpt-developer-mode-with-gram), [Dataddo](https://docs.dataddo.com/docs/chatgpt) ## Form webhooks: a signed POST for every new response Source: https://aiformapi.com/docs/webhooks Add a webhook URL to a form. We send it a JSON POST each time a response is stored. The request carries an `X-AIFormAPI-Signature` header. Recompute it with your webhook secret. That proves the call came from us. This page has the payload and checks in Node and Python. ## Add a webhook ```bash curl -X POST https://aiformapi.com/v1/forms/FORM_ID/webhooks \ -H "Authorization: Bearer $AIFORMAPI_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/hooks/aiformapi"}' ``` The URL must start with `https://` and point to a public host. Each form can have up to 3 webhooks. The reply holds the webhook `id` and its `secret`: ```json { "id": "whk_3Lp8TxN2cV5mQa", "url": "https://example.com/hooks/aiformapi", "secret": "whsec_THE_SECRET_SHOWN_ONLY_ONCE", "created_at": "2026-10-30T09:00:00Z" } ``` The secret is shown once. Store it in an environment variable now. If you lose it, delete the webhook and add a new one. We keep the secret in our database as plain text, because we need it to sign each call. No API call returns it again. Remove a webhook: ```bash curl -X DELETE https://aiformapi.com/v1/forms/FORM_ID/webhooks/WEBHOOK_ID \ -H "Authorization: Bearer $AIFORMAPI_KEY" ``` You can also add webhooks on the form page in the dashboard, and your agent can call the `add_webhook` tool. See [MCP](/docs/mcp). ## The request we send We send one POST for each stored response, whatever its source. Human, pasted and agent answers all trigger it. ```http POST /hooks/aiformapi HTTP/1.1 Content-Type: application/json X-AIFormAPI-Event: response.created X-AIFormAPI-Signature: t=1761815642,v1=5c1d0f4a8e7b... ``` ```json { "event": "response.created", "form_id": "frm_4k9QeV2mTc8Xa1", "response": { "id": "rsp_9Zt3mWq7Lx2KbN", "form_id": "frm_4k9QeV2mTc8Xa1", "source": "agent_api", "submitted_at": "2026-10-30T09:14:02Z", "answers": { "full_name": "Ada Lovelace", "attending": "Yes", "guests": 1 } } } ``` ## How we sign it The header has 2 parts: `t` is a Unix time in seconds, and `v1` is a hex HMAC-SHA256. ``` X-AIFormAPI-Signature: t=,v1= ``` The signed text is the value of `t`, a dot, and the exact raw request body. Use the raw bytes. If you parse the JSON and write it back, the text changes and the check fails. ## Verify in Node.js This example uses Express. It reads the raw body, checks the age of the call and compares the signature in constant time. ```js import express from "express"; import crypto from "node:crypto"; const SECRET = process.env.AIFORMAPI_WEBHOOK_SECRET; const TOLERANCE_SECONDS = 300; // reject calls older than 5 minutes function verifySignature(rawBody, header, secret) { if (!header) return false; const parts = {}; for (const piece of header.split(",")) { const i = piece.indexOf("="); if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim(); } const { t, v1 } = parts; if (!t || !v1) return false; const age = Math.abs(Date.now() / 1000 - Number(t)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false; const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(v1, "utf8"); return a.length === b.length && crypto.timingSafeEqual(a, b); } const app = express(); app.post("/hooks/aiformapi", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); if (!verifySignature(rawBody, req.get("X-AIFormAPI-Signature"), SECRET)) { return res.status(401).send("bad signature"); } const event = JSON.parse(rawBody); if (event.event === "response.created") { console.log("New response", event.response.id, event.response.answers); // Save it, send a message, update a sheet. Keep this fast. } res.sendStatus(200); }); app.listen(3000); ``` ## Verify in Python This example uses Flask. `request.get_data()` returns the raw bytes. ```python import hashlib import hmac import os import time from flask import Flask, abort, request SECRET = os.environ["AIFORMAPI_WEBHOOK_SECRET"].encode() TOLERANCE_SECONDS = 300 # reject calls older than 5 minutes app = Flask(__name__) def verify_signature(raw_body: bytes, header) -> bool: if not header: return False parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p) t, v1 = parts.get("t"), parts.get("v1") if not t or not v1: return False try: age = abs(time.time() - int(t)) except ValueError: return False if age > TOLERANCE_SECONDS: return False signed = t.encode() + b"." + raw_body expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) @app.post("/hooks/aiformapi") def hook(): raw = request.get_data() if not verify_signature(raw, request.headers.get("X-AIFormAPI-Signature")): abort(401) event = request.get_json(force=True) if event["event"] == "response.created": response = event["response"] print("New response", response["id"], response["answers"]) # Save it, send a message, update a sheet. Keep this fast. return "", 200 ``` ## Test your handler without a real response This script signs a sample payload with your secret and posts it to your local server. If your handler returns 200, your check works. Change the secret and it should return 401. ```python import hashlib import hmac import json import os import time import requests secret = os.environ["AIFORMAPI_WEBHOOK_SECRET"].encode() body = json.dumps( { "event": "response.created", "form_id": "frm_test", "response": { "id": "rsp_test", "form_id": "frm_test", "source": "human", "submitted_at": "2026-10-30T09:14:02Z", "answers": {"full_name": "Test Person"}, }, } ) t = str(int(time.time())) sig = hmac.new(secret, f"{t}.{body}".encode(), hashlib.sha256).hexdigest() r = requests.post( "http://localhost:3000/hooks/aiformapi", data=body, headers={"Content-Type": "application/json", "X-AIFormAPI-Signature": f"t={t},v1={sig}"}, timeout=10, ) print(r.status_code) ``` ## Delivery rules - We call your URL after the response is already stored and the person has their confirmation. A slow webhook never slows down a form. - Your server has 5 seconds to answer with a 2xx status. - We do not follow redirects. Give us the final URL. - If the call fails, we try 1 more time. - We keep the last status, error and time of the delivery for each webhook. - Answer fast with a 2xx status and do slow work afterward in a queue or background job. - A retry can deliver the same response twice. Use `response.id` to skip duplicates. - A webhook is the best way to follow new answers. It uses none of your 30 requests per minute. See [Rate limits and quotas](/docs/rate-limits). ## Checklist 1. Read the raw body, not the parsed JSON. 2. Split the header on commas, then on the first equals sign. 3. Reject if `t` is too old. 4. Compute `HMAC-SHA256(secret, t + "." + body)` as hex. 5. Compare in constant time. 6. Return 200 quickly. ## Rate limits and quotas: every number on the free plan Source: https://aiformapi.com/docs/rate-limits The free plan allows 30 requests per minute and 1,000 per day for each API key. It allows 3 open forms and 100 responses a month. Public pages and fill endpoints have their own limits by IP address. Limited responses carry `RateLimit-*` headers. A 429 adds `Retry-After`. A **rate limit** slows you down for a short time. You get `429 rate_limited`, wait, and try again. A **quota** is a monthly or plan allowance. When you hit it you get `403 quota_exceeded`, and waiting a few seconds does not help. ## The full table | Scope | Free plan | |---|---| | API key (REST and creator MCP together) | 30 requests per minute, 1,000 per day | | Open forms per account | 3 | | Responses per account per calendar month (UTC), all sources | 100. After that, new submits get `403 quota_exceeded` and the form page says "This form is full for this month". | | Public form schema, `GET /v1/public/forms/{slug}`, per IP | 60 per minute | | Human submit, per IP | 5 per minute and 30 per hour | | Human submit, per form | 60 per minute | | Form backend `POST /submit`, per IP | 5 per minute and 30 per hour | | Form backend `POST /submit`, per form | 60 per minute | | Fill token mint, per IP | 5 per minute and 30 per day | | Fill token mint, per form | 200 per day | | One fill token | 1 successful use, 60 minutes lifetime, 5 POST attempts, 30 GET reads per hour | | `POST /v1/fill/{token}`, per IP | 10 per minute | | Agent line `GET /v1/agent/{slug}`, per IP and per form | 30 per minute per IP, 120 per minute per form | | Agent line `POST /v1/agent/{slug}`, per IP | 5 per minute and 30 per hour | | Agent line `POST /v1/agent/{slug}`, per form | 30 per minute and 300 per hour | | Fill MCP calls, per IP | 20 per minute | | Signup, per IP | 3 per hour | | Signup, per email address | 3 per day | | Abuse report form, per IP | 5 per hour | | Webhooks per form | 3 | | Request body size | 64 KB | ## Pro plan (coming soon) Pro costs $19 per month. It is not on sale yet and there is no billing. It changes the plan limits only. Every other row in the table above stays the same. | Scope | Free plan | Pro plan | |---|---|---| | API key (REST and creator MCP together) | 30 per minute, 1,000 per day | 120 per minute, 20,000 per day | | Open forms per account | 3 | Unlimited | | Responses per account per calendar month (UTC), all sources | 100 | 10,000 | See [Pricing](/pricing). We give 30 days notice by email before a plan change. We store IP addresses only as a salted SHA-256 hash. The limits above work on that hash. ## Headers Every response from a rate-limited endpoint has 3 headers: | Header | Meaning | |---|---| | `RateLimit-Limit` | How many requests the current window allows. | | `RateLimit-Remaining` | How many requests you have left in this window. | | `RateLimit-Reset` | Seconds until the window resets. | A `429` adds one more: | Header | Meaning | |---|---| | `Retry-After` | Seconds to wait before you send the request again. | ```http HTTP/1.1 429 Too Many Requests Content-Type: application/json RateLimit-Limit: 30 RateLimit-Remaining: 0 RateLimit-Reset: 41 Retry-After: 41 {"error": {"code": "rate_limited", "message": "Too many requests. Try again in 41 seconds."}} ``` Check your usage at any time: ```bash curl https://aiformapi.com/v1/usage -H "Authorization: Bearer $AIFORMAPI_KEY" ``` `GET /v1/usage` returns your plan, your limits and your current usage. ```json { "plan": "free", "limits": { "requests_per_minute": 30, "requests_per_day": 1000, "open_forms": 3, "responses_per_month": 100 }, "usage": { "open_forms": 1, "responses_this_month": 12, "month": "2026-10", "resets_at": "2026-11-01T00:00:00.000Z" } } ``` ## How to back off 1. Read the `Retry-After` header on a 429 and wait that many seconds. 2. If there is no header, wait 1 second, then 2, then 4, up to a ceiling. Add a little random time so many clients do not retry at the same moment. 3. Stop after a few tries and report the error. 4. Watch `RateLimit-Remaining`. Slow down before it reaches 0. 5. Replace polling with a [webhook](/docs/webhooks). A webhook costs you no requests. Do not retry `403 quota_exceeded`, `422` errors or `410` errors. The same request will fail again. If you see `503 rate_limiter_unavailable`, we could not check the limit and chose to refuse the request rather than guess. Wait a moment and try again. ## Retry code ### JavaScript ```js async function apiWithRetry(url, options = {}, maxTries = 5) { for (let attempt = 1; attempt <= maxTries; attempt++) { const res = await fetch(url, options); if (res.status !== 429 && res.status !== 503) return res; if (attempt === maxTries) return res; const header = Number(res.headers.get("Retry-After")); const backoff = Number.isFinite(header) && header > 0 ? header : Math.min(2 ** (attempt - 1), 30) + Math.random(); await new Promise((resolve) => setTimeout(resolve, backoff * 1000)); } } const res = await apiWithRetry("https://aiformapi.com/v1/forms", { headers: { Authorization: `Bearer ${process.env.AIFORMAPI_KEY}` }, }); console.log(res.status, res.headers.get("RateLimit-Remaining")); ``` ### Python ```python import os import random import time import requests def api_with_retry(method, url, max_tries=5, **kwargs): headers = kwargs.pop("headers", {}) headers["Authorization"] = f"Bearer {os.environ['AIFORMAPI_KEY']}" for attempt in range(1, max_tries + 1): r = requests.request(method, url, headers=headers, timeout=30, **kwargs) if r.status_code not in (429, 503) or attempt == max_tries: return r retry_after = r.headers.get("Retry-After") if retry_after and retry_after.isdigit(): wait = int(retry_after) else: wait = min(2 ** (attempt - 1), 30) + random.random() time.sleep(wait) r = api_with_retry("GET", "https://aiformapi.com/v1/forms") print(r.status_code, r.headers.get("RateLimit-Remaining")) ``` ## Related - [Errors](/docs/errors) lists every code. - [Agent fill](/docs/agent-fill) explains the token rules. - [Responses](/docs/responses) shows how to fetch only new rows with `since`. ## API error codes: what each one means and how to fix it Source: https://aiformapi.com/docs/errors Every error uses one JSON shape with a stable `code`. Match on the `code`, not the message. The message can change. This page lists all 15 codes with the HTTP status, cause and fix. ## The error shape ```json { "error": { "code": "invalid_answers", "message": "Some answers need a fix.", "details": { "fields": [{"id": "guests", "problem": "must be at most 5"}] } } } ``` | Property | Meaning | |---|---| | `code` | A stable machine-readable string. Safe to branch on. | | `message` | A sentence for people. It can change, so do not parse it. | | `details` | Optional. Extra data such as the list of fields that failed. | ## All codes | Code | HTTP | Cause | Fix | |---|---|---|---| | `unauthorized` | 401 | The `Authorization` header is missing, malformed or holds an unknown key. | Send `Authorization: Bearer afk_...`. Copy the key again or rotate it. See [Authentication](/docs/authentication). | | `forbidden` | 403 | Your key is valid but may not do this. On public pages it also means the human check (Turnstile) was missing or failed. `details.reason` says which. | Use the key of the account that owns the form. On a public page, reload and complete the check. | | `not_found` | 404 | The form, response, webhook or token does not exist. | Check the id. Remember that deleting a form deletes its responses and webhooks. | | `validation_failed` | 422 | The request body breaks a rule: a bad field id, a duplicate id, a choice field without `options`, more than 50 fields, an unknown property or a wrong type. A bad `cursor` also returns this code with status 400. | Read `details.errors`, fix the request and send it again. See [Forms and fields](/docs/forms-and-fields). | | `blocked_field` | 422 | A field id, label or help text matches a blocked word such as password, PIN, card number, IBAN, passport or national ID. `details.fields` lists the field, where it matched and what matched. | Remove the field. We do not collect secrets. See [Acceptable use](/acceptable-use). | | `invalid_answers` | 422 | The submitted answers do not fit the form. `details.fields` lists each field id and the problem. | Fix the listed fields and send again. A fill token allows 5 attempts. | | `form_closed` | 409 | The owner closed the form, or it reached `close_at` or `max_responses`. `details.reason` says `closed`, `expired` or `limit_reached`. | Owners can reopen it with `POST /v1/forms/{id}/reopen`. Respondents should contact the owner. | | `quota_exceeded` | 403 | A plan allowance is used up: 100 responses this month, 3 open forms, or 3 webhooks on one form. `details` holds the limit. | Wait for the next calendar month (UTC), close a form you no longer need, remove a webhook, or move to a larger plan when one is on sale. | | `rate_limited` | 429 | Too many requests in a short time. `details` holds `limit`, `window_seconds` and `retry_after`. | Wait for `Retry-After` seconds and try again. See [Rate limits and quotas](/docs/rate-limits). | | `token_expired` | 410 | The fill token is older than 60 minutes, or it used up its 5 attempts. | Press the card on the form page and copy a new prompt. | | `token_used` | 410 | The fill token already stored a response. | Copy a new prompt for another response. | | `payload_too_large` | 413 | The request body is over 64 KB. | Send less data. Shorten long text answers. | | `rate_limiter_unavailable` | 503 | We could not check the rate limit, so we refuse the request instead of guessing. | Wait a moment and try again. | | `service_unavailable` | 503 | A service we depend on, such as email for magic links, is not available. | Try again later. If it keeps happening, email hello@aiformapi.com. | | `internal_error` | 500 | Something broke on our side. | Try again. If it keeps happening, email hello@aiformapi.com. | ## Handle errors in code ### JavaScript ```js const res = await fetch("https://aiformapi.com/v1/forms", { method: "POST", headers: { Authorization: `Bearer ${process.env.AIFORMAPI_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(formDefinition), }); if (!res.ok) { const { error } = await res.json(); switch (error.code) { case "unauthorized": throw new Error("Check AIFORMAPI_KEY."); case "blocked_field": case "validation_failed": console.error("Fix the form:", error.details); break; case "quota_exceeded": console.error("Plan limit reached:", error.message); break; case "rate_limited": // Wait for Retry-After, then try again. See the rate limits page. break; default: throw new Error(`${res.status} ${error.code}: ${error.message}`); } } ``` ### Python ```python import os import requests r = requests.post( "https://aiformapi.com/v1/forms", headers={"Authorization": f"Bearer {os.environ['AIFORMAPI_KEY']}"}, json=form_definition, timeout=30, ) if not r.ok: error = r.json()["error"] code = error["code"] if code == "unauthorized": raise SystemExit("Check AIFORMAPI_KEY.") if code in ("blocked_field", "validation_failed"): print("Fix the form:", error.get("details")) elif code == "quota_exceeded": print("Plan limit reached:", error["message"]) elif code == "rate_limited": print("Wait", r.headers.get("Retry-After"), "seconds") else: raise RuntimeError(f"{r.status_code} {code}: {error['message']}") ``` ## Errors on the form page People who fill a form by hand do not see JSON. The page shows plain messages. The closest to an error is "This form is full for this month", which appears when the form owner reached the response quota. The owner can read how to raise it in [Rate limits and quotas](/docs/rate-limits). ## Still stuck? Check the exact paths and field names in [openapi.json](/openapi.json). It describes every endpoint. ## Embed a form on your website with one script tag Source: https://aiformapi.com/docs/embed Paste one script tag where you want the form. Set `data-form` to your form's slug. The script adds an iframe. It keeps the height in step with the form. It works on any page that lets you add HTML. ## The snippet ```html ``` Replace `k3p9x2m7qa` with your form's slug. The slug is the last part of the public link: in `https://aiformapi.com/f/k3p9x2m7qa` it is `k3p9x2m7qa`. The `slug` also comes back when you create a form. The dashboard shows the right snippet on the form page, with a copy button. Use that one if your forms are on a different host later. ## Attributes | Attribute | Needed | What it does | |---|---|---| | `data-form` | Yes | The slug of the form. | | `data-title` | No | The title of the iframe, which screen readers read out. The default is `Form`. | | `data-origin` | No | The address that serves the form, such as `https://aiformapi.com`. The default is the address the script was loaded from. Set it only if the script and the forms are on different hosts. | ## A complete page ```html Join us on Friday

Friday dinner

Tell us if you can come.

``` ## How it behaves - The script inserts an iframe right after the script tag. - The iframe shows the form from AI Form API. It is not a copy, so changes you make to the form show up at once. - The height adjusts to fit the form. - Answers go to AI Form API. Your page does not see them. ## Other ways to share a form | Way | When to use it | |---|---| | The public link | Email, chat, QR code, social posts. | | The embed script | You want the form inside your own page. | | The API | You build your own screen. Read the schema with `GET /v1/public/forms/{slug}` and send answers to `POST /v1/public/forms/{slug}/responses`. Both paths are in [openapi.json](/openapi.json). | ## Let visitors use their own AI agent Agent fill is a form setting. Open your embedded page and check how it looks before you publish. If you want the same idea on a form that you built yourself, see [agent-fill.js](/docs/agent-fill-js). ## Related - [Forms and fields](/docs/forms-and-fields) - [Agent fill](/docs/agent-fill) ## agent-fill.js: let visitors hand any HTML form to their AI agent Source: https://aiformapi.com/docs/agent-fill-js `agent-fill.js` is a script for a page that has a form. It reads the labelled inputs and builds a prompt. The visitor gives the prompt to their AI agent. They paste the answer back. The script sends nothing. The visitor presses your own Submit button. ## Add it Put one script tag on the page that holds the form. Set `data-form` to a CSS selector for the form. ```html
``` The script waits until the page has loaded, so it works above or below the form. It adds a button right above the form. The script is about 22 KB minified, has no dependencies and needs no account and no API key. ## Options Set these as attributes on the script tag. | Attribute | Needed | What it does | |---|---|---| | `data-form` | No | A CSS selector for the form, such as `#my-form`. Without it, the script uses forms that carry a `data-agent-fill` attribute. If there is none and the page has exactly one form, it uses that form. | | `data-title` | No | The form name that appears in the prompt. The default is the form's `aria-label`, then the page title, then `Form`. | | `data-form-id` | No | The id that appears in the prompt and in the block the agent replies with. The default is the form's `id`, then its `name`, then `form`. | | `data-button` | No | The text on the button. The default is `Fill this form with my AI agent`. | If you add the form to the page after the script has run, mount it yourself: ```html ``` All 3 options are optional. A form is mounted once, even if you call `mount` again. ## What the visitor sees 1. The visitor presses the button above your form. A dialog opens with the prompt. 2. They press **Copy prompt** and give it to Claude, ChatGPT or any other agent. The agent asks for what it does not know, shows the answers and asks "Is this right?". 3. The agent replies with one JSON block. The visitor pastes it into **Paste your agent's answer** and presses **Fill the form**. 4. The script checks the block, fills the inputs and outlines any answer that needs a look. It says "Nothing was sent". A screen reader hears the same note. 5. The visitor reads the form and presses your normal Submit button. This is Path B from [Agent fill](/docs/agent-fill). Path A, where the agent sends the answers itself, needs an API, so it is not part of this script. ## What it reads The script reads the form again each time the button is pressed, so fields that appear later are included. It finds the label of an input from `aria-label`, `aria-labelledby`, a `label` with a matching `for`, a wrapping `label`, the placeholder, and last the `name`. | On your form | Question type | |---|---| | `input` of type text, search and other plain types | `short_text`, with `max_length` when `maxlength` is 500 or less | | `textarea` | `long_text`, with `max_length` when `maxlength` is 5,000 or less | | `input type="email"` | `email` | | `input type="tel"` | `phone` | | `input type="url"` | `url` | | `input type="date"` | `date` | | `input type="number"` or `range` | `number`, with `min` and `max` from the attributes | | `select` | `single_choice`, or `multi_choice` when it has `multiple` | | Radio buttons with one `name` | `single_choice`. The question text is the `legend` of the `fieldset` | | Checkboxes with one `name` | `multi_choice` | | One checkbox | `checkbox` | The `required` attribute makes a question required. For a group of radio buttons, one required button is enough. A choice question needs at least 2 options. Disabled inputs are left out. ## What it never reads - Inputs of type `password`, `hidden`, `file`, `color`, `submit`, `button`, `reset` and `image`. - Inputs with an `autocomplete` value that starts with `cc-`, or is `current-password`, `new-password` or `one-time-code`. - Any input whose label, `name` or `id` matches the blocked-field list: passwords, PINs, CVV, card numbers, IBANs, OTP, passport and national ID numbers and the other terms in [Forms and fields](/docs/forms-and-fields). The dialog lists the skipped fields under the prompt as "Not shared with the agent". The agent never learns they exist, and the visitor types them by hand. ## The prompt It has the same wording as the prompt on AI Form API forms, with three differences: it names the page, it says it was sent through `agent-fill.js`, and it only offers the paste-back path. ````text You are helping me fill in a form. Please do it carefully. Form: "Join the book club" Page: https://example.com/join Sent through agent-fill.js · form id join Questions: 1. [fullname] Full name — short text, required 2. [email] Email — email address, required 3. [country] Country — single choice, one of: "Germany", "United Kingdom", optional Rules: - Use what you already know about me from this chat only when you are sure. Ask me for anything missing, one short question at a time. - Never invent personal details. Leave out optional questions I skip. - Before you finish, show me all answers and ask "Is this right?". Continue only after I say yes. How to hand the answers back: Reply with ONLY this block, filled in, so I can paste it back into the form on the page. I will check it and press the form's own submit button myself. ```json {"form": "join", "answers": {"fullname": "", "email": "", "country": ""}} ``` ```` The `Page:` line holds the address of your page without the query string. The prompt exists only in the visitor's browser until they copy it. ## What it does not do - It does not call AI Form API. After the browser has downloaded the script, the script makes no network request. The labels, the prompt and the answers stay on the page. - It does not set cookies or write to browser storage. - It does not submit the form. The visitor does that. - It does not replace your form's own checks. Your server still validates what arrives. ## Good to know - The dialog sits in a shadow root, so your page styles do not change it and it does not change your page. - It sets values the way a person would, with `input` and `change` events, so forms built with React or Vue see the change. - A strict Content-Security-Policy must allow the script from `https://aiformapi.com` and inline styles for the dialog. - The same checks run on the pasted answer as on AI Form API forms: a wrong type, a missing required answer or a value that is not one of the options is outlined. ## Why it exists Plenty of forms on the web are not ours: a contact form, a booking form, a school form. Anyone who wants help from an AI agent has to copy the questions by hand. This script gives those forms the same hand-off that AI Form API forms have. Read more on [agent-fillable forms](/agent-fillable-forms). ## Status `agent-fill.js` ships with version 0, released on 2026-10-30. See the [changelog](/docs/changelog) for releases. ## Form backend reference: POST /submit Source: https://aiformapi.com/docs/form-backend `POST https://aiformapi.com/submit` takes a submission from any website. The access key in the body picks the form. The plain-language page is [form backend](/form-backend). ## Make an access key Open the dashboard and press **New form backend**. The key starts with `fbk_`. The page shows the key and the HTML to copy. A form backend counts as one open form. Its responses count in your monthly quota. ## Request Send one of these content types: - `application/x-www-form-urlencoded` (a normal browser form) - `multipart/form-data` - `application/json` (an object) | Field | Meaning | |---|---| | `access_key` | Required. The key of your form backend. | | `botcheck` | Spam trap. Leave it empty. A value means a bot: the message is dropped and the answer looks like a success. | | any other name | Saved as an answer. A name can repeat (checkboxes): the answer is a list. | The recipient is never taken from the request. The email goes to the account owner's address. ```bash curl -sS https://aiformapi.com/submit \ -H "Content-Type: application/json" \ -d '{"access_key":"fbk_YOUR_KEY","name":"Ann","message":"Hello"}' ``` ## Answer A browser form post (it asks for `text/html`) gets a hosted thank-you page. A script gets JSON: ```json { "success": true, "message": "Your message was sent.", "receipt": "AF-7K3Q-9XMD", "submitted_at": "2026-11-01T09:00:00.000Z", "response_id": "rsp_..." } ``` There is no redirect to a URL from the request. ## Errors Errors use the [usual shape](/docs/errors). | Status | Code | Cause | |---|---|---| | 401 | `unauthorized` | The access key is missing or wrong. | | 403 | `quota_exceeded` | The account has used its responses for this month. | | 409 | `form_closed` | The form is closed. | | 413 | `payload_too_large` | The body is over 64 KB. | | 415 | `validation_failed` | The content type is not one of the three above. | | 422 | `validation_failed` | Too many fields (over 50), a name over 100 characters, a value over 5,000 characters, a file, or no field. | | 422 | `blocked_field` | A field name asks for a secret. See [blocked fields](/docs/forms-and-fields). | | 429 | `rate_limited` | 5 a minute or 30 an hour for one visitor, or 60 a minute for one form. | ## CORS Any origin may send `POST`. The preflight allows only `POST` and `OPTIONS`, and only the `Content-Type` header. No credentials are allowed, so no cookie is sent. ## Read the submissions Submissions are normal responses with `source` set to `web`. Read them in the dashboard, with [the responses API](/docs/responses) or by [webhook](/docs/webhooks). The CSV has one column for each name that a website sent. ## AI Form API changelog: what v0 includes Source: https://aiformapi.com/docs/changelog Version 0 is the first release, from 2026-10-30. It covers creating forms, reading responses and agent fill. This page lists what v0 has. It lists what is coming soon. It lists what we chose not to build. ## v0 Released on 2026-10-30. ### Added - **Short agent line** (changed 2026-11-06): the line is one sentence, `Fill this form for me: https://aiformapi.com/a/{slug}`. `GET /a/{slug}` returns plain text with the questions and two ways to send. The line is on by default for new forms. - **Static agent line** (added 2026-11-01): one fixed line per form that visitors paste into any AI chat, with `GET` and `POST /v1/agent/{slug}`, an owner switch (`settings.agent_line`, default off then), an optional secret code, rate limits, a duplicate guard, and a prefilled review link (`/f/{slug}#fill=...`) for chats that cannot send requests. See [Agent fill](/docs/agent-fill). - **REST API** at `https://aiformapi.com/v1`. Create, read, update, close, reopen and delete forms. See [Quickstart](/docs/quickstart). - **11 field types:** `short_text`, `long_text`, `email`, `number`, `url`, `phone`, `single_choice`, `multi_choice`, `checkbox`, `date` and `rating`. Up to 50 fields per form. See [Forms and fields](/docs/forms-and-fields). - **Blocked fields.** Forms that ask for passwords, PINs, card numbers, IBANs, passport numbers, national ID numbers and similar secrets are rejected with `blocked_field`. - **Responses** as JSON or CSV, with `since`, `limit` and cursor paging. See [Responses](/docs/responses). - **Agent fill.** A public form page offers a copy-and-paste prompt. Path A is for agents with tools. Path B is for plain chats and lets the person paste the agent's JSON back. See [Agent fill](/docs/agent-fill). - **Fill API:** `GET` and `POST /v1/fill/{token}`. See [Fill API](/docs/fill-api). - **Creator MCP server** at `/mcp` with an API key header. Works in Claude Code, Cursor, VS Code, Claude Desktop through `mcp-remote`, and n8n. See [MCP](/docs/mcp). - **Fill MCP server** at `/mcp/fill`, with no login. Add it as a custom connector in Claude.ai and ChatGPT. - **Webhooks** with a signed `X-AIFormAPI-Signature` header. See [Webhooks](/docs/webhooks). - **Embed script:** `embed.js`. See [Embed](/docs/embed). - **agent-fill.js:** a script that adds the agent hand-off (Path B) to any HTML form on any site, with no API and no network request. See [agent-fill.js](/docs/agent-fill-js). - **Form backend** (added 2026-11-01): `POST /submit` takes a plain HTML form or JSON from any website, with an access key from the dashboard. The submission is stored as a response with `source` `web` and emailed to the account owner. See [Form backend](/docs/form-backend). - **Dashboard** with magic-link sign-in: forms, a form builder, a responses table, CSV download, share link, embed code, webhooks and usage. - **Rate limit headers** on every limited response. See [Rate limits and quotas](/docs/rate-limits). - **OpenAPI 3.1** at [/openapi.json](/openapi.json). - **Free plan:** 3 open forms, 100 responses a month, 30 requests a minute and 1,000 a day for each API key. ### Coming soon - OAuth for the creator MCP server, so it can be added in Claude.ai and ChatGPT connectors. - A Pro plan with higher limits, at $19 a month. It is not on sale yet and there is no billing. See [Pricing](/pricing). - Google Sheets sync. See [Form to Google Sheets](/form-to-google-sheets). No date. - Native Excel (.xlsx) export. See [Form to Excel](/form-to-excel). No date. ### Not in v0 - Payments, file uploads, conditional logic and e-signatures. - Importing old responses from other tools. - Redirect after submit on the free plan. --- Documented endpoints: 17 paths. See https://aiformapi.com/openapi.json.