Destinations: email, Slack, webhooks, your database
A destination is where a form's completed submissions go. Destinations belong to one form; "Add to all forms" in the dashboard and --all-forms in the CLI create one per form. Add them in the dashboard or with the CLI, so an agent can wire them without a browser:
npx kibble destinations add slack --form demo-request # prints an "Add to Slack" link for a person
npx kibble destinations add slack https://hooks.slack.com/services/T000/B000/XXXX --form demo-request
npx kibble destinations add postgres "postgres://postgres.abcd:[email protected]:6543/postgres" --form demo-request
npx kibble destinations add email [email protected] --all-forms # a workspace member
npx kibble destinations add webhook https://example.com/hooks/kibble --form demo-request
npx kibble destinations list
npx kibble destinations test dst_…
Partials, spam and test submissions are never delivered. Sandbox projects never deliver.
Public addresses only. Webhook URLs must be https://, and webhooks and databases must be on the public internet: Kibble refuses localhost, private and link-local ranges (cloud metadata), internal names like *.internal, and names that resolve to any of those, when you add the destination and again at every delivery. To try a webhook running on your machine, expose it with a tunnel (ngrok, cloudflared), add the tunnel's URL, and send it a sample with kibble destinations test dst_…. Test submissions (kibble test, or a live site's form on localhost) are never delivered.
Email goes to workspace members only. The person who claims a site (or creates it in the dashboard) gets email notifications on every form automatically, including forms added later; invite teammates to the workspace to add them. Outside addresses are refused (use Slack for shared inboxes and aliases).
Filters
Email and Slack destinations can be limited to some triage categories, fit levels and picks in the form's groups, so #sales only hears about high-fit leads while support questions go to the support inbox:
curl -X POST "$API/v1/project/destinations" -H "Authorization: Bearer $KIBBLE_SECRET_KEY" -H 'Content-Type: application/json' \
-d '{ "type": "slack", "formSlug": "demo-request", "url": "https://hooks.slack.com/services/…", "filter": { "categories": ["Sales lead"], "fit": ["high", "medium"] } }'
categories: the form's review categories (strings, up to 40 characters).fit: any ofhigh,medium,low.groups: options per group key, e.g.{ "urgency": ["This week"] }. All optional; a response must match every list that is set. A group the response has no pick in (an older review) doesn't filter.- CLI:
--categories "Sales lead" --fit high --group "Urgency: This week, This quarter"(repeat--group; the key comes from the group's name). - No filter, or empty lists, means every response.
- Filters fail open: when a response has no review (the form has review off, the review failed or timed out, or the monthly review limit was reached), it is delivered.
- Webhooks and databases cannot be filtered; they receive every completed response so your own systems stay complete. Filter on
review.category,review.fitandreview.groupsthere. - In the dashboard, each email and Slack destination has a "Send: All responses ▾" menu for the same setting.
Slack
Add to Slack (the easy way): in the form's Delivery settings, "Add Slack channel" → Add to Slack. Slack asks which channel (private channels too), and the channel shows up by name. Kibble's Slack app only gets permission to post to the channel you pick; it can't read messages or see other channels.
From the CLI, leave out the URL. An agent can't click through Slack, so the CLI prints a link for a person in the site's workspace and waits until they've picked the channel:
npx kibble destinations add slack --form demo-request # or --all-forms; --no-wait to print the link and exit
The link works for 30 minutes and only for members of the site's workspace (it signs them in first). --json prints { connectUrl, expiresAt } without waiting; kibble destinations list shows the channel once it's connected. API: POST /v1/project/destinations/slack-link with { "formSlug": "…" } or { "allForms": true }.
An incoming webhook URL still works (Slack: create an app → Incoming Webhooks → add one for the channel), for workspaces that don't allow installing apps.
Each completed submission posts a message with the form name, Kibble's summary, then the fit, category and each group's pick with why it fits on the line under them (when triage is on), up to 10 fields ("+N more fields" when there are more; long answers are shortened to 200 characters), flags such as agent, the page URL, where the person came from ("From /pricing · via google / cpc · landed on /blog/launch"; see attribution), and a button to the submission in Kibble.
Your database (Postgres, Supabase)
Kibble writes each completed submission into a table in your own Postgres database. Nothing to build: give it a connection string and it keeps the table in sync with your schema.
- The table is created on first delivery, default name
kibble_<slug>, schemapublic. - One column per field, named after the field key, typed by field type (
text,double precision,integer,boolean,date,text[]). New fields in a new schema version add columns automatically; removed fields keep their column. - System columns:
kibble_id(primary key),kibble_form,kibble_status,kibble_flags,kibble_enrichments,kibble_review(the AI review as JSON),kibble_ai_category,kibble_ai_fit, onekibble_ai_<group key>text column per triage group (added when a group is),kibble_meta,kibble_values(the full values as JSON),kibble_submitted_at,kibble_received_at. - Writes are upserts on
kibble_id, so retries and replays never duplicate rows. - The connection string is encrypted at rest and never shown again.
- The host must be public (see above). If Kibble can't connect, the error says so without detail about the network (a wrong password or a missing database is reported as such).
Supabase: Project settings → Database → Connection string → Transaction pooler (port 6543), URI format. Use a dedicated database role with CREATE on the schema if you prefer not to use postgres. Row Level Security does not apply to this role's inserts, so read the table through your normal policies.
Webhooks
Signed POSTs with retries for 24 hours and replay from the dashboard. See Webhooks and email. Use a webhook when you want to run your own logic (create a CRM lead, start a sequence) rather than store rows.
A recipe: Supabase from a webhook instead
If you would rather own the insert, a Next.js route handler is about twenty lines:
// app/api/kibble/route.ts
import { createClient } from '@supabase/supabase-js';
import { createHmac, timingSafeEqual } from 'node:crypto';
export async function POST(req: Request) {
const raw = await req.text();
const header = req.headers.get('kibble-signature') ?? '';
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]));
const expected = createHmac('sha256', process.env.KIBBLE_WEBHOOK_SECRET!).update(`${t}.${raw}`).digest('hex');
if (!v1 || expected.length !== v1.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) return new Response('bad signature', { status: 401 });
const event = JSON.parse(raw);
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const { error } = await supabase.from('demo_requests').upsert({ id: event.data.id, ...event.data.values, category: event.data.review?.category, received_at: event.createdAt });
return error ? new Response(error.message, { status: 500 }) : new Response('ok');
}
Return a 2xx only after the insert succeeds; Kibble retries anything else.