webhook
セッションの状態が変わると、登録した URL に POST で通知が届きます。通知には氏名・住所などの本人情報を載せません(判定・要約・理由コードだけ)。ただし data.review.note(審査のメモ・自由記述)と metadata は、本文をそのままログに出さないでください。氏名などが必要なときは、取得 API(GET /v1/verification-sessions/{id})で取りにいきます。
verification.approvedverification.rejectedverification.needs_reviewverification.expiredverification.failed
| type | いつ届くか |
|---|---|
verification.approved | 本人確認できたと確定した |
verification.rejected | 本人確認できなかったと確定した |
verification.needs_review | 自動では決められず、審査に回った。審査が終わると approved か rejected がもう一度届く |
verification.expired | 期限までに手続きが終わらなかった |
verification.failed | 回復できないエラーで終わった |
- 再処理 したセッションは、新しい判定が出るともう一度通知が届きます(別のイベント ID)
- 受け取るイベントの種類は管理画面で選べます
- 知らない
typeが届いたら無視して 2xx を返してください(種類は今後増えることがあります)
{ "id": "(イベント ID)", "type": "verification.approved", "apiVersion": "(API バージョン)", "createdAt": "(ISO 8601 の日時)", "metadata": { "userId": "user_0001" }, "data": { "session": { "sessionId": "(セッション ID)", "tenantId": "(テナント ID)" } }}- 上の
dataは、どのイベントにもあるsessionだけを示しています。dataの形はtypeで決まります。判定のイベント(approved / rejected / needs_review)のdataは取得 API のoutcomeと同じ形です。各項目は イベントリファレンス を見てください metadataは、セッション作成時に渡した値がそのまま返りますapiVersionは現在2026-08-02です。同じバージョンの中でも項目が増えることがあります。知らない項目は無視してくださいreasonsの値も増えます。知らない値でエラーにしないでください- 保存期間を過ぎた後の再送では、
metadataとdata.review.noteがnullになります
署名を検証する
Section titled “署名を検証する”通知は Standard Webhooks に準拠しています。次の 3 つのヘッダが付きます。
| ヘッダ | 内容 |
|---|---|
webhook-id | イベント ID(ボディの id と同じ)。再送されても変わらない |
webhook-timestamp | 送信した時刻(UNIX 秒)。再送のたびに新しい時刻で署名し直す |
webhook-signature | 署名。シークレットの切り替え中は、空白区切りで 2 つ並ぶ |
検証は Standard Webhooks の公式ライブラリ(npm の standardwebhooks)で行えます。ライブラリは、署名の一致と、webhook-timestamp が現在時刻から一定の範囲にあることを確かめます。SupaTrust が推奨する許容幅は前後 300 秒です(standardwebhooks 1.1.1 の既定も同じ幅です)。
- ボディを生のバイト列のまま受け取る。JSON として解釈した後に文字列へ戻すと、バイト列が変わって検証に失敗します(Express なら、このルートだけ
express.raw()で受ける) - シークレット(
whsec_で始まる文字列)を渡して検証する - 検証に失敗したら 400 などを返し、中身を使わない
- 受信サーバーの時計を合わせておく(時刻がずれると検証に失敗する)
// webhook の受信(Node 20 以降 + Express + standardwebhooks)。// 守ること: 生のボディで署名を検証する / イベント ID で重複を除く / 詳細は API から取得する /// 知らない種別・知らない値は無視して 2xx を返す / 個人情報をログに出さないimport { realpathSync } from "node:fs";import { fileURLToPath } from "node:url";import express from "express";import { Webhook, WebhookVerificationError } from "standardwebhooks";
const requireEnv = (name: string): string => { const value = process.env[name]; if (!value) throw new Error(`環境変数 ${name} を設定してください`); return value;};
const API_BASE = requireEnv("SUPATRUST_API_BASE");const API_KEY = requireEnv("SUPATRUST_API_KEY");// シークレットは管理画面で発行したもの。文字列のまま渡せばよい(接頭辞の扱いはライブラリが行う)const webhook = new Webhook(requireEnv("SUPATRUST_WEBHOOK_SECRET"));
/** 受信した通知の共通部分。ここに無いフィールドが増えても壊れないよう、使う部分だけを書く */interface SupatrustEvent { id: string; type: string; apiVersion: string; createdAt: string; metadata: Record<string, unknown> | null; data: { session: { sessionId: string } };}
/** 取得 API の応答のうち、この例で使う部分 */interface SessionDetail { id: string; status: string; outcome: { verdict: string; reasons: string[] } | null; purgedAt: string | null;}
// 例のための置き場。本番は DB に置き、イベント ID の列に UNIQUE 制約を付ける// (複数台で受けても、再起動しても重複を除けるように)const processedEventIds = new Set<string>();
async function fetchSession(sessionId: string): Promise<SessionDetail> { const response = await fetch( `${API_BASE}/v1/verification-sessions/${encodeURIComponent(sessionId)}`, { headers: { authorization: `Bearer ${API_KEY}` } }, ); if (!response.ok) throw new Error(`セッションの取得に失敗しました: ${response.status}`); return (await response.json()) as SessionDetail;}
async function saveVerificationState(session: SessionDetail): Promise<void> { // 自社 DB の更新に置き換える。reasons は知らない値が来ても例外にしない(値は今後増える) console.info("verification updated", { sessionId: session.id, status: session.status, verdict: session.outcome?.verdict ?? null, });}
export const app = express();
// express.json() より前に、このパスだけ raw で受ける(Buffer のまま署名を検証するため)app.post("/webhooks/supatrust", express.raw({ type: "application/json" }), async (req, res) => { let event: SupatrustEvent; try { event = webhook.verify(req.body as Buffer, { "webhook-id": req.header("webhook-id") ?? "", "webhook-timestamp": req.header("webhook-timestamp") ?? "", "webhook-signature": req.header("webhook-signature") ?? "", }) as SupatrustEvent; } catch (error) { if (error instanceof WebhookVerificationError) { // 署名が合わない・時刻が許容幅の外 = SupaTrust からの正しい通知ではない res.status(400).end(); return; } throw error; }
if (processedEventIds.has(event.id)) { res.status(204).end(); return; }
switch (event.type) { case "verification.approved": case "verification.rejected": case "verification.needs_review": case "verification.expired": case "verification.failed": // 通知の順番は前後することがあるので、通知の中身ではなく取得した最新の状態を保存する await saveVerificationState(await fetchSession(event.data.session.sessionId)); break; default: // 知らない種別は無視する break; }
// 処理を終えてから記録する。途中で失敗したら 500 になり、再送で取り直せる processedEventIds.add(event.id); res.status(204).end();});
app.use(express.json()); // 他のルート用
// このファイルを直接起動したときだけ待ち受ける(import したときは app だけを渡す)。// 起動パスは実体に解決して比べる(symlink 経由で起動すると一致せず、黙って待ち受けないため)const entry = process.argv[1];if (entry !== undefined && realpathSync(entry) === fileURLToPath(import.meta.url)) app.listen(3000);同じ通知が複数回届くことがあります。webhook-id(ボディの id)で 1 回だけ処理してください。
- 処理済みの ID は DB に保存し、UNIQUE 制約を付ける(複数台で受けても、再起動しても効く)
- 処理を終えてから「処理済み」にする。途中で失敗したら 2xx 以外を返し、再送で取り直す
- 通知が届く順番は前後することがあります。通知の中身で状態を上書きせず、取得 API で最新の状態を取り直すと確実です
- 2xx を返すと届いたことになります。応答のボディは読みません
- 2xx 以外・時間切れ・接続できないときは、間隔をあけて何度か再送されます。再送しても届かなければ、そのイベントの配信は打ち切られます
- リダイレクト(3xx)はたどりません。3xx は失敗として扱われます
- 応答が遅いと時間切れになります。受け取ったらすぐ応答し、重い処理は後で行ってください
- 受信 URL は https だけが登録できます。社内ネットワークなどのプライベートなアドレスに解決される URL には送りません
シークレットを切り替える
Section titled “シークレットを切り替える”切り替え中は、新旧どちらのシークレットでも検証が通るように署名が 2 つ付きます。止めずに切り替えられます。
- 管理画面でシークレットを再発行する。新しいシークレットはこのとき一度だけ表示されます
- 受信サーバーの
SUPATRUST_WEBHOOK_SECRETを新しい値に替えて、反映する - 新しい値で検証が通っていることを確かめる
- 管理画面で古いシークレットを無効にする
シークレットが漏れた疑いがあるときも同じ手順で切り替え、4 までをすぐに行ってください。