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
bettercollectedcommand 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 formsforms:write: create, change and publish formsforms:delete: delete forms, and with them their responses (forms:writedoes not allow this)responses:read: list and read responses, with the full answersdeletion_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 anETag. Send it back asIf-Matchwhen you replace (PUT) or edit (POST .../ops) a form, and a change made in the meantime gives412instead of being overwritten. - Edits.
POST /forms/{formId}/opsapplies edit operations, such as adding a question, all or nothing. The/dry-runendpoints show what would change and store nothing. - Errors always have the same shape: a stable
codeand amessage. When a document is invalid,errorslists each problem with a JSON pointer into your request. - Limits. Requests are limited per key and per workspace; over the limit the API replies
429with aRetry-Afterheader.
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.