# Webhook

> 署名付きの Webhook ペイロード、HMAC-SHA256 署名の検証、リトライのスケジュール、配信ログ。

Webhook は、受け付けた送信データを届いたそばからあなたのサーバーに送ります。配信は [Standard Webhooks](https://www.standardwebhooks.com) 方式で署名され、約 3 日間リトライされ、ダッシュボードに試行ごとに記録されます。すぐに使えるハンドラーは [Webhook を受け取る](https://ikiform.com/ja/docs/guides/webhook-handlers.md)にあります。

## エンドポイントを追加する

1. ダッシュボードで **Webhooks** を開き、**Add endpoint** を選びます。
2. HTTPS の URL を入力し、フォームを 1 つ、またはワークスペースのすべてのフォームを選びます。
3. 署名シークレットをコピーします。`whsec_` で始まり、全体が表示されるのは 1 回だけです。
4. テストイベントを送って、ハンドラーを確認します。

### エンドポイントの URL

- `https:` のみ。デフォルトのポート 443 で、URL にユーザー名やパスワードを含めず、最大 2,048 文字。
- 公開されたホスト名。IP アドレス、`localhost`、`.local`、`.internal`、`.intranet`、`.lan`、`.home.arpa`、`.onion` で終わる名前は拒否されます。ikiform.com、workers.dev、pages.dev も同様です。
- ホスト名は公開アドレスだけに解決される必要があります。プライベート、ループバック、リンクローカル、キャリアグレード NAT の範囲は拒否されます。
- URL は保存時と、毎回の配信の前にチェックされます。プライベートアドレスに解決されるようになったエンドポイントは無効化されます。
- 1 つのワークスペースに登録できるエンドポイントは 3 件までです。Enterprise には上限がありません。

## イベント

| タイプ | 送信されるタイミング | リトライ |
| --- | --- | --- |
| `submission.created` | 送信データが受け付けられたとき、またはスパムの送信データがスパムではないとマークされたとき | あり |
| `endpoint.test` | エンドポイントのページで **Send test** を選んだとき | なし（1 回のみ） |

スパムで Webhook が送られることはありません。ワークスペースが月間の上限を超えて保留された送信データでも送られません。

## リクエスト

各配信は JSON のボディと 3 つの署名ヘッダーを持つ `POST` で、Ikiform の配信サービスからユーザーエージェント `Ikiform-Webhooks/1.0` で送られます。

`ヘッダー`:

```http
POST /webhooks/ikiform HTTP/1.1
content-type: application/json
user-agent: Ikiform-Webhooks/1.0
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

| ヘッダー | 値 |
| --- | --- |
| `webhook-id` | 配信 ID。同じ配信のリトライでは毎回同じなので、重複の無視に使います |
| `webhook-timestamp` | この試行に署名した時刻の Unix 秒。試行ごとに新しくなります |
| `webhook-signature` | `v1,` に base64 の HMAC-SHA256 を続けたもの。シークレットのローテーション中は、スペース区切りの複数の値になります |

### ペイロード

**submission.created** (`ボディ`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": false
  }
}
```

**endpoint.test** (`ボディ`)

```json
{
  "id": "dlv_01k7v2f0a6c8e2g4j6m8p0r2t4",
  "type": "endpoint.test",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "endpoint_id": "whk_01k7v2e1b3d5f7h9k1m3n5q7s9",
    "message": "This is a test event from Ikiform."
  }
}
```

**切り詰め** (`256 KB 超`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": {},
    "meta": { "origin": "https://example.com", "country": "GB" },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": true
  }
}
```

| フィールド | 説明 |
| --- | --- |
| `id` | 配信 ID。`webhook-id` と同じ |
| `type` | `submission.created` または `endpoint.test` |
| `timestamp` | 配信が作成された日時（ISO 8601、UTC）。リトライでも同じ |
| `api_version` | エンドポイントの作成時のペイロードのバージョン。現在は `2026-10-17` |
| `data.submission_id` | 送信データの ID。[REST API](https://ikiform.com/ja/docs/api.md#get-submission) で取得できます |
| `data.form` | フォームの `id` と `name` |
| `data.fields` | 保存された値。フィールドが定義されている場合は、バリデーションと型変換を経た値 |
| `data.meta` | REST API の `meta` と同じキー。すべてのキーが存在し、どの値も `null` になりえます。訪問者の IP やそのハッシュが送られることはありません |
| `data.meta.user_agent` | ブラウザーのユーザーエージェント。最大 512 文字 |
| `data.meta.origin`, `referrer` | ブラウザーが送った場合の、送信元 |
| `data.meta.country` | 2 文字の国コード。Cloudflare から取得 |
| `data.meta.subject`, `reply_to` | `_subject` と `_replyto` フィールドから |
| `data.created_at` | 送信データが保存された日時 |
| `data.truncated` | ボディが 256 KB を超える場合は `true` で、その場合 `fields` は空になります |

`meta` の値はどれも `null` になりえます。フィールドの値は、自分のサーバーへのフォーム送信と同じく、信頼できない入力として扱ってください。

## 署名を検証する

署名は、`whsec_` 以降を base64 デコードしたシークレットを鍵とした `{webhook-id}.{webhook-timestamp}.{raw body}` の HMAC-SHA256 を base64 エンコードしたもので、`webhook-signature` の `v1,` の後ろに入っています。

- リクエストの生のボディを、受け取ったとおりに使ってください。JSON を解析して再シリアライズするとバイト列が変わります。
- ヘッダーには、スペース区切りで複数の署名が入ることがあります。いずれかの `v1` 署名が一致すれば、リクエストを受け入れます。
- 比較は定数時間で行い、現在時刻から 5 分以上ずれたタイムスタンプは拒否してください。

### SDK を使う

`verifyWebhook` は上記のすべてを Web Crypto で行い、解析済みのイベントを返すか、`reason` 付きの `WebhookVerificationError` を投げます。`Headers` オブジェクトも、Node の `req.headers` のような単純なレコードも受け付けます。

`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") {
      console.log(event.data.fields);
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(error.reason, { status: 400 });
    }
    throw error;
  }
}
```

### SDK を使わない

Standard Webhooks のどのライブラリでも動きます。手で書いても数行です:

**Node** (`verify.js`)

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const secret = Buffer.from(process.env.IKIFORM_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

export function verify(body, headers) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest();
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}
```

