bettercollected is open source under the Apache License 2.0. The code, including everything on this page, is in the public repository. This page describes bettercollected 4.1; the documentation links go to release 4.1.1.

What there is

  • REST API: forms as JSON documents. Create, check, change, publish and delete them, and read their responses.
  • MCP server: the same actions as tools for an AI client you choose.
  • Python SDK and CLI: a typed client, and the bettercollected command to keep forms as code.
  • TypeScript SDK: a typed client that only needs fetch.

All four use the same workspace API keys, the same permissions and the same publish checks as the form builder. The SDKs are on PyPI and npm, and work with bettercollected 4.1 or later. Both packages are built and published by the public repository's CI with provenance: npm audit signatures checks the npm package, and PyPI shows the attestations of each file under Download files.

API keys

A workspace admin creates keys in the dashboard, under AI → MCP. That tab also shows your instance's MCP address.

  • A key works in its own workspace only. It starts with bc_, followed by 64 characters, and is shown once, when you create it. The server stores only a hash of it.
  • Each key has scopes. A key never does more than the person who created it may do at the moment of the call: their permissions are checked again every time.
  • Revoke a key on the same tab. The list shows when each key was last used.

The scopes a key can have:

  • forms:read: list, read and check forms
  • forms:write: create, change and publish forms
  • forms:delete: delete forms, and with them their responses (forms:write does not allow this)
  • responses:read: list and read responses, with the full answers
  • deletion_requests:read: see pending deletion requests (MCP)
  • deletion_requests:write: close a respondent's deletion request (REST API)

A key with responses:read gives whoever holds it the full, unredacted answers. The dashboard asks the admin to confirm this before it creates such a key, and records who confirmed it and when.

Audit log. Every call made with a key, through the REST API or the MCP server, is recorded in an audit log on the server: which key, which action, whether it worked, and when. The log keeps ids and counts, never the content of forms or answers. It is not yet viewable in the dashboard.

REST API

The API works with forms as form spec v1 documents. The API documentation lists every endpoint, and the OpenAPI document describes them in full.

  • Base URL: https://<your-instance>/api/v1/public/v1
  • Authentication: Authorization: Bearer <API key>. Cookies are never read, so a browser session cannot be used against the API.

The examples below read the key from an environment variable, so it stays out of your code. Load it from your secret manager or from a .env file you never commit, or type it at a hidden prompt, so it does not end up in your shell history:

export BETTERCOLLECTED_URL="https://<your-instance>"
read -rs BETTERCOLLECTED_API_KEY && export BETTERCOLLECTED_API_KEY   # paste the key; it is not shown
API="$BETTERCOLLECTED_URL/api/v1/public/v1"

Create a form

Save the form as signup.json. The whyWeAsk field is required on questions that ask for an email address, a phone number or an ID number: without it, the publish check refuses the form.

{
  "specVersion": "1",
  "title": "Newsletter sign-up",
  "language": "en",
  "pages": [
    {
      "id": "page-1",
      "fields": [
        {
          "id": "email",
          "type": "email",
          "title": "Your email address",
          "whyWeAsk": "To send you the newsletter.",
          "validations": { "required": true }
        }
      ]
    }
  ],
  "endings": [{ "title": "Thank you", "message": "You are signed up." }],
  "trust": {
    "purpose": "To send you our monthly newsletter.",
    "retention": { "type": "days", "value": 365 }
  }
}

Create it as a draft (needs forms:write). The API replies with the new formId and the stored document, and an ETag header for the version.

curl -sS -X POST "$API/forms" \
  -H "Authorization: Bearer $BETTERCOLLECTED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: newsletter-signup-v1" \
  --data @signup.json

The Idempotency-Key makes a retry safe: the same key with the same request within 24 hours returns the first reply instead of creating a second form. Use a new key for a different document; reusing one within 24 hours gives a 422 idempotency_key_reused.

To check a document without storing anything, send it to POST /forms/validate.

Publish it

FORM_ID="<formId from the reply above>"
curl -sS -X POST "$API/forms/$FORM_ID/publish" \
  -H "Authorization: Bearer $BETTERCOLLECTED_API_KEY"

Publishing runs the server's publish checks, the same ones the builder runs:

  • nothing is chosen in advance;
  • no required question nobody can answer;
  • a reason on every question that asks for an email address, a phone number or an ID number;
  • an ending for every jump;
  • theme colours that are easy to read;
  • the workspace has a name, which respondents see as who receives their answers.

The builder also checks that every question has a title. A form that fails gets a 422 form_not_publishable whose message lists every problem and what to fix. Give your workspace a name in its settings before you publish: a workspace still called "untitled" counts as having none.

Read responses

Needs responses:read. The list is newest first; size is at most 100.

curl -sS "$API/forms/$FORM_ID/responses?page=1&size=20" \
  -H "Authorization: Bearer $BETTERCOLLECTED_API_KEY"

curl -sS "$API/forms/$FORM_ID/responses/<responseId>" \
  -H "Authorization: Bearer $BETTERCOLLECTED_API_KEY"

The account a respondent answered with (their sign-in email or identity) and the URL parameters a form captured are never returned. Answers to the form's own questions are, including an email question like the one above. A response whose respondent asked for it to be deleted carries "deletionRequest": {"status": "pending"}: honour it in every copy you keep.

