Docs
After submitWebhooks and email
View as Markdown(opens in a new tab)

Webhooks and email

Completed submissions are delivered to every enabled destination on their form: email, Slack, webhooks and databases. Partials, spam and test submissions never trigger deliveries. Sandbox projects never deliver.

Email

Email notifications go to workspace members: the person who claims the site gets them on every form, and you add teammates per form in the dashboard (Setup → Delivery) or with npx kibble destinations add email <member> --form <slug> (--all-forms for every form). There is no verification step and no outside addresses; use Slack for shared inboxes and aliases. One email per completed submission: Kibble's one-line summary and category first (when triage is on), then each field, and a link to the response.

Webhooks

Add a URL in the form's Setup → Delivery in the dashboard, or npx kibble destinations add webhook <url> --form <slug>. You get a signing secret once; store it as an environment variable.

Request: POST with Content-Type: application/json, User-Agent: Kibble-Webhooks/1, Kibble-Delivery-Id, Kibble-Signature.

{
  "id": "dlv_…",
  "type": "submission.completed",
  "createdAt": "2026-10-13T12:00:00.000Z",
  "data": { "id": "sub_…", "status": "complete", "values": { "email": "[email protected]" }, "form": { "id": "frm_…", "slug": "waitlist", "name": "Waitlist" }, "meta": { … }, "versionNumber": 3, … }
}

Verify the signature

Kibble-Signature: t=1760356800,v1=<hex> where v1 = HMAC-SHA256(secret, "<t>.<raw body>").

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyKibbleSignature(rawBody: string, header: string, secret: string, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Respond with any 2xx within 10 seconds. Anything else is retried.

Retries and replay

Backoff: 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, then every 6 hours until 24 hours after the first attempt. The delivery log in the dashboard shows every attempt with the response code. Replay any delivery with one click; a replay is a fresh delivery with the same payload.