Receiving webhooks
Verified webhook handlers for a Next.js route handler, Express, a Cloudflare Worker and Python.
Every handler does the same four things: read the raw body, verify the signature, ignore deliveries it has already handled, and answer 2xx within 15 seconds. The payload and headers are described on Webhooks.
Next.js route handler
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") {
await saveLead(event.id, event.data.fields);
}
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response(error.reason, { status: 400 });
}
throw error;
}
}request.text() gives the raw body. Don't call request.json() first; re-serialized JSON no longer matches the signature.
Express
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
const app = express();
app.post("/webhooks/ikiform", express.text({ type: "application/json" }), async (req, res) => {
try {
const event = await verifyWebhook({
payload: req.body,
headers: req.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET,
});
if (event.type === "submission.created") await saveLead(event.id, event.data.fields);
res.status(204).end();
} catch (error) {
if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason);
res.status(500).end();
}
});
app.listen(3000);express.text({ type: "application/json" }) keeps the body as the exact string Ikiform sent. A global express.json() would parse it first, so mount the webhook route before it or exclude this path.
Cloudflare Worker
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
interface Env {
IKIFORM_WEBHOOK_SECRET: string;
LEADS: KVNamespace;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
if (request.method !== "POST") return new Response(null, { status: 405 });
const payload = await request.text();
try {
const event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
if (event.type === "submission.created") {
ctx.waitUntil(env.LEADS.put(event.id, JSON.stringify(event.data)));
}
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
},
};The SDK uses Web Crypto only, so it runs in Workers, Deno and Bun with no polyfills.
Python
import base64
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
secret = base64.b64decode(os.environ["IKIFORM_WEBHOOK_SECRET"].removeprefix("whsec_"))
def verify(body: bytes, headers) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > 300:
return False
content = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(secret, content, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
@app.post("/webhooks/ikiform")
def ikiform_webhook():
body = request.get_data()
if not verify(body, request.headers):
abort(400)
event = request.get_json()
if event["type"] == "submission.created":
save_lead(event["id"], event["data"]["fields"])
return "", 204Handle duplicates
A delivery can arrive more than once, for example when your server handled it but the response timed out. The webhook-id header and the payload's id are the delivery id and stay the same across retries. Store it with a unique constraint and skip work you have already done:
async function saveLead(deliveryId: string, fields: Record<string, unknown>) {
const inserted = await db.query(
"insert into leads (delivery_id, fields) values ($1, $2) on conflict (delivery_id) do nothing returning id",
[deliveryId, JSON.stringify(fields)],
);
if (inserted.rowCount === 0) return;
await notifySales(fields);
}Testing locally
- Ikiform only delivers to public
https:URLs on port 443, solocalhostwon't work. Expose your local server with a tunnel that gives you a public HTTPS URL, and use that URL for a test endpoint. - Use Send test on the endpoint page. It sends an
endpoint.testevent once, with no retries. - Paste a real delivery into the signature verifier to check your secret and headers in the browser.