Form ID

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

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") {
      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

server.js
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

src/index.ts
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

app.py
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 "", 204

Handle 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:

leads.ts
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, so localhost won'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.test event once, with no retries.
  • Paste a real delivery into the signature verifier to check your secret and headers in the browser.