ID formulir

Menerima webhook

Handler webhook terverifikasi untuk route handler Next.js, Express, Cloudflare Worker, dan Python.

Setiap handler melakukan empat hal yang sama: membaca body mentah, memverifikasi tanda tangan, mengabaikan pengiriman yang sudah ditangani, dan menjawab 2xx dalam 15 detik. Payload dan header-nya dijelaskan di Webhook.

Route handler Next.js

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() memberi body mentah. Jangan panggil request.json() lebih dulu; JSON yang diserialisasi ulang tidak lagi cocok dengan tanda tangan.

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" }) mempertahankan body sebagai string persis yang dikirim Ikiform. express.json() global akan mengurainya lebih dulu, jadi pasang route webhook sebelum itu atau kecualikan path ini.

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

SDK hanya memakai Web Crypto, jadi berjalan di Workers, Deno, dan Bun tanpa polyfill.

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

Menangani duplikat

Sebuah pengiriman bisa tiba lebih dari sekali, misalnya saat server Anda sudah menanganinya tetapi responsnya timeout. Header webhook-id dan id di payload adalah ID pengiriman dan tetap sama di setiap percobaan ulang. Simpan dengan unique constraint dan lewati pekerjaan yang sudah Anda lakukan:

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

Menguji secara lokal

  • Ikiform hanya mengirim ke URL https: publik di port 443, jadi localhost tidak akan berfungsi. Ekspos server lokal Anda dengan tunnel yang memberi URL HTTPS publik, lalu pakai URL itu untuk endpoint tes.
  • Gunakan Send test di halaman endpoint. Tombol ini mengirim event endpoint.test sekali, tanpa percobaan ulang.
  • Tempel pengiriman sungguhan ke verifikator tanda tangan untuk memeriksa secret dan header Anda di browser.