Read form responses as JSON or CSV

Updated 2026-10-30

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_...
ParameterWhat it does
formatjson or csv. JSON is the default.
orderasc (oldest first, the default) or desc (newest first).
sinceOnly responses submitted after this ISO 8601 UTC time, for example 2026-10-30T09:00:00Z.
limitRows per page. JSON: 100 by default, 1,000 at most. CSV: up to 10,000 rows per file.
cursorThe 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: 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 Excel and 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.

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.

What source means

ValueHow the answer arrived
humanA person typed it into the form page.
pasteA person pasted an AI agent's JSON block into the form page, reviewed it and pressed Submit. This is Path B.
agent_apiAn agent sent POST /v1/fill/{token} over HTTP. This is Path A.
agent_mcpAn agent called submit_fill on the fill MCP server. This is Path A.

Use source to see how many of your answers come from agents. See 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.