# フォームの Webhook 署名を検証する

> フォームの Webhook が本当に Ikiform から来たかを確認する方法。Standard Webhooks の HMAC-SHA256 方式、テストベクター、生のボディ、タイムスタンプ、シークレットのローテーションを解説します。

公開日 2026-10-25 · https://ikiform.com/ja/guides/webhook-signature-verification

署名を自分で計算し直し、リクエストに含まれる署名と比較します。Ikiform は Standard Webhooks に準拠しています。署名は、`webhook-id`、`webhook-timestamp`、生のボディをドットでつないだ文字列の HMAC-SHA256 を base64 にしたもので、鍵には `whsec_` シークレットの base64 デコードした部分を使い、`v1,<signature>` の形で送られます。定数時間の比較でいずれかの `v1` 署名が一致し、タイムスタンプが 5 分以内のときだけリクエストを受け付けてください。

Webhook のエンドポイントは、誰からの POST リクエストでも受け付ける公開 URL です。署名を確認しなければ、URL を知った人なら誰でも、偽の送信データを CRM やデータベース、チームのチャットに送り込めます。確認のコードは数行で、その数行を正確に書くことがこのガイドのテーマです。

## 署名の仕組み

Ikiform とあなたのサーバーは 1 つのシークレットを共有します。シークレットはエンドポイントを追加したときに 1 回だけ表示されます。Ikiform は配信を試みるたびに、そのシークレットでリクエストのハッシュを計算し、結果をヘッダーに入れます。サーバーは受け取った内容で同じ計算を繰り返します。一致する値を作れるのはシークレットを持つ人だけで、ボディ、ID、タイムスタンプのどれかが変わると一致しなくなります。

図: Ikiform が Webhook の配信に署名する方法と、サーバーがそれを確認する方法

1. Ikiform がイベントを JSON にシリアライズします。
2. 配信 ID、その試行の Unix タイムスタンプ、ボディをドットでつなぎます。
3. その文字列の HMAC-SHA256 を、エンドポイントの有効なシークレットごとに計算します。
4. 各結果を base64 エンコードして v1 を前に付け、webhook-signature ヘッダーで送ります。
5. ハンドラーは、何かを解析する前に生のボディと 3 つのヘッダーを読みます。
6. 手元のシークレットで同じ HMAC を計算します。
7. すべての v1 署名と定数時間で比較し、タイムスタンプが 5 分以内であることを確認します。
8. 一致すれば 2xx を返してイベントを処理します。一致しなければ 400 を返し、何もしません。

リクエストと一緒に秘密の情報が送られることはありません。署名は、送信者がシークレットを知っていたこと、そしてバイト列が途中で変更されていないことを証明します。

## 方式の詳細

