Formular-ID

Webhooks empfangen

Verifizierte Webhook-Handler für einen Next.js Route Handler, Express, einen Cloudflare Worker und Python.

Jeder Handler macht dieselben vier Dinge: den rohen Body lesen, die Signatur prüfen, bereits verarbeitete Zustellungen ignorieren und innerhalb von 15 Sekunden mit 2xx antworten. Payload und Header sind unter Webhooks beschrieben.

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() liefert den rohen Body. Ruf nicht vorher request.json() auf; neu serialisiertes JSON passt nicht mehr zur Signatur.

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" }) behält den Body als genau den String, den Ikiform gesendet hat. Ein globales express.json() würde ihn vorher parsen, also binde die Webhook-Route davor ein oder schließe diesen Pfad aus.

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;
    }
  },
};

Das SDK nutzt nur Web Crypto und läuft daher ohne Polyfills in Workers, Deno und Bun.

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

Duplikate behandeln

Eine Zustellung kann mehr als einmal ankommen, zum Beispiel wenn dein Server sie verarbeitet hat, die Antwort aber in ein Timeout lief. Der Header webhook-id und die id im Payload sind die Zustellungs-ID und bleiben bei erneuten Versuchen gleich. Speichere sie mit einem Unique-Constraint und überspringe Arbeit, die du schon erledigt hast:

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);
}

Lokal testen

  • Ikiform stellt nur an öffentliche https:-URLs auf Port 443 zu, daher funktioniert localhost nicht. Mach deinen lokalen Server über einen Tunnel mit öffentlicher HTTPS-URL erreichbar und nutze diese URL für einen Test-Endpunkt.
  • Nutze Send test auf der Seite des Endpunkts. Damit wird einmalig ein endpoint.test-Event gesendet, ohne erneute Versuche.
  • Füge eine echte Zustellung in den Signaturprüfer ein, um dein Secret und deine Header im Browser zu prüfen.