Skip to content
forecraft
Sign in Get an API key
Menu
Get an API key Sign in

Docs

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_ID with { "definition": … }, then publish again.
  • Close it: PATCH with { "status": "closed" }, or cap it with { "submission_limit": 100 }.
  • Read one answer: GET /api/v1/submissions/:id. Delete one: DELETE the 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.

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.


Getting started Form definitions