Form ID

Webhooks

Signed webhook payloads, verifying the HMAC-SHA256 signature, the retry schedule and delivery logs.

Webhooks push each accepted submission to your server as it arrives. Deliveries are signed with the Standard Webhooks scheme, retried for about three days, and logged attempt by attempt in the dashboard. Ready-made handlers are in Receiving webhooks.

Add an endpoint

  1. In the dashboard, open Webhooks and choose Add endpoint.
  2. Enter an HTTPS URL and pick one form, or all forms in the workspace.
  3. Copy the signing secret. It starts with whsec_ and is shown in full only once.
  4. Send a test event to check your handler.

Endpoint URLs

  • https: only, on the default port 443, with no username or password in the URL, up to 2,048 characters.
  • A public hostname. IP addresses, localhost, and names ending in .local, .internal, .intranet, .lan, .home.arpa or .onion are refused, as are ikiform.com, workers.dev and pages.dev.
  • The hostname must resolve only to public addresses. Private, loopback, link-local and carrier-grade NAT ranges are refused.
  • The URL is checked when you save it and again before every delivery. An endpoint that starts resolving to a private address is disabled.
  • A workspace can have up to 3 endpoints. Enterprise has no limit.

Events

TypeSent whenRetried
submission.createdA submission is accepted, or a spam submission is marked as not spamYes
endpoint.testYou choose Send test on the endpoint pageNo, one attempt

Spam never triggers a webhook. Neither do submissions held because the workspace is over its monthly limit.

The request

Each delivery is a POST with a JSON body and three signature headers, sent from Ikiform's delivery service with the user agent Ikiform-Webhooks/1.0.

Headers
POST /webhooks/ikiform HTTP/1.1
content-type: application/json
user-agent: Ikiform-Webhooks/1.0
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
HeaderValue
webhook-idThe delivery id. The same on every retry of the same delivery, so use it to ignore duplicates
webhook-timestampUnix seconds when this attempt was signed. New on every attempt
webhook-signaturev1, plus a base64 HMAC-SHA256. Several space-separated values while a secret rotates

Payloads

{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": false
  }
}
FieldDescription
idThe delivery id, the same as webhook-id
typesubmission.created or endpoint.test
timestampWhen the delivery was created, in ISO 8601 UTC. The same on every retry
api_versionThe payload version the endpoint was created with, currently 2026-10-17
data.submission_idThe submission's id. Fetch it with the REST API
data.formThe form's id and name
data.fieldsThe stored values. With fields defined, these are the validated, coerced values
data.metaThe same keys as the REST API's meta. Every key is present and any value can be null. The visitor's IP and its hash are never sent
data.meta.user_agentThe browser's user agent, up to 512 characters
data.meta.origin, referrerWhere the post came from, when the browser sent them
data.meta.countryTwo-letter country code, from Cloudflare
data.meta.subject, reply_toFrom the _subject and _replyto fields
data.created_atWhen the submission was stored
data.truncatedtrue when the body would be over 256 KB, in which case fields is empty

Any meta value can be null. Treat field values as untrusted input, just like a form post to your own server.

Verify the signature

How the webhook signature is computed
webhook-id.webhook-timestamp.raw body

HMAC-SHA256, keyed with the base64-decoded secret after whsec_

webhook-signature: v1,base64(signature)

  • Use the raw request body exactly as received. Parsing and re-serializing JSON changes the bytes.
  • The header can hold several space-separated signatures. Accept the request if any v1 signature matches.
  • Compare in constant time, and reject timestamps more than 5 minutes from now.

With the SDK

verifyWebhook does all of the above with Web Crypto and returns the parsed event, or throws WebhookVerificationError with a reason. It accepts a Headers object or a plain record such as Node's req.headers.

app/api/webhooks/ikiform/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    if (event.type === "submission.created") {
      console.log(event.data.fields);
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(error.reason, { status: 400 });
    }
    throw error;
  }
}

Without the SDK

Any Standard Webhooks library works too. By hand it takes a few lines:

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

const secret = Buffer.from(process.env.IKIFORM_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

export function verify(body, headers) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest();
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

Signature verifier

Ignore the 5 minute window

Uses the SDK's verifyWebhook with Web Crypto in your browser. Nothing you paste leaves this page.

Responding

Your responseWhat Ikiform does
Any 2xx within 15 secondsMarks the delivery succeeded
410 GoneFails the delivery and disables the endpoint right away
429 or 503 with Retry-AfterRetries after that delay, but no later than the next step of the schedule
A redirectNot followed. Counts as a failure
Any other status, a timeout or a network errorRetries on the schedule below

Do slow work after responding, for example in a queue. The first 4 KB of your response body is saved in the delivery log.

Retries

A failed attempt is retried on this schedule, each delay with up to 20% random jitter either way. After the 9th attempt, about 3 d 6 h after the first, the delivery is marked failed.

Webhook retry schedule, with the delay before each attempt on a log scale
AttemptWait before itSince first
  1. 1now0 s
  2. 25 s5 s
  3. 35 min5 min
  4. 430 min35 min
  5. 52 h2 h 35 min
  6. 65 h7 h 35 min
  7. 710 h17 h 35 min
  8. 81 d1 d 18 h
  9. 91 d 12 h3 d 6 h

Dark bars show the shortest wait and light bars the longest, from 20% jitter either way. After attempt 9 the delivery is marked failed.

Disabled endpoints

If every delivery to an endpoint fails for 5 days, the endpoint is disabled and workspace owners get an email. A 410 Gone response or a destination that becomes private disables it immediately. Turn it back on from the endpoint page once it is fixed.

Delivery logs

Each endpoint page lists its deliveries with their status. Expand one to see every attempt: when it ran, whether it was automatic, manual or a test, the status code, the duration, and the first 4 KB of your response or the error. Retry any delivery from there. Attempts are kept for 30 days and deliveries for 90 days.

Rotating secrets

Roll signing secret creates a new secret. For the next 24 hours every delivery is signed with both the old and the new secret, so webhook-signature holds two values and you can deploy the new secret without dropping events.