# Formkurier API > Formkurier API: forms whose answers go signed to your webhook (PASSTHROUGH) or are stored encrypted in Germany (STORE). Authenticate with `Authorization: Bearer fk_live_…` (create keys in the app) or the session cookie. - [OpenAPI document](/openapi.json) ## Webhooks (PASSTHROUGH) - Each answer is POSTed as JSON to the webhook of the form (set with PUT /api/v1/forms/{id}/webhook; https, public address). Headers: `X-Formkurier-Timestamp` (Unix seconds), `X-Formkurier-Signature`, `X-Formkurier-Event-Id` (stable across retries, use it to drop duplicates). - Signature: `sha256=` + lowercase hex of HMAC-SHA256 with the full secret (including its `whsec_` prefix) as key over `.` (the exact bytes received, before parsing). Compare in constant time and reject timestamps more than 300 seconds off. - Body: `{ type, eventId, createdAt, form: { id, name, version }, data }`. `type` is `submission.created` or `webhook.test` (POST /api/v1/forms/{id}/webhook/test). `data` holds the answers under the field keys; file fields as described below. - Delivered means any 2xx within 10 seconds. Otherwise retries after 1, 5, 30, 120 minutes (5 attempts in all); the encrypted body is deleted after delivery or after 24 hours. Status per attempt: GET /api/v1/forms/{id}/deliveries. - In PASSTHROUGH a published form always has a webhook: switching to PASSTHROUGH (PATCH /api/v1/workspace) and removing the webhook of a published form (PUT …/webhook with url null) are refused with 409, so no answer can get lost. Removing a webhook is also refused while answers wait for a retry; removing it deletes its delivery log, and setting one again creates a new signing secret. - Local development: the webhook must be a public https address; use a tunnel (e.g. cloudflared, ngrok) to your machine. ## Definitions: conditions, pages, success pages - Conditions: `rules: [{ id, trigger, condition, value?, action, target }]`. `trigger` and `target` are field keys; `condition` one of equals, not_equals, contains, not_contains, greater_than, less_than, is_empty, is_not_empty; `action` one of show, hide, require, disable, set_value. Example: `{ id: r1, trigger: art, condition: equals, value: Hochzeit, action: show, target: gaeste }` shows `gaeste` only for weddings; a hidden required field does not block sending. Several conditions on one target: `ruleGroups: [{ id, operator: AND|OR, action, target, conditions: [{ trigger, condition, value? }] }]`. - Pages: `steps: [{ id, title, description?, fields: [] }]`. - Success page: `successPages: { default: { content: [{ id, type: text, props: { content } }] , redirect?: { url } } }`; the first text line is the heading. - Options of select, radio, multiselect and checkbox go in `config.options` (strings or `{ value, label }`), not on the field itself. ## Sending answers without the embed - GET /api/public/captcha returns an ALTCHA challenge (proof of work, no third party). Solve it with altcha-lib: `import { solveChallenge } from "altcha-lib"; import { deriveKey } from "altcha-lib/algorithms/sha"` and pass `deriveKey` with the challenge; send `captcha` as base64 of the JSON `{ challenge, solution }` with POST /api/public/forms/{id}/submissions `{ values, captcha }`. Each solution works once. Forms with `actions.captcha.enabled: false` need none. - Limit: 20 answers per address and form in 10 minutes (429). ## Conventions - Errors: `{ error: , errors?: [...] }`; `errors` holds German texts for people (an object per field for some checks). Codes: invalid_request (400), unauthorized (401), subscription_required (402), missing_scope and session_required (403), not_found (404), conflict (409), length_required (411), payload_too_large (413), invalid_definition, invalid_webhook_url, rejected (answers; 400 for a failed captcha) and idempotency_key_reused (422). Publishing a form whose definition is not complete yet (e.g. a choice without options) is conflict (409) with the reasons, rate_limited (429, with Retry-After). - Idempotency-Key header on POST /api/v1/forms and /api/v1/forms/from-yaml: the same key and body within 24 hours returns the first answer instead of creating a second form. - POST without a body still needs `Content-Length: 0` (some clients omit it; the API then answers 411). - Prices: 9 € per month plus VAT (B2B), 7 days free; billing through Polar. ## File uploads - Field `type: file`, optional `config.accept` (MIME types, `image/*` or extensions like `.pdf`) and `config.multiple: true`. At most 10 MB per file and 5 files per answer. - Submit: the value of a file field is `[{ filename, data }]`, `data` the content as base64. The type is recognized from the content, not from the browser. - PASSTHROUGH: the webhook body carries each file inline under the field key as `{ filename, mimeType, size, data }` (base64). It is encrypted in the outbox like the rest of the answer and deleted after delivery. - STORE: files are kept encrypted. The answer lists them as `{ id, filename, mimeType, size, url }`; `url` downloads the file. Deleting the answer deletes its files. ## MCP server (Claude, ChatGPT and other agents) - Endpoint: `https://app.formkurier.com/mcp`, Streamable HTTP, stateless. Sign-in with OAuth 2.1: authorization code with PKCE, Client ID Metadata Documents or dynamic client registration, refresh tokens. Discovery: `/.well-known/oauth-protected-resource/mcp` and `/.well-known/oauth-authorization-server`. - Claude: Settings > Connectors > Add custom connector, URL `https://app.formkurier.com/mcp`. ChatGPT: Settings > Apps > Create (developer mode), same URL, authentication OAuth. Claude Code: `claude mcp add --transport http formkurier https://app.formkurier.com/mcp`. - On connecting, the user signs in, picks one workspace and chooses "Lesen" (forms:read, submissions:read) or "Lesen und schreiben" (all requested scopes, as for API keys). The connection acts only for that workspace; on a read-only connection the writing tools change nothing and say so. Disconnect in the app under Einstellungen > Verbundene Apps or with DELETE /api/v1/connected-apps/{id}. - Without a running subscription the tools answer with a note and the subscription link, no data. ### Tools - `formkurier_get_started` (read): Get started - `formkurier_workspace` (read): Workspace - `formkurier_set_storage_mode` (write, destructive, workspace:write): Switch storage mode - `formkurier_list_forms` (read, forms:read): List forms - `formkurier_get_form` (read, forms:read): Read form - `formkurier_create_form` (write, forms:write): Create form - `formkurier_update_form` (write, forms:write): Change form draft - `formkurier_publish_form` (write, destructive, forms:write): Publish form - `formkurier_list_versions` (read, forms:read): List published versions - `formkurier_restore_version` (write, forms:write): Restore version into draft - `formkurier_archive_form` (write, destructive, forms:write): Archive form - `formkurier_embed` (read, forms:read): Form link and embed code - `formkurier_get_webhook` (read, forms:read): Read webhook - `formkurier_get_webhook_secret` (read, forms:write): Read webhook signing secret - `formkurier_set_webhook` (write, destructive, forms:write): Set webhook - `formkurier_test_webhook` (write, forms:write): Send test event - `formkurier_rotate_webhook_secret` (write, destructive, forms:write): Rotate webhook secret - `formkurier_list_deliveries` (read, forms:read): Webhook deliveries - `formkurier_list_answers` (read, submissions:read): List answers - `formkurier_get_answer` (read, submissions:read): Read answer - `formkurier_export_answers` (read, submissions:read): Export answers - `formkurier_delete_answer` (write, destructive, submissions:write): Delete answer - `formkurier_get_file` (read, submissions:read): Get uploaded file - `formkurier_get_branding` (read): Read branding - `formkurier_set_branding` (write, destructive, workspace:write): Change branding - `formkurier_get_avv` (read): Read AVV status ## Endpoints - GET /api/health: Liveness check - GET /api/v1/workspace: Workspace, storage mode and subscription - PATCH /api/v1/workspace: Switch the storage mode (PASSTHROUGH or STORE) or the answer mail - GET /api/v1/forms: List forms - POST /api/v1/forms: Create a form from a definition (JSON or YAML) - GET /api/v1/forms/{id}: Read a form (add ?format=yaml for the definition as YAML) - PUT /api/v1/forms/{id}: Change name or definition (draft; publish to go live) - DELETE /api/v1/forms/{id}: Archive a form (it stops accepting answers) - POST /api/v1/forms/{id}/publish: Publish the current draft as a new version - GET /api/v1/forms/{id}/versions: Published versions - POST /api/v1/forms/{id}/versions/{version}/restore: Copy a published version into the draft (publish to make it live again) - GET /api/v1/forms/{id}/webhook: Webhook target and signing secret - PUT /api/v1/forms/{id}/webhook: Set the webhook target (public https only); null removes it - POST /api/v1/forms/{id}/webhook/rotate: Create a new signing secret - POST /api/v1/forms/{id}/webhook/test: Send a signed test event now - GET /api/v1/forms/{id}/deliveries: Last 100 webhook deliveries (status only, no answers) - GET /api/v1/forms/{id}/submissions: Stored answers (mode STORE), newest first, decrypted; files as `{ id, filename, mimeType, size, url }` - GET /api/v1/forms/{id}/submissions/export: Export stored answers (newest first, up to 5000) as CSV or JSON file - GET /api/v1/forms/{id}/submissions/{submissionId}: One stored answer, decrypted - DELETE /api/v1/forms/{id}/submissions/{submissionId}: Delete one stored answer and its files (scope submissions:write) - GET /api/v1/api-keys: List API keys (session only) - POST /api/v1/api-keys: Create an API key; the token is shown once (session only) - DELETE /api/v1/api-keys/{id}: Revoke an API key (session only) - GET /api/v1/connected-apps: Apps connected to this workspace over OAuth (MCP), by any member - DELETE /api/v1/connected-apps/{id}: Disconnect an app: its tokens stop working at once - POST /api/v1/billing/checkout: Start the Polar checkout: card now, 7 days free, then 9 € per month plus VAT - POST /api/v1/billing/sync: Read the result of a finished checkout - POST /api/v1/billing/portal: Customer portal at Polar (card, invoices, cancel) - GET /api/public/captcha: ALTCHA challenge for one submission - GET /api/public/forms/{id}: Published definition of a form - POST /api/public/forms/{id}/submissions: Submit answers to a published form - GET /api/v1/avv: Data processing agreement (AVV) of the workspace - PUT /api/v1/avv: Set the controller data of the AVV (session only); clears an earlier acceptance - POST /api/v1/avv/accept: Accept the current AVV version (session only) - GET /api/v1/forms/{id}/submissions/{submissionId}/files/{fileId}: Download one uploaded file of a stored answer (decrypted, as attachment) - POST /api/v1/forms/from-yaml: Create a form from a YAML definition sent as the raw body (name from meta.name) - GET /api/v1/domains: Custom domains with DNS records and status - POST /api/v1/domains: Add a custom domain (e.g. formulare.example.com); then set the two DNS records and verify - POST /api/v1/domains/{id}/verify: Check the DNS records now (TXT required, CNAME/A reported) - DELETE /api/v1/domains/{id}: Remove a custom domain - GET /api/v1/workspace/branding: Colour, font and logo of the public forms - PATCH /api/v1/workspace/branding: Change colour, font or logo; omitted keys stay, null resets