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
- In the dashboard, open Webhooks and choose Add endpoint.
- Enter an HTTPS URL and pick one form, or all forms in the workspace.
- Copy the signing secret. It starts with
whsec_and is shown in full only once. - 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.arpaor.onionare 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
| Type | Sent when | Retried |
|---|---|---|
submission.created | A submission is accepted, or a spam submission is marked as not spam | Yes |
endpoint.test | You choose Send test on the endpoint page | No, 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.
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=| Header | Value |
|---|---|
webhook-id | The delivery id. The same on every retry of the same delivery, so use it to ignore duplicates |
webhook-timestamp | Unix seconds when this attempt was signed. New on every attempt |
webhook-signature | v1, 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
}
}| Field | Description |
|---|---|
id | The delivery id, the same as webhook-id |
type | submission.created or endpoint.test |
timestamp | When the delivery was created, in ISO 8601 UTC. The same on every retry |
api_version | The payload version the endpoint was created with, currently 2026-10-17 |
data.submission_id | The submission's id. Fetch it with the REST API |
data.form | The form's id and name |
data.fields | The stored values. With fields defined, these are the validated, coerced values |
data.meta | The 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_agent | The browser's user agent, up to 512 characters |
data.meta.origin, referrer | Where the post came from, when the browser sent them |
data.meta.country | Two-letter country code, from Cloudflare |
data.meta.subject, reply_to | From the _subject and _replyto fields |
data.created_at | When the submission was stored |
data.truncated | true 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
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
v1signature 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.
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
Uses the SDK's verifyWebhook with Web Crypto in your browser. Nothing you paste leaves this page.
Responding
| Your response | What Ikiform does |
|---|---|
Any 2xx within 15 seconds | Marks the delivery succeeded |
410 Gone | Fails the delivery and disables the endpoint right away |
429 or 503 with Retry-After | Retries after that delay, but no later than the next step of the schedule |
| A redirect | Not followed. Counts as a failure |
| Any other status, a timeout or a network error | Retries 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.
- 1now0 s
- 25 s5 s
- 35 min5 min
- 430 min35 min
- 52 h2 h 35 min
- 65 h7 h 35 min
- 710 h17 h 35 min
- 81 d1 d 18 h
- 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.