HTTP API
Base URL: https://api.kibbleforms.com (or your apiUrl). JSON in and out. Errors always have this shape:
{ "error": { "code": "validation_failed", "message": "2 fields are invalid", "fields": { "email": "Enter a valid email address" }, "hint": "…", "docs": "https://api.kibbleforms.com/docs/schema.md" } }
Codes: project_not_found, form_not_found, project_unclaimed (403), origin_not_allowed (403), agents_blocked (403), validation_failed (400), schema_rejected (400), rate_limited (429, with Retry-After), unauthorized (401), forbidden (403), not_found (404), conflict (409), invalid_request (400), internal (500).
Submissions (public, used by the SDK)
POST /v1/projects/{projectId}/forms/{slug}/submissions
CORS is limited to the site's domains (kibble domains; sandbox projects: localhost only). Requests without an Origin header are rejected unless they carry the project secret key. Headers: Idempotency-Key (optional, recommended), X-Kibble-Test: 1 (with the secret key; stores a test submission). A localhost Origin on a live project is also stored as test (enriched, never delivered or counted).
{
"schemaHash": "sha256 hex of the canonical schema (the SDK computes it)",
"values": { "email": "[email protected]", "name": "Ada" },
"status": "complete",
"sessionId": "uuid, required for partial",
"pageIndex": 0,
"meta": { "pageUrl": "…", "referrer": "/pricing", "landingPage": "/blog/x", "entryReferrer": "https://…", "utm": { "source": "x" }, "startedAt": "ISO", "honeypot": "", "turnstileToken": "…",
"actor": { "type": "agent", "name": "Claude", "onBehalfOf": "Ada" } }
}
Response 200: { "id": "sub_…", "status": "complete" | "partial" | "test", "success": { …settings.success }, "flags": ["unregistered_schema", "agent"] }. Spam is stored but never revealed to the client; the response says complete. A declared agent (meta.actor.type: "agent") always gets the agent flag, or 403 agents_blocked when the form's policy is block.
Project API (secret key)
Authorization: Bearer sk_…. This is what the CLI uses.
| Method and path | Purpose |
|---|---|
POST /v1/projects |
Create a sandbox project (no auth; rate limited). Returns the secret key once and the claim link. |
POST /v1/projects/link |
Connect a repo to a site created in the dashboard (no auth; same rate limit as provisioning): { "code": "kbl_XXXXX-XXXXX-XXXXX-XXXXX" } → { project, secretKey, drafts: [{ slug, name, template, brief }] }. Codes are single use and last 24 hours (400 when used or expired: copy a fresh prompt). A site that already has a key gets 409 conflict unless the code is a reconnect code, which replaces the key (the old repo stops working). kibble init --link <code> calls this. |
GET /v1/project |
Status: mode, claim link, origins, workspace, forms with 7-day counts. Drafts are listed with status: "draft" and null version fields. |
PUT /v1/project/forms/{slug} |
Register a schema: { "schema": …, "source"?: { "env": "production" | "preview" | "development" | "unknown", "branch"?, "commit"?, "url"? } }. The framework plugin and kibble sync fill source from the host; without it the push is unknown and never changes production. A production push makes this version the form's questions (productionVersionNumber) and marks the form live; preview and development pushes register versions without changing production or, once the form is live, its settings. Returns the version, whether it was created, the diff and warnings (type changes on kept fields, removed fields that have answers). |
GET /v1/project/forms |
List forms, each with status: draft (created in the dashboard, waiting for its schema file), active or archived. Pushing a draft's slug registers its first version and makes it active, keeping its notifications and triage settings. Drafts take no responses and have no schema.json (404 form_not_found). |
GET /v1/project/forms/{slug} |
Form (versionNumber = latest registration, productionVersionNumber, liveAt), settings, and versions with fields and provenance (env, branch, commit, deployUrl, productionAt). |
GET /v1/project/forms/{slug}/submissions?status=&q=&limit=&cursor= |
Paged submissions, newest first. status is what a response is: all (default), complete, partial, spam, test. q searches the answers. |
GET /v1/project/forms/{slug}/submissions/{id} |
One submission. |
PATCH /v1/project/forms/{slug}/submissions/{id} |
{ "category": "<review category>", "fit": "high" | "medium" | "low", "groups": { "<group key>": "<option>" } } (any of them). They correct triage (see AI review). Every change is recorded in the response's history. Other fields are read only. Returns the updated record. |
GET /v1/project/forms/{slug}/export.csv?status=&q= |
CSV: id, status, ai_summary, ai_category, ai_fit, ai_group_<key> per triage group, created_at, one column per field across versions, page_url, referrer, landing_page, entry_referrer, utm_*, flags. status defaults to complete. |
GET / PUT /v1/project/forms/{slug}/review |
Triage settings: { "review": { "enabled"?, "idealResponse"?, "summary"?, "categories"?, "groups"? }, "effective": { … } }. groups is up to 5 { key?, name, options, hint? }: triage picks one option in each. summary is what each summary should focus on, in plain words. PUT replaces them with the body you send. |
GET / POST / DELETE /v1/project/domains |
The site's domains: { "domains": ["https://acme.com", "https://*.acme.com"] }. POST { "domain": "acme.com" } adds one (normalized); DELETE ?domain=acme.com removes one. |
GET / PUT / DELETE /v1/project/turnstile |
The site's Cloudflare Turnstile widget: { "siteKey": "0x4AAAAAAA…" | null, "forms": [{ "slug": "contact", "siteKey": "0x4AAAAAAA…", "verified": true }], "note"?: "…" }. PUT { "siteKey", "secret" } stores the secret key encrypted after Cloudflare accepts it (it is never returned); DELETE removes it. See Turnstile. |
GET /v1/project/destinations |
List destinations (never includes secrets; a channel connected with "Add to Slack" shows as #channel (Workspace)). |
POST /v1/project/destinations |
{ type: 'email' | 'webhook' | 'slack' | 'postgres', formSlug | allForms: true, email?, url?, connectionString?, table?, schema?, label?, filter?: { categories?, fit?, groups?: { <group key>: [options] } } }. Destinations are per form; allForms creates one per form and returns { destinations }, formSlug returns { destination }. Email goes to workspace members only. filter is for email and Slack only. Webhooks return the signing secret once. |
POST /v1/project/destinations/slack-link |
{ formSlug } | { allForms: true } → 201 { url, expiresAt, formSlug }: a 30-minute link to the dashboard's "Add to Slack" for a person in the site's workspace (claimed sites only). Webhook and database URLs must be public https; see destinations. |
DELETE /v1/project/destinations/{id} |
Remove a destination. |
POST /v1/project/destinations/{id}/test |
Send a sample submission to it. |
Public, no auth: GET /v1/projects/{projectId}/forms/{slug}/schema.json describes a form for agents (fields, conditions, agent policy, an example request). See Agent-native forms.
Submission record:
{
"id": "sub_…", "formId": "frm_…", "schemaVersionId": "sv_…", "versionNumber": 3,
"status": "complete", "values": { "email": "[email protected]" }, "extra": {},
"sessionId": null, "pagesCompleted": null, "flags": [], "spamScore": 0, "spamReasons": [],
"meta": { "pageUrl": "…", "referrer": "/pricing", "landingPage": "/blog/x", "entryReferrer": "https://news.ycombinator.com", "utm": {}, "userAgent": "…", "country": "DE" },
"enrichments": { "email": { "domain": "b.co", "isFreeMail": false, "isRole": false, "isDisposable": false, "mxValid": true }, "duplicate": null, "velocity": { "ipSubmissions1h": 0 } },
"review": { "summary": "…", "category": "Sales lead", "fit": "high", "fitReason": "…", "facts": { "company": "…" }, "spam": { "likely": false, "reason": null }, "model": "gpt-6-luna", "reviewedAt": "…" },
"createdAt": "…", "completedAt": "…"
}
status is what a response is (Kibble sets it). review is triage, null when review is off, pending or failed.
Read API (workspace API key)
Authorization: Bearer kb_…, created in the dashboard. Read only.
| Method and path | Purpose |
|---|---|
GET /v1/forms |
All forms in the workspace. |
GET /v1/forms/{formId}/submissions |
Same query parameters and shape as above. |
GET /v1/forms/{formId}/submissions/{id} |
One submission. |
GET /v1/forms/{formId}/export.csv |
CSV, same columns and filters as above. |
Rate limits
Per IP 60 submissions a minute, per form and per origin 600 a minute, provisioning 5 an hour per IP. Over the limit returns 429 with Retry-After. The SDK retries 5xx and network errors, never 4xx.