Recebendo webhooks
Handlers de webhook com verificação para um route handler do Next.js, Express, um Cloudflare Worker e Python.
Todo handler faz as mesmas quatro coisas: lê o corpo bruto, verifica a assinatura, ignora entregas que já tratou e responde 2xx em até 15 segundos. O payload e os headers estão descritos em Webhooks.
Route handler do 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() dá o corpo bruto. Não chame request.json() antes; um JSON serializado de novo não corresponde mais à assinatura.
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" }) mantém o corpo exatamente como a string que o Ikiform enviou. Um express.json() global o leria antes, então monte a rota do webhook antes dele ou exclua este caminho.
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;
}
},
};O SDK usa só Web Crypto, então roda em Workers, Deno e Bun sem 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 "", 204Trate duplicados
Uma entrega pode chegar mais de uma vez, por exemplo quando seu servidor a processou mas a resposta expirou. O header webhook-id e o id do payload são o id da entrega e continuam os mesmos entre as novas tentativas. Guarde-o com uma restrição de unicidade e pule o trabalho que você já fez:
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);
}Testando localmente
- O Ikiform só entrega para URLs
https:públicas na porta 443, entãolocalhostnão funciona. Exponha seu servidor local com um túnel que dê uma URL HTTPS pública e use essa URL em um endpoint de teste. - Use Send test na página do endpoint. Ele envia um evento
endpoint.testuma vez, sem novas tentativas. - Cole uma entrega real no verificador de assinatura para conferir seu segredo e seus headers no navegador.