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-signature | Standard 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-id | evt_0123456789abcdef0123456789abcdef |
webhook-timestampとt | 1791194400 |
| ボディ | {"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"data":{"card":{"id":"c1"}}} |
webhook-signature | v1,+xxWSXH6weQJ/MVg4+N78a1Dj4y/AQ4p94+PRNzpECg= |
X-KB-Signature | t=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日です。並行期間中は、すべての配信に両方のシークレットで署名が付きます。
- ローテーションし、新しいシークレットを受信サーバーで古いシークレットと並べて保存します。
- どちらかのシークレットで検証できた配信を受け入れます。
- 並行期間が終わったら、古いシークレットを削除します。
古いシークレットが漏えいした場合は、待たずにPOST /v1/webhooks/{webhook_id}/revoke-previous-secretですぐ並行期間を終了するか、"overlap_hours": 0でローテーションしてください。
チェックリスト
- 生のボディでどちらかの署名を検証し、タイムスタンプを確認する。
- イベントを
webhook-idをキーにして保存し、10秒以内に2xxで応答する。 - 重複と古いイベントはスキップし、必要なときはAPIで現在のオブジェクトを読み込む。
- Sursaに送信を控えてほしいときは
Retry-After付きの429または503で応答し、誤って410を返さない。 - 知らないイベントタイプとフィールドはスキップする。
- 定期的にイベントフィードと照合する。