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
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
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
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
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 "", 204Duplikate 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:
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 funktioniertlocalhostnicht. 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.