API reference
Getting started
- On the API page (in the menu under your initials), create a key. It's shown once, so copy it somewhere safe.
- Send it with every request:
curl -H "Authorization: Bearer YOUR_KEY" \ https://api.filloutthisform.com/v1/forms - Follow ids down:
/forms→/forms/{formId}/links→/links/{linkId}/responses.
Base URL: https://api.filloutthisform.com/v1. Just want a file? Every form and link has a Download button (CSV or JSON), and you don't need a key. See Responses & downloads.
New to it? Using the API walks through your first key and request, with pictures.
Keys and authentication
- Keys look like
fotf_live_…and go in theAuthorization: Bearerheader. Never put them in a URL. - A key can read all your forms, or only the ones you pick when creating it. Anything else looks like it doesn't exist (404).
- Anyone with a key can read the responses it covers, so keep keys on a server or in your tool's secret settings, never in a web page. (The API sends no CORS headers, so web pages can't read its responses anyway.)
- Revoking a key on the API page stops it at once. We only store a hash, so a lost key can't be shown again: revoke it and create a new one.
Plans and limits
| Free | Pro | |
|---|---|---|
| API keys | 1 | 10 |
| Requests per minute | 30 | 300 |
| Requests per day | 1,000 | 50,000 |
| Responses read per day | 10,000 | 1,000,000 |
| Page size (JSON) | up to 50 | up to 100 |
| Page size (CSV, NDJSON) | up to 500 | up to 1,000 |
Limits are per account, across all its keys. Daily limits reset at 00:00 UTC. Every response says where you are: X-Daily-Requests-Remaining and X-Daily-Responses-Remaining. A bigger limit than the page size is cut down to it, not refused. API limits come with Pro. Upgrading a single form doesn't change them.
Responses
Each response looks like this:
{
"id": "6f1c2a…",
"form_id": "a91e…",
"link_id": "3b7d…",
"sequence": 1042,
"submitted_at": "2026-10-01T09:12:44.000Z",
"answers": { "q_name": "Ada", "q_size": "opt_m", "q_tags": ["opt_a", "opt_c"], "q_age": 36 },
"display": { "q_name": "Ada", "q_size": "Medium", "q_tags": "Analytics, Compute", "q_age": "36" },
"verified": [],
"encryption": null
}answersholds the raw values, keyed by question id. Choice answers are option ids. Ids never change when you rename questions or options, so build on them. A signature is a small PNG as adata:image/png;base64,…string, and itsdisplaytext is justSigned.displayholds the same answers as text, exactly as the respondent saw the questions. Unanswered questions are left out of both./forms/{formId}/columnslists question ids with their labels and types, including questions removed from the form since.- Responses are anonymous: nothing says who sent them, unless your form asks.
Paging and syncing
Response lists come oldest first (order=desc for newest first), a page at a time:
{ "data": [ … ], "has_more": true, "next_cursor": "YXNjOjEwNDI" }- Pass
cursor=next_cursorto get the next page. Keep going whilehas_moreis true. - To sync, save the last
next_cursor. Next time, ask with it and you get only responses that arrived since. An empty page gives the same cursor back. since=2026-10-01(any ISO date or time) starts from a point in time instead.sequenceonly ever goes up and is never reused, so nothing is skipped. Still, de-duplicate byidif you might process a page twice.
JSON, CSV and NDJSON
Response lists can also come as CSV (a header row of question labels, then one row per response) or NDJSON (one JSON response per line). Use format=csv or format=ndjson, or the Accept header (text/csv or application/x-ndjson). Paging info is then in the X-Next-Cursor and X-Has-More headers.
In CSV, answers that start with =, +, - or @ get a ' in front, so spreadsheets don't run them as formulas (plain numbers are left alone).
Endpoints
All are relative to https://api.filloutthisform.com/v1. The webhook ones need Pro (see Webhooks). The full machine-readable reference is the OpenAPI document (works with Postman, Insomnia and code generators).
GET/me
Your account, plan, limits and today's usage
GET/forms
List forms
GET/forms/{formId}
A form and its current elements
GET/forms/{formId}/columns
Columns for tabular output (current questions, then removed ones)
GET/forms/{formId}/links
The form's shared links
GET/forms/{formId}/responses
Responses through every link of the form
- limit
- Page size. JSON: default 50, at most 50 (Free) / 100 (Pro). CSV/NDJSON: default and at most 500 / 1000. Larger values are capped.
- cursor
- next_cursor from a previous page: returns responses after it. Keep the last one and poll with it to get only new responses.
- since
- Only responses submitted at or after this ISO 8601 date/time.
- order
- asc (oldest first, for syncing) or desc (newest first).
- format
- json, csv (header row + one row per response, readable answers) or ndjson (one response object per line). Also chosen by the Accept header. CSV and NDJSON pages give the cursor in X-Next-Cursor and X-Has-More.
GET/links/{linkId}
A shared link
GET/links/{linkId}/responses
Responses through one link
- limit
- Page size. JSON: default 50, at most 50 (Free) / 100 (Pro). CSV/NDJSON: default and at most 500 / 1000. Larger values are capped.
- cursor
- next_cursor from a previous page: returns responses after it. Keep the last one and poll with it to get only new responses.
- since
- Only responses submitted at or after this ISO 8601 date/time.
- order
- asc (oldest first, for syncing) or desc (newest first).
- format
- json, csv (header row + one row per response, readable answers) or ndjson (one response object per line). Also chosen by the Accept header. CSV and NDJSON pages give the cursor in X-Next-Cursor and X-Has-More.
GET/responses/{responseId}
One response
GET/responses/{responseId}/files/{fileId}
A file uploaded with a response
GET/webhooks
List webhooks (Pro)
POST/webhooks
Add a webhook (Pro)
GET/webhooks/{webhookId}
A webhook (Pro)
DELETE/webhooks/{webhookId}
Delete a webhook
GET/openapi.json
This document (no key needed)
Errors
Errors are JSON with a code you can check, a message, and a request id:
{ "error": { "code": "rate_limited", "message": "…", "request_id": "…" } }| Code | Status | When |
|---|---|---|
| invalid_request | 400 | A query parameter is wrong (limit, order, cursor, since or format), or a request body is. |
| unauthorized | 401 | The API key is missing, malformed, unknown or revoked. |
| key_disabled | 403 | The key is beyond your plan's key allowance (for example after Pro ends). Newest keys are disabled first. |
| pro_required | 403 | Webhooks are part of Pro. |
| not_found | 404 | No such endpoint or id, or it isn't yours, or it's outside the key's forms. |
| method_not_allowed | 405 | That method isn't used on this endpoint. The Allow header lists the ones that are. |
| limit_reached | 409 | The account already has as many webhooks as it can. |
| rate_limited | 429 | Over the per-minute request rate. Retry after the Retry-After seconds (60). |
| quota_exceeded | 429 | The day's requests or responses are used up. Retry-After gives the seconds until 00:00 UTC. |
| internal_error | 500 | Something went wrong on our side. Quote the request_id if you contact support. |
Examples
Download a link's responses as CSV
curl -H "Authorization: Bearer $FOTF_API_KEY" \
"https://api.filloutthisform.com/v1/links/LINK_ID/responses?format=csv" > responses.csvSync new responses (JavaScript)
const API = "https://api.filloutthisform.com/v1";
const headers = { Authorization: `Bearer ${process.env.FOTF_API_KEY}` };
// Fetches everything after `cursor` (null the first time) and returns the
// cursor to save for next time.
async function sync(linkId, cursor, handle) {
for (;;) {
const url = new URL(`${API}/links/${linkId}/responses`);
url.searchParams.set("limit", "50");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers });
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After")) * 1000));
continue;
}
if (!res.ok) throw new Error((await res.json()).error.message);
const page = await res.json();
for (const response of page.data) await handle(response);
cursor = page.next_cursor ?? cursor;
if (!page.has_more) return cursor;
}
}Sync new responses (Python)
import os, time, requests
API = "https://api.filloutthisform.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['FOTF_API_KEY']}"}
def sync(link_id, cursor, handle):
"""Handles every response after `cursor` (None the first time) and
returns the cursor to save for next time."""
while True:
params = {"limit": 50, **({"cursor": cursor} if cursor else {})}
res = requests.get(f"{API}/links/{link_id}/responses", headers=HEADERS, params=params)
if res.status_code == 429:
time.sleep(int(res.headers["Retry-After"]))
continue
res.raise_for_status()
page = res.json()
for response in page["data"]:
handle(response)
cursor = page["next_cursor"] or cursor
if not page["has_more"]:
return cursor