**Python** (`verify.py`)

```python
import base64
import hashlib
import hmac
import os
import time

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
```

### 署名の検証ツール

このページのインタラクティブな検証ツールは、シークレット、3 つの Webhook ヘッダー、生のボディを SDK の `verifyWebhook` で、すべてブラウザー内で確認します。正しく署名されたサンプルの配信を生成することもできます。貼り付けた内容がページの外に出ることはありません。

## 応答する

| あなたの応答 | Ikiform の動作 |
| --- | --- |
| 15 秒以内の `2xx` | 配信を成功として記録 |
| `410 Gone` | 配信を失敗にし、エンドポイントをすぐに無効化 |
| `Retry-After` 付きの `429` または `503` | その時間が経ってからリトライ。ただしスケジュールの次の段階より遅くはしない |
| リダイレクト | 従わず、失敗として扱う |
| その他のステータス、タイムアウト、ネットワークエラー | 下記のスケジュールでリトライ |

時間のかかる処理は、キューなどを使って応答のあとに行ってください。レスポンスボディの先頭 4 KB が配信ログに保存されます。

## リトライ

失敗した試行は、このスケジュールでリトライされます。各待ち時間には前後最大 20% のランダムなジッターが入ります。9 回目の試行（初回から約 3日6時間後）のあと、配信は失敗として記録されます。

| 試行 | 前の試行からの待ち時間 | 初回からの経過時間 |
| --- | --- | --- |
| 1 | すぐ | 0 |
| 2 | 5秒 | 5秒 |
| 3 | 5分 | 5分 |
| 4 | 30分 | 35分 |
| 5 | 2時間 | 2時間35分 |
| 6 | 5時間 | 7時間35分 |
| 7 | 10時間 | 17時間35分 |
| 8 | 1日 | 1日18時間 |
| 9 | 1日12時間 | 3日6時間 |

### 無効化されたエンドポイント

エンドポイントへの配信が 5 日間すべて失敗すると、エンドポイントは無効化され、ワークスペースのオーナーにメールが届きます。`410 Gone` の応答や、送信先がプライベートになった場合はすぐに無効化されます。問題を直したら、エンドポイントのページから再びオンにしてください。

## 配信ログ

各エンドポイントのページには、配信がステータス付きで一覧表示されます。1 つを展開すると、すべての試行を確認できます。実行日時、自動・手動・テストのどれか、ステータスコード、所要時間、レスポンスの先頭 4 KB またはエラーです。そこからどの配信でもリトライできます。試行は 30 日間、配信は 90 日間保持されます。

## シークレットのローテーション

**Roll signing secret** で新しいシークレットを作成します。その後 24 時間は、すべての配信が古いシークレットと新しいシークレットの両方で署名されるので、`webhook-signature` には 2 つの値が入り、イベントを取りこぼさずに新しいシークレットをデプロイできます。