Good to know

  • Versions. GET /forms/{formId} replies with an ETag. Send it back as If-Match when you replace (PUT) or edit (POST .../ops) a form, and a change made in the meantime gives 412 instead of being overwritten.
  • Edits. POST /forms/{formId}/ops applies edit operations, such as adding a question, all or nothing. The /dry-run endpoints show what would change and store nothing.
  • Errors always have the same shape: a stable code and a message. When a document is invalid, errors lists each problem with a JSON pointer into your request.
  • Limits. Requests are limited per key and per workspace; over the limit the API replies 429 with a Retry-After header.

MCP server

The MCP server gives an AI client the same actions as tools. It uses streamable HTTP and the same API keys and scopes as the REST API, and its calls go into the same audit log.

  • Address: https://<your-instance>/api/v1/mcp/. The AI → MCP tab shows it without the final slash; the server redirects that address to the one with the slash, so use the slash if your client does not follow redirects.
  • Authentication: Authorization: Bearer <API key>

Tools: list_forms, get_form, get_form_spec, create_form, update_form, validate_form, dry_run_ops, publish_form, list_responses, get_response, list_deletion_requests and get_ai_profile.

Connect an MCP client

Give the key only the scopes the client needs. In Claude Code:

claude mcp add --transport http bettercollected "$BETTERCOLLECTED_URL/api/v1/mcp/" \
  --header "Authorization: Bearer $BETTERCOLLECTED_API_KEY"

Your shell fills in the variable, so the key itself is saved in Claude Code's own configuration. Keep that configuration private, and never add this server with --scope project: that writes the key into .mcp.json in your project, where it can end up in git.

To share the server with a project instead, put it in the project's .mcp.json (Claude Code's project file) with a reference to the variable, not the key:

{
  "mcpServers": {
    "bettercollected": {
      "type": "http",
      "url": "https://<your-instance>/api/v1/mcp/",
      "headers": { "Authorization": "Bearer ${BETTERCOLLECTED_API_KEY}" }
    }
  }
}

Claude Code fills in ${BETTERCOLLECTED_API_KEY} from the environment. Other clients use their own file and format: check whether yours fills in ${…} variables. If it does not, put the key in the client's secret store, and never commit a file that holds a key. With responses:read, the AI client and the AI provider behind it receive the full answers.

The AI form assistant

bettercollected has an AI form assistant. It can draft a form from a description and suggest edits in the builder, and over MCP the tools create_form_with_ai, create_form_outline and build_form_from_outline (with fill_gaps) use it. It is off until a workspace admin switches it on, for one AI provider the instance has set up. Until then the server refuses every AI request (403 ai_not_enabled) and sends nothing to an AI provider. On your own instance, you set up the provider and its key.

Python SDK and CLI

A small typed client (httpx and Pydantic) and the bettercollected command. See the Python SDK documentation and the package on PyPI.

Install it from PyPI (Python 3.10 or later). Both the library and the bettercollected command come with it:

pip install bettercollected

# or in a uv project
uv add bettercollected

# or only the command, as a uv tool
uv tool install bettercollected

Create, publish and read responses:

import json
import os

from bettercollected import BetterCollected, BetterCollectedError

with open("signup.json") as f:
    document = json.load(f)

with BetterCollected(os.environ["BETTERCOLLECTED_API_KEY"], os.environ["BETTERCOLLECTED_URL"]) as bc:
    created = bc.create_form(document, idempotency_key="newsletter-signup-v1")
    try:
        bc.publish_form(created.form_id)
    except BetterCollectedError as error:
        print(error)  # the message lists what to fix, then the code

    for summary in bc.list_responses(created.form_id).items:
        response = bc.get_response(created.form_id, summary.response_id)
        print(response.submitted_at, response.answers)

Forms as code with the CLI. It reads the key from BETTERCOLLECTED_API_KEY only, and the instance from --url or BETTERCOLLECTED_URL:

bettercollected pull <formId> -o signup.json   # write the form to a file
bettercollected validate signup.json           # check it
bettercollected diff signup.json               # what push would change
bettercollected push signup.json               # replace the form, if nobody changed it since the pull

TypeScript SDK

A small typed client that only uses fetch (Node.js 22 or later; ES modules only). See the TypeScript SDK documentation and the package on npm.

Install it from npm:

npm install @bettercollected/sdk
import { readFile } from "node:fs/promises";
import { BetterCollected, BetterCollectedError } from "@bettercollected/sdk";

const bc = new BetterCollected({
  apiKey: process.env.BETTERCOLLECTED_API_KEY!,
  baseUrl: process.env.BETTERCOLLECTED_URL!,
});

const document = JSON.parse(await readFile("signup.json", "utf8"));
const { formId } = await bc.createForm(document, { idempotencyKey: "newsletter-signup-v1" });
try {
  await bc.publishForm(formId);
} catch (error) {
  // the message lists every problem and what to fix
  if (error instanceof BetterCollectedError) console.error(error.code, error.message);
}

const { items } = await bc.listResponses(formId);
for (const summary of items) {
  const response = await bc.getResponse(formId, summary.responseId);
  console.log(response.submittedAt, response.answers);
}

Run it yourself

bettercollected runs on your own servers too: the repository's README explains how. The API, the MCP server and the SDKs work the same way there; use your own address as BETTERCOLLECTED_URL.

Questions, or something missing? Open an issue or read what else connects.