Docs

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.