# Webhook を受け取る

> Next.js の Route Handler、Express、Cloudflare Worker、Python 向けの、検証付き Webhook ハンドラー。

どのハンドラーも同じ 4 つのことを行います。生のボディを読み取り、署名を検証し、処理済みの配信を無視し、15 秒以内に `2xx` で応答します。ペイロードとヘッダーは [Webhook](https://ikiform.com/ja/docs/webhooks.md) で説明しています。

## Next.js の Route Handler

`app/api/webhooks/ikiform/route.ts`:

```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()` で生のボディを取得します。先に `request.json()` を呼ばないでください。再シリアライズした JSON は署名と一致しなくなります。

## Express

`server.js`:

```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" })` は、ボディを Ikiform が送ったとおりの文字列のまま保持します。グローバルな `express.json()` が先に解析してしまうので、Webhook のルートはその前にマウントするか、このパスを除外してください。

## Cloudflare Worker

`src/index.ts`:

```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 は Web Crypto だけを使うので、ポリフィルなしで Workers、Deno、Bun で動きます。

## Python

`app.py`:

```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 "", 204
```

## 重複に対処する

サーバーは処理したもののレスポンスがタイムアウトした場合など、同じ配信が複数回届くことがあります。`webhook-id` ヘッダーとペイロードの `id` は配信 ID で、リトライしても変わりません。一意制約を付けて保存し、処理済みの作業はスキップしてください:

`leads.ts`:

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

## ローカルでテストする

- Ikiform が配信するのはポート 443 の公開 `https:` URL だけなので、`localhost` は使えません。公開 HTTPS URL を発行するトンネルでローカルサーバーを公開し、その URL をテスト用のエンドポイントに使ってください。
- エンドポイントのページで **Send test** を使います。`endpoint.test` イベントを 1 回だけ、リトライなしで送ります。
- 実際の配信を[署名の検証ツール](https://ikiform.com/ja/docs/webhooks.md#verifier)に貼り付けると、シークレットとヘッダーをブラウザーで確認できます。
