コンテンツにスキップ

webhook

セッションの状態が変わると、登録した URL に POST で通知が届きます。通知には氏名・住所などの本人情報を載せません(判定・要約・理由コードだけ)。ただし data.review.note(審査のメモ・自由記述)と metadata は、本文をそのままログに出さないでください。氏名などが必要なときは、取得 API(GET /v1/verification-sessions/{id})で取りにいきます。

  • verification.approved
  • verification.rejected
  • verification.needs_review
  • verification.expired
  • verification.failed
typeいつ届くか
verification.approved本人確認できたと確定した
verification.rejected本人確認できなかったと確定した
verification.needs_review自動では決められず、審査に回った。審査が終わると approvedrejected がもう一度届く
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 の値も増えます。知らない値でエラーにしないでください
  • 保存期間を過ぎた後の再送では、metadatadata.review.notenull になります

通知は 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 の既定も同じ幅です)。

  1. ボディを生のバイト列のまま受け取る。JSON として解釈した後に文字列へ戻すと、バイト列が変わって検証に失敗します(Express なら、このルートだけ express.raw() で受ける)
  2. シークレット(whsec_ で始まる文字列)を渡して検証する
  3. 検証に失敗したら 400 などを返し、中身を使わない
  4. 受信サーバーの時計を合わせておく(時刻がずれると検証に失敗する)
// 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 には送りません

切り替え中は、新旧どちらのシークレットでも検証が通るように署名が 2 つ付きます。止めずに切り替えられます。

  1. 管理画面でシークレットを再発行する。新しいシークレットはこのとき一度だけ表示されます
  2. 受信サーバーの SUPATRUST_WEBHOOK_SECRET を新しい値に替えて、反映する
  3. 新しい値で検証が通っていることを確かめる
  4. 管理画面で古いシークレットを無効にする

シークレットが漏れた疑いがあるときも同じ手順で切り替え、4 までをすぐに行ってください。