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・重複排除・履歴記録とは別経路である。