Webhooks#
このページでは、イベント通知用 Webhook の登録、署名検証、再試行、配信履歴を説明する。検証には公開 SDK @auth-platform/server の verifyWebhook を使う。
関連資料: Management API、Server SDK (@auth-platform/server)。
イベントとエンベロープ#
配信ボディは JSON の Domain Event である。全 event に共通する envelope は次の形をとる。
{
"eventId": "evt_<id>",
"type": "user.created",
"version": 1,
"occurredAt": "2026-09-29T12:00:00.000Z",
"workspaceId": "wsp_<id>",
"environmentId": "env_<id>",
"actor": { "type": "workspace_member", "id": "<principal-id>" },
"payload": { "userId": "usr_<id>", "method": "email_otp" },
"trace": { "requestId": "<request-id>", "correlationId": "<correlation-id>" }
}
environmentId と actor は省略可能であり、actor.id も省略可能である。trace は必須で、requestId と correlationId は個別に省略できる。eventId は evt_ で始まる一意な ID、version は常に 1、occurredAt は ISO 8601 日時である。actor.type は end_user、workspace_member、api_key、system のいずれか。
サブスクライブ可能なイベント型と payload field は以下のとおり。optional と記した field は省略可能である。型と payload の正本は specs/events.schema.json である。
type |
payload |
|---|---|
user.created |
userId, method (email_otp / passkey / social), optional maskedEmail |
user.updated |
userId, changedFields (空でない文字列配列) |
user.deleted |
userId, optional reason (user_request / admin_action) |
user.blocked |
userId, optional reason (admin_action / abuse_detection) |
identity.linked |
userId, identityId, provider |
identity.unlinked |
userId, identityId, provider |
passkey.created |
userId, passkeyId, optional aaguid (UUID), optional backedUp (boolean) |
passkey.deleted |
userId, passkeyId |
session.created |
userId, sessionId, method (email_otp / passkey / social / refresh_token), optional clientId, optional ipPrefixHash |
session.revoked |
userId, sessionId, reason (user_logout / admin_revocation / refresh_token_reuse / session_expired) |
login.succeeded |
userId, sessionId, method (email_otp / passkey / social / refresh_token), optional clientId, optional ipPrefixHash |
login.failed |
optional userId, method (email_otp / passkey / social / refresh_token), reason (invalid_code / expired_code / passkey_verification_failed / social_provider_error / user_blocked / email_suppressed), optional clientId, optional maskedEmail, optional ipPrefixHash |
email.delivery.bounced |
deliveryId, recipientHash (base64url HMAC-SHA256), optional messageId, bounceType (hard / soft) |
mau.threshold_reached |
billingMonth (YYYY-MM), thresholdPercent (80 / 95 / 100), currentMau, freeTierLimit |
method の値は user.created では登録時の 3 種類、ログイン event では refresh_token を含む 4 種類である。maskedEmail はマスク済みのアドレスであり、完全なメールアドレスは event に含まれない。ipPrefixHash は IP prefix の SHA-256 hex であり、元の IP address ではない。
Management API での登録と運用#
Management API の base URL は https://api.zeroword.smartcrab.ai/v1 である。各操作には表の scope が必要である。Management API key の Workspace は key に固定されるため X-Workspace-Id は不要である(Dashboard session の場合だけ必須)。Management API の共通ルールは Management API を参照する。
| Method | Path | Scope | 用途 |
|---|---|---|---|
POST |
/v1/webhooks |
webhooks:write |
Environment、URL、購読 event を登録する。response には署名 secret が一度だけ含まれる。 |
GET |
/v1/webhooks?environment_id=<environmentId> |
webhooks:read |
Environment の Webhook 一覧を取得する。 |
PATCH |
/v1/webhooks/{webhookId} |
webhooks:write |
URL、event type、active / disabled 状態を更新する。 |
DELETE |
/v1/webhooks/{webhookId} |
webhooks:write |
Webhook を削除する。以後の配信は停止し、一覧にも表示されない。一時停止には PATCH で status: "disabled" を使う。 |
POST |
/v1/webhooks/{webhookId}/rotate-secret |
webhooks:rotate_secret |
署名 secret をローテーションする。response は { id, secret, rotated_at } であり、新 secret は一度だけ含まれる。 |
POST |
/v1/webhooks/{webhookId}/test |
webhooks:test |
合成 event を 1 回送信し、結果を同期 response で返す。 |
GET |
/v1/webhooks/{webhookId}/deliveries |
webhooks:read |
配信試行の履歴を取得する。 |
作成リクエストの例:
curl -X POST 'https://api.zeroword.smartcrab.ai/v1/webhooks' \
-H "Authorization: Bearer $AUTH_PLATFORM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"environment_id": "<environmentId>",
"url": "https://hooks.example.com/auth/events",
"event_types": ["user.created", "session.revoked"]
}'
作成時の response は Webhook の各 field に secret を加えた object であり、secret は暗号化保存され、後続の一覧取得では返らない。作成・ローテーションのどちらでも secret は一度だけ返る。secret は暗号学的にランダムな 32 byte 値を prefix なしの base64url(padding なし)で encode した文字列であり、verifyWebhook や独自実装の HMAC key には decode せず UTF-8 文字列として渡す。受信アプリの secret store へ直ちに保存すること。event_types は既知の 14 型から選ぶ空でない配列であり、未知の型と重複した型は拒否される。
URL は HTTPS のみ受け付ける。userinfo (user:pass@host) と fragment を含む URL、localhost 系 host、blocked な IP literal を拒否する。IPv4 では RFC 1918、CGNAT、0/8、loopback、link-local、protocol-assignment (192.0.0.0/24)、documentation、benchmarking、multicast、reserved 領域などを拒否する。IPv6 では unspecified (::)、loopback (::1)、link-local、unique-local、multicast と、blocked IPv4 へ map される IPv6 を拒否する。URL 登録時には DNS を解決しないため、public hostname が private IP へ解決されるケースはこの検査では検出できない。配信時は HTTP redirect を追わない(redirect: "manual")。
配信ヘッダーと署名#
通常配信は POST と Content-Type: application/json に加え、次の 3 header を付ける。HTTP header 名の大小文字は区別しない。
| Header | 値 |
|---|---|
X-Auth-Event-Id |
イベントの eventId |
X-Auth-Timestamp |
Unix 秒の 10 進数文字列 |
X-Auth-Signature |
v1=<lowercase-hex> |
署名値は HMAC-SHA256(secret, timestamp + "." + rawBody) である。rawBody は HTTP request body を UTF-8 で読んだ文字列そのもの。署名後の JSON parse / stringify、空白の除去、key 順の変更は署名を壊す。配信側は key を再帰的にソートした JSON を署名するが、受信側は再シリアライズせず、必ず受け取った body を使う。
@auth-platform/server で検証する#
verifyWebhook は Headers または case-insensitive な Record<string, string>、raw body、secret 1 つまたは secret の配列を受け取る。既定で現在時刻との差が 300 秒を超える timestamp を過去・未来の両方向で拒否する。境界ちょうど 300 秒は許容する。clock はテスト用に Unix 秒を返す関数として注入できる。成功時は eventId と timestampSeconds、失敗時は missing_header、malformed_timestamp_header、malformed_signature_header、timestamp_too_old、timestamp_in_future、signature_mismatch、crypto_operation_failed のいずれかを返す Result になる。
Hono では body を一度だけ text として読み、署名検証後に処理する。
import { Hono } from "hono";
import { verifyWebhook } from "@auth-platform/server";
const app = new Hono<{ Bindings: { WEBHOOK_SECRET: string } }>();
app.post("/webhooks", async (c) => {
const rawBody = await c.req.text();
const verified = await verifyWebhook({
headers: c.req.raw.headers,
rawBody,
secret: c.env.WEBHOOK_SECRET,
});
if (verified.type === "Failure") return c.text("Invalid webhook signature", 401);
// verified.value.eventId を使って重複排除した後にイベントを処理する。
return c.body(null, 204);
});
Next.js App Router でも request.text() を JSON parse より先に呼ぶ。
import { verifyWebhook } from "@auth-platform/server";
export async function POST(request: Request): Promise<Response> {
const rawBody = await request.text();
const secret = process.env.WEBHOOK_SECRET;
if (!secret) return new Response("Webhook secret is not configured", { status: 500 });
const verified = await verifyWebhook({ headers: request.headers, rawBody, secret });
if (verified.type === "Failure") return new Response("Invalid webhook signature", { status: 401 });
// verified.value.eventId を使って重複排除した後にイベントを処理する。
return new Response(null, { status: 204 });
}
Node.js HTTP server では request stream を一度だけ集めて raw body を作り、受信 header を Headers に渡す。
import { createServer } from "node:http";
import { verifyWebhook } from "@auth-platform/server";
const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error("WEBHOOK_SECRET is required");
const server = createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const rawBody = Buffer.concat(chunks).toString("utf8");
const headers = new Headers();
for (const [name, value] of Object.entries(request.headers)) {
if (typeof value === "string") headers.set(name, value);
else if (Array.isArray(value)) headers.set(name, value.join(", "));
}
const verified = await verifyWebhook({ headers, rawBody, secret });
if (verified.type === "Failure") {
response.writeHead(401).end("Invalid webhook signature");
return;
}
response.writeHead(204).end();
});
server.listen(3000);
すべての例で検証失敗時は event を処理せず、成功時のみ処理を続ける。JSON body をアプリで利用する場合も、署名検証後に rawBody を parse する。
Secret ローテーション#
POST /webhooks/{webhookId}/rotate-secret は新しい secret と rotated_at を返す。新 secret は一度だけ表示される。ローテーション後 24 時間は受信側が current と previous の両方を検証できる overlap 期間であり、期間中に送信済み・再試行中の request には旧 secret で署名されたものが残る可能性がある。その後は current secret のみ有効となる。受信アプリは新 secret を保存し、旧 secret を捨てる前に新旧 2 つを候補にして検証する。verifyWebhook に secret 配列を渡すとすべての候補を試す。24 時間経過後に旧 secret を削除する。
const verified = await verifyWebhook({
headers,
rawBody,
secret: [currentSecret, previousSecret],
});
再試行、重複排除、配信履歴#
通常配信は少なくとも 1 回の配信を前提とし、同じ event が再送されることがある。consumer は副作用と同一 transaction で eventId を記録するなどして冪等化し、同じ eventId を二重適用しないこと。eventId は attempt や配信履歴の id ではなく、event 単位の重複排除 key である。
2xx response だけが成功となる。network error、timeout、2xx 以外の HTTP status は原則再試行する。最初の失敗後、再試行前の基準 delay は 30 秒、1 分、5 分、15 分、30 分、1 時間、6 時間。各 delay は equal jitter により基準値の 50〜100% になる。初回を含め最大 8 回送る。410 Gone は即時に恒久失敗とし、429 は再試行する。他の 4xx は 3 回目以降の失敗で恒久失敗となる。再試行を使い切った delivery と恒久失敗(410、3 回目以降の 4xx)は、いずれも exhausted として記録される。
GET /webhooks/{webhookId}/deliveries は 90 日間保持される試行履歴を返す。status (pending / delivered / failed / exhausted)、occurred_after、occurred_before、cursor、limit で絞り込める。各 record は id、webhook_id、event_id、event_type、attempt、status、response_status_code、duration_ms、occurred_at を含み、request / response body や署名 secret は含まない。test API の同期送信は retry および配信履歴への記録を行わない。
curl 'https://api.zeroword.smartcrab.ai/v1/webhooks/<webhookId>/deliveries?status=failed&limit=20' \
-H "Authorization: Bearer $AUTH_PLATFORM_API_KEY" \
テスト送信#
POST /webhooks/{webhookId}/test は合成 webhook.test event を同期的に一度送信する。webhook.test は通常の 14 event 型の購読対象ではない。request body は {} でよい。API は HTTP 200 で delivered、response_status、response_time_ms、requested_at を返し、delivered: true は受信先が 2xx を返した場合だけである。受信先から HTTP response が得られない場合、response_status と response_time_ms は null になる。test-send は通常配信の retry・重複排除・履歴記録とは別経路である。