Recevoir des webhooks
Des handlers de webhook vérifiés pour un route handler Next.js, Express, un Cloudflare Worker et Python.
Chaque handler fait les mêmes quatre choses : lire le corps brut, vérifier la signature, ignorer les livraisons déjà traitées et répondre 2xx en moins de 15 secondes. Le payload et les en-têtes sont décrits sur Webhooks.
Route handler Next.js
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() donne le corps brut. N'appelez pas request.json() avant ; un JSON resérialisé ne correspond plus à la 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" }) garde le corps sous forme de la chaîne exacte envoyée par Ikiform. Un express.json() global l'analyserait d'abord : montez donc la route du webhook avant lui, ou excluez ce chemin.
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;
}
},
};Le SDK n'utilise que Web Crypto, il tourne donc dans Workers, Deno et Bun sans polyfill.
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 "", 204Gérer les doublons
Une livraison peut arriver plusieurs fois, par exemple quand votre serveur l'a traitée mais que la réponse a expiré. L'en-tête webhook-id et l'id du payload sont l'id de la livraison et restent identiques d'une tentative à l'autre. Stockez-le avec une contrainte d'unicité et ignorez le travail déjà fait :
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);
}Tester en local
- Ikiform ne livre qu'à des URL
https:publiques sur le port 443, donclocalhostne fonctionnera pas. Exposez votre serveur local avec un tunnel qui vous donne une URL HTTPS publique, et utilisez cette URL pour un endpoint de test. - Utilisez Send test sur la page de l'endpoint. Il envoie un événement
endpoint.testune seule fois, sans nouvelle tentative. - Collez une vraie livraison dans le vérificateur de signature pour contrôler votre secret et vos en-têtes dans le navigateur.