本文へスキップ
Sursa Developers

Webhookを受信する

Webhookは、Sursaでの変更を、起きたときに自社のシステムに知らせます。カードが公開・検証・アーカイブされた、質問に回答がなかった、などです。Sursaは、登録したアドレスに、イベントを含むHTTPSのPOSTを送信します。このガイドでは、再試行、順序の入れ替わり、重複があっても正しく動く受信サーバーを作ります。

各イベントの意味と内容については、Webhookの概要と、APIリファレンスのイベントカタログを参照してください。

1. Webhookを登録する

管理者は、SursaのAdministration → Integrations → Webhooks and API(管理 → 連携 → Webhook)でWebhookを登録します。または、adminロールとwebhooks:manageスコープを持つキーを使ってPOST /v1/webhooksで登録します。

curl -X POST https://api.sursa.com/v1/webhooks \
  -H "Authorization: Bearer $SURSA_ADMIN_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/sursa",
    "description": "Sync verified cards to the help center",
    "events": ["card.published", "card.updated", "card.verified", "card.archived"]
  }'

レスポンスのsecretには、whsec_で始まる署名シークレットが含まれます。この値は一度だけ表示されます。受信サーバーと一緒に保存してください(たとえば環境変数SURSA_WEBHOOK_SECRET)。

受信先URLはhttpsを使い、公開IPアドレスに解決される必要があります。プライベート、ループバック、その他の予約済みネットワークのアドレスは、登録時とすべての配信時に拒否されます。自分のコンピューターで開発するには、公開HTTPSアドレスを割り当てるトンネリングサービスを通して、ローカルの受信サーバーを公開してください。

POST /v1/webhooks/{webhook_id}/testで、いつでもテストイベントを送信できます。レスポンスには、受信サーバーの応答が示されます。

2. 配信の形式

POST /sursa HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
webhook-id: evt_0123456789abcdef0123456789abcdef
webhook-timestamp: 1791194400
webhook-signature: v1,l1LLl/aEl4dQq7Geb383KbyNeZgs7VZpiw6iuoJgunI=
X-KB-Signature: t=1791194400,v1=82aa6b27378d89d7d880d17c3fe96268598443ae0e64d059c0c41e94e5d3ffbe
X-KB-Event-Id: evt_0123456789abcdef0123456789abcdef
X-KB-Event: card.verified
X-KB-Delivery: 99812
X-KB-Attempt: 1
X-KB-Sequence: 1042

{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"api_version":"1.60.0","sequence":1042,"object":{"type":"card","id":"c1"},"payload":"full","data":{"card":{"id":"c1"}},"test":false}
ヘッダー意味
webhook-idイベントのID。すべての再試行で同じです。重複の除去に使います。
webhook-timestampこの配信試行に署名した時刻(Unix時間の秒)。
webhook-signatureStandard Webhooks形式の署名。
X-KB-Signature同じ配信に、以前のSursaの形式で付けた署名。
X-KB-Eventイベントタイプ。
X-KB-Attempt配信試行の番号(1から)。
X-KB-SequenceそのWebhookへの配信順序での、この配信の位置。

ヘッダー名は大文字と小文字を区別しません。フレームワークによってはX-Kb-Signatureやx-kb-signatureと表示されます。

ボディはイベントです。そのdataはタイプによって異なります。ペイロードをthinに設定したWebhookは、dataなしのイベントを受信します。その場合は、自分のキーを使ってAPIでオブジェクトを読み込んでください。

3. 署名を検証する

各配信を信頼する前に、その署名を確認してください。どちらの形式もボディの正確なバイト列に署名するので、JSONを解析する前に生のボディを読み込みます。JSONをシリアライズし直すとバイト列が変わり、検証が失敗します。

形式署名する文字列鍵署名
Standard Webhooks(webhook-signature)<webhook-id>.<webhook-timestamp>.<body>シークレットのwhsec_より後の部分をBase64デコードしたものv1,+HMAC-SHA256のBase64
以前の形式(X-KB-Signature)<t>.<body>シークレットの文字列全体をテキストとしてv1=+HMAC-SHA256の16進数