Ikiform は、複数の Webhook 提供元が採用しているオープンな仕様 [Standard Webhooks](https://www.standardwebhooks.com) を使っています。すべての配信には 3 つのヘッダーが付きます。

| ヘッダー | 値 | 注意 |
| --- | --- | --- |
| `webhook-id` | `dlv_...` | 配信 ID。リトライでも毎回同じなので、冪等性キーとしても使えます |
| `webhook-timestamp` | Unix 秒。例: `1792300000` | この試行に署名した時刻。リトライのたびに新しくなります |
| `webhook-signature` | `v1,` と base64 | スペース区切りの 1 つ以上の値。シークレットのローテーション中は 2 つ |

`署名される内容`:

```text
webhook-id:        dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
raw body:          {"id":"dlv_01k7...","type":"endpoint.test",...}

signed content = webhook-id + "." + webhook-timestamp + "." + raw body
key            = base64-decode(secret without "whsec_")
signature      = base64(HMAC-SHA256(key, signed content))
header         = "v1," + signature
```

配信を検証する手順は、次の順番です。

1. 生のボディをバイト列か元のままの文字列として読みます。まだ解析しないでください。
2. 3 つのヘッダーを読みます。どれかが欠けていれば拒否します。
3. タイムスタンプが整数で、自分の時計との差が前後 5 分以内であることを確認します。そうでなければ拒否します。
4. シークレットから `whsec_` を取り除き、残りを base64 デコードします。HMAC の鍵はその 32 バイトで、文字列そのものではありません。
5. `{webhook-id}.{webhook-timestamp}.{raw body}` の HMAC-SHA256 を計算し、標準アルファベットとパディングで base64 エンコードします。
6. `webhook-signature` をスペースで分割します。各要素を最初のカンマで分割し、バージョンが `v1` でないものは飛ばします。
7. 各 `v1` の値を自分の値と定数時間で比較します。どれかが一致すれば、その配信は本物です。
8. そこで初めて JSON を解析し、処理します。

## テストベクター

ハンドラーをデプロイする前に、正しい値を計算できることを確かめましょう。以下の入力は、実際の `endpoint.test` の配信ボディを架空のシークレットで署名したものです。コードは最後の行の署名をそのまま再現しなければなりません。各言語のガイドには、これを使う単体テストが付いています。

| 入力 | 値 |
| --- | --- |
| シークレット | `whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=` |
| `webhook-id` | `dlv_01k7v2d4n8r2p6s0w3y5b7c9e1` |
| `webhook-timestamp` | `1792300000` |
| 生のボディ | 下のスクリプトにあるとおりのバイト列。スペースや改行はなし |
| 期待される `webhook-signature` | `v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=` |

コードを書かなくても確認できます。macOS か Linux のターミナルで実行すると、署名の base64 部分が表示されます。

`ターミナル`:

```bash
SECRET='whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw='
ID='dlv_01k7v2d4n8r2p6s0w3y5b7c9e1'
TS='1792300000'
BODY='{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","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."}}'

KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary \
  | base64
```

実際によくある失敗はこの 2 つなので、それぞれテストしておく価値があります。

| 変更点 | 署名 | ハンドラーは受け付けるべきか |
| --- | --- | --- |
| 同じボディを 2 スペースのインデントで整形したもの | `v1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY=` | いいえ。JSON は同じでもバイト列が違います |
| 1 秒後の `webhook-timestamp` | `v1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks=` | いいえ。タイムスタンプも署名に含まれます |
| ローテーション中に 2 つ目のシークレットで署名したもの | `v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=` | はい。ヘッダーに自分のシークレットと一致する値も含まれていれば |

シークレットのローテーション中、ヘッダーには両方の値が入ります。どちらのシークレットを持つハンドラーでも、これを受け付けなければなりません。

`2 つの署名`:

```http
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=
```

> ベクターのタイムスタンプは固定なので、実際の検証処理では古すぎるとして拒否されます。テストでは固定した現在時刻を渡す必要があり、各言語のガイドはそうしています。Webhooks のページにある[署名検証ツール](https://ikiform.com/ja/docs/webhooks.md#verifier)では、任意のシークレット、ヘッダー、ボディをブラウザーで確認できます。

## 検証の方法

| 方法 | 得意なこと | トレードオフ |
| --- | --- | --- |
| `@ikiform/sdk/webhooks` の `verifyWebhook` | 1 回の呼び出しですべてを確認し、型付きのイベントを返します。Node、Workers、Deno、Bun で動きます | JavaScript と TypeScript のみ |
| Standard Webhooks のライブラリ | Python、Go、PHP、Ruby、JavaScript、Rust、Java、C#、Elixir 向けにメンテナンスされたコード | 依存パッケージが 1 つ増えます。ヘッダーの渡し方はライブラリごとに確認してください。大文字と小文字を区別するものもあります |
| 自前の HMAC コード | 依存なし、約 20 行で、すべての分岐をテストできます | 文字列比較、間違った鍵のバイト列、解析済みのボディなど、気づきにくい誤りをしやすい |
| 代わりに秘密のパスやクエリトークンを使う | シンプル | ログ、プロキシ、エラートラッカーから漏れ、ボディについて何も証明せず、有効期限も設けられません。代わりにはなりません |

Ikiform はすべての配信に署名しており、配信を認証する方法として公式にサポートしているのは署名です。送信元 IP アドレスの固定リストはドキュメントで公開していないので、IP の許可リストは頼れる確認方法ではありません。

## フレームワークで検証する

JavaScript のスタックなら、SDK が処理を引き受けます。フレームワークごとに違うのは、ルートを置く場所と、何かに解析される前にボディを取得する方法です。自分のものを選んでください。

**Next.js**

App Router のルートハンドラーです。`request.text()` が生のボディです。先に `request.json()` を呼ぶと失われます。[自前サーバーのガイド](https://ikiform.com/blog/form-submissions-to-your-own-server.md)では、重複排除と `after()` を追加しています。

`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!,
    });
    console.info(event.type, request.headers.get("webhook-id"));
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}
```

**Astro**

`POST` をエクスポートするエンドポイントです。オンデマンドレンダリングが必要なので、`prerender = false` とアダプターを残してください。`astro:env/server` の `getSecret` は、`IKIFORM_WEBHOOK_SECRET` を実行時に、サーバー上でだけ読みます。`import.meta.env` ではビルドに埋め込まれてしまいます。

`src/pages/api/webhooks/ikiform.ts`:

```ts
import type { APIRoute } from "astro";
import { getSecret } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: getSecret("IKIFORM_WEBHOOK_SECRET") ?? "",
    });
    console.info(event.type, event.id);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
};
```

**TanStack Start**

サーバールートです。同じファイルベースのルートに、標準の `Request` を受け取る `server.handlers.POST` 関数を置きます。

`src/routes/api/webhooks/ikiform.ts`:

```ts
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const Route = createFileRoute("/api/webhooks/ikiform")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const payload = await request.text();
        try {
          const event = await verifyWebhook({
            payload,
            headers: request.headers,
            secret: process.env.IKIFORM_WEBHOOK_SECRET!,
          });
          console.info(event.type, event.id);
          return new Response(null, { status: 204 });
        } catch (error) {
          if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
          throw error;
        }
      },
    },
  },
});
```

**React Router**

リソースルートです。`action` を持ちデフォルトエクスポートのないモジュールを `routes.ts` に登録します。ページを描画せず、Ikiform に直接応答します。

`app/routes.ts`:

```ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("webhooks/ikiform", "routes/webhooks.ikiform.ts"),
] satisfies RouteConfig;
```

`app/routes/webhooks.ikiform.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/webhooks.ikiform";

export async function action({ request }: Route.ActionArgs) {
  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: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    console.info(event.type, event.id);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}
```

**SvelteKit**

`+server.ts` のエンドポイントです。SvelteKit 組み込みの CSRF チェックがブロックするのはクロスサイトのフォームのコンテンツタイプだけで、Ikiform は `application/json` を送るので、設定の変更は不要です。`$app/env/private` から読めるよう、`src/env.ts` で `defineEnvVars` を使って `IKIFORM_WEBHOOK_SECRET` を宣言してください。

`src/routes/webhooks/ikiform/+server.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { IKIFORM_WEBHOOK_SECRET } from "$app/env/private";
import type { RequestHandler } from "./$types";

export const POST: RequestHandler = async ({ request }) => {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({ payload, headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
    console.info(event.type, event.id);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
};
```

**Nuxt**

Nitro のサーバールートです。`readRawBody` は、何かに解析される前のボディを文字列で返します。`NUXT_IKIFORM_WEBHOOK_SECRET` を設定し、`nuxt.config.ts` の `runtimeConfig` に `ikiformWebhookSecret` を宣言してください。

`server/api/webhooks/ikiform.post.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export default defineEventHandler(async (event) => {
  const payload = (await readRawBody(event, "utf8")) ?? "";
  const { ikiformWebhookSecret } = useRuntimeConfig(event);
  try {
    const delivery = await verifyWebhook({
      payload,
      headers: getRequestHeaders(event),
      secret: ikiformWebhookSecret,
    });
    console.info(delivery.type, delivery.id);
    setResponseStatus(event, 204);
    return null;
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      throw createError({ status: 400, message: error.reason });
    }
    throw error;
  }
});
```

どのハンドラーも、成功時は `204` を、それ以外は失敗理由を付けて `400` を返します。時間のかかる処理は応答のあとに行ってください。Ikiform は 15 秒待っても応答がなければ、その試行を失敗とみなします。

## 生のボディが重要な理由

署名が対象にするのは意味ではなくバイト列です。`{"a":1}` と `{ "a": 1 }` は JSON としては同じでもバイト列は違うので、一度解析して再シリアライズしたボディはまず一致しません。受信の途中でボディに手を加えるものはすべて同じです。JSON を解析するミドルウェア、再エンコードするプロキシ、文字列ではなくオブジェクトを渡してくるフレームワークなどです。

ボディはバイト列か文字列として 1 回だけ読み、検証してから、その同じ文字列を解析します。各言語のガイドでは、それぞれのフレームワークで生のボディがどこにあるかを示しています。[自前サーバーのガイド](https://ikiform.com/blog/form-submissions-to-your-own-server.md)には、よく使われる Node と Python のフレームワークの表があります。

## 定数時間で比較する

通常の文字列比較は、最初に違う文字が見つかった時点で止まります。理論上は、何千ものリクエストの時間を測れる攻撃者なら、推測の先頭何文字が正しいかを知り、有効な署名を 1 文字ずつ組み立てられます。定数時間の比較は、どれだけ一致していても同じ時間がかかります。どの言語にも用意されています。

| 言語 | 定数時間の比較 |
| --- | --- |
| Node.js | `crypto.timingSafeEqual(a, b)`。長さが違うと例外を投げるので、先に長さを確認します |
| Python | `hmac.compare_digest(a, b)` |
| Go | `hmac.Equal(a, b)` |
| PHP | `hash_equals($known, $user)` |
| Ruby | `OpenSSL.secure_compare(a, b)`、Rails なら `ActiveSupport::SecurityUtils.secure_compare` |

## タイムスタンプとリプレイ

一度キャプチャされた有効なリクエストは、何かで期限を切らない限り永遠に有効なままです。その役目を果たすのが署名済みのタイムスタンプです。自分の時計との差が前後 5 分を超える配信はすべて拒否してください。Ikiform は試行のたびに新しいタイムスタンプで署名し直すので、3 日後のリトライでも通ります。失敗するのは、コピーされた古いリクエストだけです。

その 5 分の間は、リプレイも、本物の重複も起こりえます。Ikiform は時間内に `2xx` が返らないと、ハンドラーの処理が終わっていてもリトライします。`webhook-id` を一意制約付きで保存し、処理済みの ID は飛ばしてください。これで両方を防げます。

> サーバーの時計は同期させておいてください。5 分を超えてずれた時計では、すべての配信が `timestamp_too_old` か `timestamp_too_new` で拒否されます。マネージドなプラットフォームは通常時計を同期していますが、自分の VM では NTP を動かしてください。

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

シークレットを知っていた人が抜けたとき、ログに残ったりコミットされたりした可能性があるとき、または定期的に、シークレットをローテーションします。エンドポイントのページで **Roll signing secret** を選ぶと新しいシークレットが作られ、その後 24 時間はすべての配信が両方で署名されます。

図: シークレットのローテーション前、最中、後に webhook-signature に入る内容

1. ローテーション前は、各配信に古いシークレットで作った署名が 1 つ付きます。
2. Roll signing secret を選び、新しいシークレットをコピーします。
3. その後 24 時間は各配信に署名が 2 つ付くので、どちらのシークレットを持つサーバーでも受け付けられます。この間に新しいシークレットをデプロイします。
4. 24 時間後は新しい署名だけが付き、古いシークレットのままのサーバーでは拒否されます。

最初の 1 つだけでなく、すべての v1 エントリーを確認していれば、ハンドラーに特別なコードは要りません。

24 時間経っても古いシークレットがデプロイされたままだと、すべての配信が失敗します。Ikiform は各配信を約 3 日かけて 8 回リトライするので、正しいシークレットをデプロイしたら、すでにあきらめられたものは配信ログの **Retry** で再送してください。

## 確認が通ったら

有効な署名が証明するのは、リクエストが Ikiform から来て、変更されていないことです。内容が安全であることや、新しいことは証明しません。仕上げに 3 つの習慣を身につけましょう。

- **重複を飛ばす。** `webhook-id`、または同じ値であるペイロードの `id` を一意制約付きで保存します。処理済みの配信のリトライは何もしない操作になります。
- **先に応答し、処理はあとで。** イベントを保存するかキューに入れたら、すぐに `2xx` を返します。Ikiform は 15 秒より遅い応答を失敗とみなしてリトライするので、重複の問題が戻ってきます。
- **フィールドは信頼しない。** `data.fields` には訪問者が公開フォームに入力した内容が入っています。HTML、チャットのメッセージ、メールに表示する前にエスケープし、シェルやパラメーター化していないクエリには絶対に渡さないでください。

### 本番公開前にテストする

Ikiform はポート 443 の公開 `https:` URL にしか配信しないので、`localhost` では Webhook を受け取れません。`cloudflared tunnel --url http://localhost:3000` などのトンネルを動かし、表示された URL を作業中だけ 2 つ目のエンドポイントとして追加してください。エンドポイントのページの **Send test** は `endpoint.test` イベントを 1 回だけ、リトライなしで、本物の配信とまったく同じ署名を付けて送ります。終わったらテスト用のエンドポイントを削除してください。シークレットは別なので、ほかには何も変わりません。

配信が失敗して理由がわからないときは、ログからヘッダーとボディをコピーして[署名検証ツール](https://ikiform.com/ja/docs/webhooks.md#verifier)に貼り付けてください。ブラウザーで同じ確認を行い、どの手順で失敗したかを教えてくれます。

## 検証でよくある 5 つの間違い

| 間違い | 症状 | 対処 |
| --- | --- | --- |
| `whsec_...` の文字列全体を鍵に使っている | すべての配信で `no_matching_signature` | `whsec_` を取り除いてから base64 デコードします。鍵は 32 バイトです |
| 解析済みのボディを検証している | `no_matching_signature`。一部のペイロードだけで起きることも | 生のボディを検証し、そのあとで解析します |
| 最初の署名だけを確認している | ローテーション直後から始まる失敗 | スペース区切りのすべての `v1` エントリーをループで確認します |
| base64 ではなく 16 進数を使っている | `no_matching_signature` | Standard Webhooks が使うのはパディング付きの標準 base64 で、16 進数や base64url ではありません |
| ヘッダーを大文字・小文字まで完全一致で探している | 一部のプロキシの背後で `missing_headers` | ヘッダー名は大文字と小文字を区別しません。Ikiform は小文字で送ります。大文字・小文字を区別せずに探してください |

ワンクリックの連携はありません。どのツールにも、署名付きの [Webhook](https://ikiform.com/ja/docs/webhooks.md) で直接、または [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) や [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) などの自動化ツールを経由して届けます。1 つのワークスペースでエンドポイントを 3 つまで持て、それぞれに専用のシークレットがあります。ペイロードの全体、リトライのスケジュール、配信ログについては [Webhooks](https://ikiform.com/ja/docs/webhooks.md) を見てください。

## 状況に合わせて選ぶ

各言語のページには、その言語らしいハンドラー、ライブラリを使う方法、このページのテストベクターを実行する単体テストがあります。

- [Node.js](https://ikiform.com/ja/guides/webhook-signature-verification/nodejs.md): Node.js で Ikiform の Webhook 署名を検証する方法。Express と Fastify での生のボディ、SDK、standardwebhooks パッケージ、テスト済みの node:crypto 版。
- [Python](https://ikiform.com/ja/guides/webhook-signature-verification/python.md): Flask、FastAPI、Django で Ikiform の Webhook 署名を検証する方法。standardwebhooks パッケージか標準ライブラリを使い、pytest のテストも付けます。
- [Go の net/http](https://ikiform.com/ja/guides/webhook-signature-verification/go.md): Go の net/http で Ikiform の Webhook 署名を検証する方法。ボディを 1 回だけ読み、crypto/hmac で確認してテストするか、Standard Webhooks ライブラリを使います。
- [PHP](https://ikiform.com/ja/guides/webhook-signature-verification/php.md): Ikiform の Webhook 署名を、素の PHP では hash_hmac と hash_equals で、Laravel では Standard Webhooks ライブラリと CSRF の除外で検証します。
- [Ruby](https://ikiform.com/ja/guides/webhook-signature-verification/ruby.md): Ikiform の Webhook 署名を、Sinatra では OpenSSL と Minitest のテストで、Rails では standardwebhooks gem と request.raw_post で検証します。

## 動作を確認する

まずステージングのエンドポイントで、次に本番でもう一度行ってください。Ikiform に無料プランはありません。テストは無料です。プランのないワークスペースでも、`localhost` からのテスト送信を月 50 件まで受け付けます。本番運用するには、500 件で月 $6 からの Pro を選ぶか、買い切りにしてください。10,000 件で $49、または 50,000 件で $149 で、クレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

1. テストベクターを自分のコードで実行します。期待どおりの署名が出て、2 つの署名を含むヘッダーを受け付け、整形されたボディを拒否するはずです。
2. ダッシュボードで **Webhooks** を開き、エンドポイントの公開 `https:` URL を追加して、`whsec_` のシークレットをサーバーの環境変数にコピーします。
3. エンドポイントのページで **Send test** を選びます。配信ログに `204` が表示され、イベントがログに届きます。
4. サーバー上のシークレットを 1 文字だけ変えて、もう一度テストを送ります。ログに `no_matching_signature` 付きの `400` が表示されます。
5. 実際にフォームを送信し、`submission.created` の配信が成功することを確認します。

## トラブルシューティング

| 問題 | 対処法 |
| --- | --- |
| `400 no_matching_signature` | シークレットが違う、ボディが解析済み、または鍵が base64 デコードされていません。どれが原因かは、同じコードパスにテストベクターを通して確かめてください。 |
| `400 timestamp_too_old` や `timestamp_too_new` | サーバーの時計がずれているか、保存したリクエストを手動で再送しています。時計を同期し、配信ログの **Retry** を使ってください。こちらは署名し直して送ります。 |
| `400 missing_headers` | プロキシかフレームワークが `webhook-*` ヘッダーを落としたか、リクエストが Ikiform から来たものではありません。ハンドラーに届くヘッダー名をすべてログに出してください。 |
| テストイベントでは動くのに、実際の送信データで失敗する | 実際のペイロードには非 ASCII 文字や長いテキストが含まれます。コードが検証前にボディをデコードまたは再エンコードしています。受け取ったバイト列そのものを検証してください。 |
| シークレットをローテーションした直後から、すべての配信が失敗する | ハンドラーが最初の署名しか確認していないか、古いシークレットをデプロイしたまま 24 時間が過ぎています。すべての `v1` エントリを確認し、新しいシークレットをデプロイしてください。 |
| 配信ログにはタイムアウトと出ているのに、ハンドラーは実行されている | Ikiform は 15 秒待ちます。先に応答を返して時間のかかる処理は後で行い、重複した `webhook-id` の値はスキップしてください。 |

## よくある質問

### Webhook の署名はどうやって検証しますか？

Webhook の ID、タイムスタンプ、生のボディをドットでつないだものについて、whsec_ シークレットの base64 デコードした部分を鍵にして HMAC-SHA256 を計算し直します。結果を base64 エンコードし、webhook-signature ヘッダー内の各 v1 の値と定数時間で比較します。

### Webhook の署名がまったく一致しないのはなぜですか？

ほぼ必ず次の 3 つのどれかです。検証前にボディが解析されて再シリアライズされた、base64 デコードしたバイト列ではなく whsec_ の文字列全体を鍵にした、またはシークレットが別のエンドポイントのものだった、のいずれかです。

### Standard Webhooks とは何ですか？

HMAC-SHA256 と、webhook-id、webhook-timestamp、webhook-signature の各ヘッダーで Webhook に署名するためのオープンな仕様です。Ikiform はこれに準拠しているので、Standard Webhooks のどのライブラリでも Ikiform の配信を検証できます。

### 古いタイムスタンプを拒否するのはなぜですか？

そうしないと、傍受されたリクエストがいつまでも有効なままになります。自分の時計から 5 分以上ずれたものをすべて拒否すれば古いリクエストのリプレイを防げますし、Ikiform はリトライのたびに新しいタイムスタンプで署名するので、本物のリトライは問題なく通ります。

### シークレットをローテーションすると配信はどうなりますか？

24 時間は、各配信が古いシークレットと新しいシークレットの両方で署名されるので、イベントを失わずに新しいシークレットをデプロイできます。その後は新しいシークレットだけが有効です。

### 署名が不正なとき、ハンドラーは 401 と 400 のどちらを返すべきですか？

Ikiform にとってはどちらでもかまいません。2xx 以外のステータスはすべて失敗とみなされ、リトライされます。410 は返さないでください。エンドポイントが即座に無効化されます。

## その他のガイド

- [Node.js](https://ikiform.com/ja/guides/webhook-signature-verification/nodejs.md): Node.js で Ikiform の Webhook 署名を検証する方法。Express と Fastify での生のボディ、SDK、standardwebhooks パッケージ、テスト済みの node:crypto 版。
- [Python](https://ikiform.com/ja/guides/webhook-signature-verification/python.md): Flask、FastAPI、Django で Ikiform の Webhook 署名を検証する方法。standardwebhooks パッケージか標準ライブラリを使い、pytest のテストも付けます。
- [Go の net/http](https://ikiform.com/ja/guides/webhook-signature-verification/go.md): Go の net/http で Ikiform の Webhook 署名を検証する方法。ボディを 1 回だけ読み、crypto/hmac で確認してテストするか、Standard Webhooks ライブラリを使います。
- [PHP](https://ikiform.com/ja/guides/webhook-signature-verification/php.md): Ikiform の Webhook 署名を、素の PHP では hash_hmac と hash_equals で、Laravel では Standard Webhooks ライブラリと CSRF の除外で検証します。
- [Ruby](https://ikiform.com/ja/guides/webhook-signature-verification/ruby.md): Ikiform の Webhook 署名を、Sinatra では OpenSSL と Minitest のテストで、Rails では standardwebhooks gem と request.raw_post で検証します。
- [新しい SaaS のウェイトリスト](https://ikiform.com/ja/guides/waitlist.md): 自分のランディングページにローンチ前のウェイトリストを作ります。フォーム、スパム対策、重複、確認メール、ローンチ日のきれいなエクスポートまで扱います。
- [静的サイトのお問い合わせフォーム](https://ikiform.com/ja/guides/contact-form-static-site.md): サーバーなしで静的サイトに動くお問い合わせフォームを追加します。POST の送り先、スパム、メールでの返信、各ホストとフレームワーク向けのコードまで。
- [フォームをメールに送る](https://ikiform.com/ja/guides/send-form-to-email.md): メールサーバーなしで、すべての送信データを受信トレイに届けます。mailto: と PHP の mail() がうまくいかない理由と、スパムを除外して返信もできる構成を解説します。
