The curl sequence
Everything an agent with only an API key needs, in order: create a form, preview it, publish it, open it, submit to it as a person would, then list and export the answers. Each step is one curl; jq carries the form's id and slug from one step to the next.
You need curl, jq and two environment variables. Get a key from the dashboard (API keys) or, locally, from npm run seed.
export FORECRAFT_URL=http://localhost:3000
export FORECRAFT_API_KEY=fc_live_…
0. Check the key
curl -s "$FORECRAFT_URL/api/v1/forms" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" | jq '.items | length'
A number means the key works. A 401 with application/problem+json means it does not.
1. Create a form
The body is { definition, slug? }. Leave slug out and one is made from the title (with a short random suffix), so the sequence can run twice. The definition below has three fields and two rules: seats is shown, and required, only for the Team plan.
FORM=$(curl -s -X POST "$FORECRAFT_URL/api/v1/forms" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"definition": {
"schema": {
"title": "Beta waitlist",
"description": "Two questions. We write back within a day.",
"type": "object",
"properties": {
"email": { "type": "string", "format": "email", "title": "Work email" },
"plan": { "type": "string", "title": "Plan", "enum": ["Solo", "Team"] },
"seats": { "type": "integer", "title": "Seats", "minimum": 2, "maximum": 50 }
},
"required": ["email", "plan"]
},
"rules": {
"rules": [
{ "id": "seats-for-teams", "show": "seats", "when": { "==": [{ "var": "plan" }, "Team"] } },
{ "id": "seats-required-for-teams", "require": "seats", "when": { "==": [{ "var": "plan" }, "Team"] } }
]
},
"appearance": { "theme": "aurora", "submitLabel": "Join the waitlist" }
}
}')
FORM_ID=$(echo "$FORM" | jq -r .id)
SLUG=$(echo "$FORM" | jq -r .slug)
echo "$FORM_ID $SLUG"
201 Created returns the form resource: id, slug, title, status, published_version (null until you publish), has_unpublished_changes, submission_count, definition, url and preview_url. A definition that does not render is a 422 whose errors[].pointer says where, for example /schema/properties/plan.
2. Preview it
curl -s -X POST "$FORECRAFT_URL/api/v1/forms/$FORM_ID/preview" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" | jq '.audit.passed, .audit.counts'
The response is { html, audit: { passed, counts, findings }, rules }: the exact HTML the public page will carry, its accessibility audit, and the rules definition the server will enforce. Publish when passed is true. To try a change without saving it, send {"definition": {…}} as the body; nothing is stored.
3. Publish it
URL=$(curl -s -X POST "$FORECRAFT_URL/api/v1/forms/$FORM_ID/publish" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" | jq -r .url)
echo "$URL"
Publishing snapshots the draft as version 1 and answers { version, published_at, url }. The public URL always serves the latest published version; later edits stay in the draft until you publish again.
4. Open the public URL
curl -s -o /dev/null -w "%{http_code}\n" "$FORECRAFT_URL/f/$SLUG"
200 once published. Before the first publish it is 404. Send the URL to the people who should fill it in.
5. Submit, as a person would
The public submission endpoint needs no key. It takes JSON { "data": { … } } or a form encoding, exactly like the page sends.
curl -s -X POST "$FORECRAFT_URL/api/v1/f/$SLUG/submissions" \
-H "Content-Type: application/json" \
-d '{ "data": { "email": "ada@example.com", "plan": "Team", "seats": 5 } }' | jq .
201 returns { id, created_at, thank_you, redirect }. Now break a rule on purpose: a Team plan with no seats.
curl -s -X POST "$FORECRAFT_URL/api/v1/f/$SLUG/submissions" \
-H "Content-Type: application/json" \
-d '{ "data": { "email": "grace@example.com", "plan": "Team" } }' | jq '.status, .errors'
The server runs the same rules the browser ran and answers 422 with a problem whose errors[0] has "field": "seats" and "rule": "required". A closed, full or unpublished form answers 409; too many submissions from one address, 429.
6. List the submissions
curl -s "$FORECRAFT_URL/api/v1/forms/$FORM_ID/submissions" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" | jq '.total, .items[0].data'
{ items, total }, newest first. Each item is { id, form_id, version, created_at, data }. Filter with ?q= (text in the answers), ?version=, ?since= and ?until= (ISO dates), and page with ?limit= (default 50) and ?offset=.
7. Export them
curl -s "$FORECRAFT_URL/api/v1/forms/$FORM_ID/submissions/export?format=csv" \
-H "Authorization: Bearer $FORECRAFT_API_KEY"
One row per submission: id,submitted_at,version, then one column per field (email,plan,seats). Use format=json for { form, items } instead.
Afterwards
- Change the form:
PATCH /api/v1/forms/$FORM_IDwith{ "definition": … }, then publish again. - Close it:
PATCHwith{ "status": "closed" }, or cap it with{ "submission_limit": 100 }. - Read one answer:
GET /api/v1/submissions/:id. Delete one:DELETEthe same path.
The whole API is in the API reference; the same steps as MCP tools are in the MCP docs.
Issue your first invoice
The same key issues documents: upload a logo, start from the invoice starter with your
company and accent, preview it (findings and the pages themselves), publish, issue from
JSON, download the PDF, void it with a reason, and issue the next one with the next number.
Same curl, jq and environment variables as above; base64 decodes the page images.
1. Upload a logo
cat > logo.svg <<'SVG'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" fill="#0f4c5c"/><path d="M11 29V11h18" fill="none" stroke="#fff" stroke-width="3"/></svg>
SVG
LOGO=$(curl -s -X POST "$FORECRAFT_URL/api/v1/assets?name=logo.svg" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" \
-H "Content-Type: image/svg+xml" \
--data-binary @logo.svg | jq -r '.ref')
echo "$LOGO"
An asset:ast_… reference. Assets are judged by their bytes (PNG, JPEG, WebP, SVG); a
script with an image's name is a 415, an SVG that runs or loads anything a 422.
2. List the starters
curl -s "$FORECRAFT_URL/api/v1/report-starters" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" | jq -r '.items[] | "\(.id)\t\(.title)"'
invoice, letter, business-report, confirmation, survey-summary, blank.
3. Create a report from the invoice starter
Give it your company (the sample is what previews render) and one dark accent. The
starter keeps its layout, money, numbering (INV-{YYYY}-{seq:4}) and house style.
REPORT=$(curl -s -X POST "$FORECRAFT_URL/api/v1/reports" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg logo "$LOGO" '{
starter: "invoice",
title: "Invoice",
appearance: { accent: "#0f4c5c", logo: $logo },
sample: { seller: { name: "Quayside Logistics Ltd", street: "4 Harbour Row", city: "Leith EH6 6LX",
country: "United Kingdom", vat: "GB 123 4567 89", email: "accounts@quayside.example",
iban: "GB29NWBK60161331926819", bic: "NWBKGB2L", bank: "Example Bank plc" } }
}')" | jq -r '.id')
echo "$REPORT"
4. Preview it: the findings and the pages
curl -s -X POST "$FORECRAFT_URL/api/v1/reports/$REPORT/preview" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "format": "png", "pages": 2 }' > preview.json
jq '{ ok, pages, findings }' preview.json
jq -r '.images[0].data' preview.json | base64 -d > page-1.png
ok is true when no finding is an error. Look at page-1.png: it is the PDF's own page 1.
A finding names its block ("block": "items") and what to do; fix the definition with
PATCH /api/v1/reports/$REPORT ({ "definition": … }) and preview again. "format": "pdf"
returns the PDF itself; "format": "html" the page before printing.
5. Publish
curl -s -X POST "$FORECRAFT_URL/api/v1/reports/$REPORT/publish" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" | jq '.version'
6. Issue a document from JSON
DATA='{
"seller": { "name": "Quayside Logistics Ltd", "street": "4 Harbour Row", "city": "Leith EH6 6LX",
"country": "United Kingdom", "vat": "GB 123 4567 89", "email": "accounts@quayside.example",
"iban": "GB29NWBK60161331926819", "bic": "NWBKGB2L", "bank": "Example Bank plc" },
"customer": { "name": "Lumen Studio Lda", "street": "Rua do Alecrim 47", "city": "1200-014 Lisboa", "country": "Portugal" },
"reference": "PO 7781",
"due": "2026-11-30",
"items": [
{ "description": "Cold-chain freight, Leith to Lisbon", "qty": 3, "unit": "pallet", "price": 420, "vat": 20 },
{ "description": "Customs brokerage", "qty": 1, "unit": "file", "price": 185.5, "vat": 20 }
]
}'
DOC=$(curl -s -X POST "$FORECRAFT_URL/api/v1/reports/$REPORT/documents" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d "{ \"issue\": true, \"data\": $DATA }")
echo "$DOC" | jq '{ number, status, pdf_url, share_url }'
DOC_ID=$(echo "$DOC" | jq -r '.id')
NUMBER=$(echo "$DOC" | jq -r '.number')
The data is checked against the report's schema (a 422 names each pointer) and the
document is preflighted before it gets its number; an error is a 422 preflight-failed
with the findings. The number is assigned in the same transaction that issues it.
7. Download the PDF
curl -s "$FORECRAFT_URL/api/v1/documents/$DOC_ID/pdf" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" -o "$NUMBER.pdf"
ls -l "$NUMBER.pdf"
Every download serves the same bytes (the ETag is their SHA-256). The share_url opens
the document for anyone with the link, with a Download PDF button; POST /api/v1/documents/$DOC_ID/share/rotate revokes it.
8. Void it, then issue the next one
curl -s -X POST "$FORECRAFT_URL/api/v1/documents/$DOC_ID/void" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Issued to the wrong customer entity" }' | jq '{ number, status, void_reason }'
curl -s -X POST "$FORECRAFT_URL/api/v1/reports/$REPORT/documents" \
-H "Authorization: Bearer $FORECRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d "{ \"issue\": true, \"data\": $DATA }" | jq '{ number, status }'
The voided document keeps its number and prints VOID; the next issue takes the next
number — the sequence has no gap. An issued document never changes (PATCH is a 409).
The definition, every block and the preflight codes are in reports; what to choose, and why, in report style.