どちらか一方を検証すれば十分です。Standard Webhooksのヘッダーを推奨します。同じ検証が多くの既製のライブラリで利用できます。シークレットのローテーション中は、各ヘッダーに有効なシークレットごとの署名が1つずつ入ります(webhook-signatureではスペース区切り、X-KB-Signatureではv1=の繰り返し)。どれか1つが一致すれば、配信は有効です。

また、自分の時計から5分以上ずれたタイムスタンプは拒否してください。記録された配信を後から再送されるのを防げます。

# Check a saved delivery by hand: body.json holds the raw body, the values come from its headers.
SECRET="whsec_…"; WEBHOOK_ID="evt_…"; TIMESTAMP="1791194400"

# Standard Webhooks: compare with the value after "v1," in webhook-signature
KEY=$(printf '%s' "${SECRET#whsec_}" | tr '_-' '/+')
while [ $(( ${#KEY} % 4 )) -ne 0 ]; do KEY="$KEY="; done
KEY_HEX=$(printf '%s' "$KEY" | openssl base64 -d -A | xxd -p | tr -d '\n')
{ printf '%s.%s.' "$WEBHOOK_ID" "$TIMESTAMP"; cat body.json; } \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | openssl base64 -A

# Earlier format: compare with the value after "v1=" in X-KB-Signature
{ printf '%s.' "$TIMESTAMP"; cat body.json; } | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1

署名検証コードをテストする

実際の配信につなぐ前に、次の値でコードを実行してください。時計をタイムスタンプの時刻に合わせると両方の検証に通り、ボディを1バイトでも変えると失敗する必要があります。

入力値
シークレットwhsec_test_secret_0123456789abcdef
webhook-idevt_0123456789abcdef0123456789abcdef
webhook-timestampとt1791194400
ボディ{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"data":{"card":{"id":"c1"}}}
webhook-signaturev1,+xxWSXH6weQJ/MVg4+N78a1Dj4y/AQ4p94+PRNzpECg=
X-KB-Signaturet=1791194400,v1=ce26a19cb63cd3ae620217bf7688e1df1044fa7d59d9ae148d215f0292b768dc

4. すばやく応答し、処理は後で行う

Sursaは応答を最大10秒待ちます。2xxのステータスはすべて配信の成功を意味します。それ以外は、リダイレクト、タイムアウト、接続の切断を含め、すべて失敗を意味し、再試行されます。

そのため、受信サーバーは検証し、イベントを記録し、応答するだけにしてください。実際の処理は、その後で自社のキューから行います。

// Node.js 20+, no framework. verifyStandard and verifyLegacy come from step 3.
import http from "node:http";

const SECRET = process.env.SURSA_WEBHOOK_SECRET;

http
  .createServer(async (req, res) => {
    const parts = [];
    for await (const part of req) parts.push(part);
    const body = Buffer.concat(parts); // the raw bytes, before any JSON parsing

    const valid = req.headers["webhook-signature"]
      ? verifyStandard(SECRET, req.headers, body)
      : verifyLegacy(SECRET, req.headers["x-kb-signature"], body);
    if (!valid) {
      res.writeHead(400).end();
      return;
    }

    const event = JSON.parse(body);
    await saveEventOnce(event); // insert into your table or queue, keyed by event.id; ignore a duplicate
    res.writeHead(204).end();
  })
  .listen(3000);

2xxは、イベントを確実に保存した後でだけ返してください。先に応答してからクラッシュすると、Sursaはすでに配信済みとして数えているので、そのイベントは失われます。

5. 再試行

失敗した配信は、間隔を広げながら再試行されます。約1分後、5分後、30分後、2時間後、5時間後、10時間後、24時間後、24時間後と、3日近くにわたって9回の配信試行を行います。多くのWebhookからの再試行が同時に届かないように、各間隔は最大20%変動します。

  • Retry-After付きの429または503の応答は、次の配信試行を少なくともその時間まで(最大24時間後まで)延ばします。システムが過負荷のときやメンテナンス中に使ってください。
  • 410 Goneの応答はWebhookを無効にします。そのアドレスへの配信がもう不要なときだけ返してください。
  • 10回連続で配信試行が失敗すると、Webhookは一時停止され、組織の管理者に通知されます。イベントはたまり続け、管理画面またはPOST /v1/webhooks/{webhook_id}/resumeでWebhookを再開すると、順序どおりに配信されます。
  • 最後の配信試行の後、配信はexhaustedとマークされます。手動でもう一度送信できます。

Webhookの配信は、各配信試行、受信サーバーの応答コード、その所要時間とともに、GET /v1/webhooks/{webhook_id}/deliveriesと管理画面で一覧表示されます。POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliverは、1件の配信をもう一度送信します。再配信のwebhook-idは同じです。

6. 順序と重複

  • 順序。1つのWebhookへの配信は、sequenceの順に厳密に1件ずつ送られます。ある配信が再試行されているあいだ、後の配信は待ちます。異なるWebhook同士が互いを待つことはありません。
  • 重複。同じイベントが複数回届くことがあります。たとえば、受信サーバーの応答が途中で失われた場合です。処理したすべてのイベントのwebhook-id(ボディのidと同じ)を保存し、すでに見たイベントはスキップしてください。
  • 状態で考える。同じオブジェクトへの変更では、created_atまたはsequenceを保存済みの値と比較し、手元の状態より古いイベントは無視してください。迷ったときは、APIで現在のオブジェクトを読み込んでください。イベントは何かが変わったことを伝え、APIは現在の状態を伝えます。

7. イベントフィードと照合する

どのイベントも、それを購読したWebhookがあるかどうかにかかわらず、30日間イベントフィードにも保持されます。再試行でカバーできる期間より長く受信サーバーが停止していた場合や、何も漏れていないことを確認したい場合は、adminロールとwebhooks:manageスコープを持つキーでフィードを読み込んでください。

curl -G https://api.sursa.com/v1/webhook-events \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  --data-urlencode "type=card.*" \
  --data-urlencode "created_since=2026-10-20T00:00:00Z" \
  --data-urlencode "limit=200"

フィードのイベントは、配信と同じidと形を持ち、配信用のフィールドsequence、payload、testだけがありません。受信サーバーはすでに見たイベントをスキップするので、照合は好きなだけ頻繁に(たとえば1時間に1回)実行できます。

GET /v1/webhook-events/{event_id}は、IDで1件のイベントを読み込みます。

8. シークレットをローテーションする

POST /v1/webhooks/{webhook_id}/rotate-secretは新しいシークレットを発行し、以前のシークレットを並行期間のあいだ有効に保ちます。並行期間はデフォルトで24時間、最大7日です。並行期間中は、すべての配信に両方のシークレットで署名が付きます。

  1. ローテーションし、新しいシークレットを受信サーバーで古いシークレットと並べて保存します。
  2. どちらかのシークレットで検証できた配信を受け入れます。
  3. 並行期間が終わったら、古いシークレットを削除します。

古いシークレットが漏えいした場合は、待たずにPOST /v1/webhooks/{webhook_id}/revoke-previous-secretですぐ並行期間を終了するか、"overlap_hours": 0でローテーションしてください。

チェックリスト

  • 生のボディでどちらかの署名を検証し、タイムスタンプを確認する。
  • イベントをwebhook-idをキーにして保存し、10秒以内に2xxで応答する。
  • 重複と古いイベントはスキップし、必要なときはAPIで現在のオブジェクトを読み込む。
  • Sursaに送信を控えてほしいときはRetry-After付きの429または503で応答し、誤って410を返さない。
  • 知らないイベントタイプとフィールドはスキップする。
  • 定期的にイベントフィードと照合する。