# Webhook

> 届いたお問い合わせを1件ごとに、署名付きの JSON で Zapier・Make・CRM・自社のサーバーへ送る（有料プラン・1サイト5本）。送る形・署名の検証・冪等性・再送・送り先の制限・テスト送信。

URL: https://siteon.dev/ja/docs/webhooks

Siteon は、AI で作ったサイトのためのインフラです。AI や手で作った静的サイト（HTML）に、ホスティング・お客さん（お店の人）が直せる CMS・お問い合わせフォーム・アクセス解析・AI エージェントからの操作（CLI・MCP）を足し、そのまま納品できる状態にします。

## できること

フォームでお問い合わせを受け付けるたびに、登録した URL へ**署名付きの JSON を POST** します。Zapier や Make の「Webhook で受け取る」トリガー、CRM の受け口、自社のサーバーなどにつなげられます。

| 項目 | 内容 |
|---|---|
| プラン | 有料プラン（Lite・Standard・Business）だけ。**Free プラン では使えません**（契約が切れて Free と同じ扱いになったサイトも送りません） |
| 本数 | 1サイト5本まで。送るフォームを選べます（選ばなければ全フォーム） |
| 送り先 | `https://` の URL だけ（[送り先の制限](#送り先の制限)） |
| 成功 | 10秒以内に 2xx が返ること |
| 再送 | 失敗したら 2・4・8・16・32・60・60分おきに送り直し、計8回で「失敗」（およそ3時間） |
| 操作 | 管理画面の「設定 → 詳細 → Webhook」、CLI の `siteon webhooks`、MCP の `webhooks_*`（[CLI・MCP・API キー](https://siteon.dev/ja/docs/cli-mcp)） |

追加・削除・テスト送信ができるのはサイトのオーナーだけです（スタッフは一覧を見るだけ）。お店へのメールや Slack の通知とは別に動き、Webhook が失敗してもお問い合わせは消えません。

## 追加する

管理画面の「設定 → 詳細 → Webhook」で送り先の URL と対象のフォームを選んで追加します。CLI なら:

```sh
siteon webhooks add https://hooks.example.com/siteon --site hanaya --form contact
```

**署名用の秘密（`whsec_` で始まる文字列）は、追加したときに1回だけ表示されます。** 受け取り側の環境変数などに保存してください。あとから見ることはできません。漏れたり失くしたりしたら、その Webhook を消して作り直します（新しい秘密が出ます）。

## 送る JSON

`POST <登録した URL>`、`content-type: application/json` で次を送ります。

```json
{
  "event": "form.submitted",
  "site": "hanaya",
  "form": "contact",
  "submission_id": "…",
  "submitted_at": "2026-10-04T09:30:00.000Z",
  "data": { "name": "山田 花子", "email": "hanako@example.com", "message": "秋の花束を予約したいです" },
  "attribution": {
    "first": { "utm_source": "google", "utm_medium": "cpc", "gclid": "…", "landing": "/", "referrer": "…", "at": "…" },
    "last": { "utm_source": "instagram", "landing": "/menu/", "at": "…" }
  },
  "test": false
}
```

| キー | 中身 |
|---|---|
| `event` | `form.submitted`（テスト送信は `webhook.test`） |
| `site` / `form` | サイト ID / フォームのキー |
| `submission_id` | 受付 ID。管理画面の一覧と同じ ID |
| `submitted_at` | 受け付けた日時（ISO 8601・UTC） |
| `data` | 入力内容（schema.json の `fields[].key` ごと） |
| `attribution` | 流入元。`first`（最初に来たとき）と `last`（最後に来たとき）に `utm_*`・`gclid`・`fbclid`・着地ページ・参照元（[流入元の記録](https://siteon.dev/ja/docs/script#流入元utmの記録)） |
| `test` | テスト送信なら `true` |

ヘッダ:

| ヘッダ | 中身 |
|---|---|
| `X-SiteKit-Signature` | `t=<UNIX 秒>,v1=<署名>`。署名は `HMAC-SHA256(秘密, t + "." + 本文)` の16進 |
| `Idempotency-Key` | 受付 ID（`submission_id` と同じ）。再送でも変わりません |
| `X-SiteKit-Event` | `event` と同じ値 |
| `X-SiteKit-Delivery` | 送信ごとの ID（調べるとき用） |

## 署名を確かめる

署名の形は Stripe の Webhook と同じです。受け取り側で次を確かめます。

1. ヘッダから `t` と `v1` を取り出す
2. `t + "." + 受け取った本文（そのままの文字列）` を秘密で HMAC-SHA256 し、16進にしたものが `v1` と一致する（比べるときは時間が一定の比較を使う）
3. `t` が今から5分以上ずれていたら拒否する（古い送信の使い回しを防ぐ）

**本文は JSON として読み直す前の文字列で計算します。** 読み直してから文字列に戻すと、空白や並びが変わって一致しません。

Node.js:

```js
import crypto from "node:crypto";

// rawBody: 受け取ったままの本文（文字列）、header: X-SiteKit-Signature の値
export function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Python:

```python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if not t or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

秘密は `whsec_` を含めた文字列全体をそのまま鍵にします。

## 冪等性（同じ問い合わせが2回届いたとき）

応答が途中で切れた場合などに、同じお問い合わせが2回以上届くことがあります。`Idempotency-Key`（＝`submission_id`）は再送でも同じなので、**受け取り側で一度処理した ID は読み飛ばして** 2xx を返してください。CRM に二重に登録されるのを防げます。

## 再送と失敗

- 受け付けた直後に1回目を送ります。失敗（2xx 以外・10秒を超えた・つながらない）したら、2・4・8・16・32・60・60分おきに送り直し、計8回で「失敗」として止めます
- **リダイレクト（3xx）は追わず、失敗として扱います。** 登録する URL はリダイレクトしない最終の URL にします
- 応答の本文は読みません。2xx を返すまでの時間を短くしたいときは、受け取り側でキューに積んでからすぐ 2xx を返します
- 送るたびに保存済みのお問い合わせから組み立てるので、**再送の前にお問い合わせを消すと、その分はもう送りません**
- 直近の送信結果は、管理画面の「設定 → 詳細 → Webhook」と `siteon webhooks list` で見られます

## 送り先の制限

送り先は、追加するときと**送るたびに**確かめます。次のものは登録できず、送りません。

- `https://` 以外（`http://` など）。ポートの指定（既定の 443 以外）。`user:pass@` を含む URL
- IP アドレスの直書き（公開の IP も不可）
- ドットの無い名前・`localhost`・`.local`・`.internal`・`.home.arpa`・`.test`・`.example`・`.invalid` などの内部向け・予約済みの名前
- Siteon 自身のホスト（API・管理画面・ホスティングの共通ドメインとそのサブドメイン）

社内のサーバーに送るときは、インターネットから https で届く公開の名前を用意してください。

## テスト送信

管理画面の「設定 → 詳細 → Webhook」のテスト送信か、`siteon webhooks test <id>`（MCP の `webhooks_test`）で、保存済みの URL へ**本物の秘密で署名したダミー**を送り、結果（HTTP ステータス）を返します。

- `event` は `webhook.test`、`test` は `true`、`submission_id` は `test_` で始まります。`data` は対象フォームの項目に合わせたダミー、`attribution` は空です
- お問い合わせとしては保存されず、件数にも数えません
- 1本につき1分に1回まで（サイトの1日のテスト送信の枠にも数えます）

受け取り側では、`test` が `true` のものは署名だけ確かめて、本番の処理（CRM への登録など）はしないようにしておくと安全です。

## API キーの権限

CLI・MCP から操作するときの鍵の権限です（[CLI・MCP・API キー](https://siteon.dev/ja/docs/cli-mcp#api-キー)）。

| 操作 | 要る scope |
|---|---|
| 一覧（`webhooks list` / `webhooks_list`） | `sites:read` |
| 追加（`webhooks add` / `webhooks_add`） | `sites:write` と `submissions:read` の両方（入力内容を外に送れるようになるため） |
| 削除（`webhooks remove` / `webhooks_remove`） | `sites:write` |
| テスト送信（`webhooks test` / `webhooks_test`） | `forms:test` |
