Managed Agentsのwebhookで署名検証する実装手順
Claude Managed Agentsのwebhookエンドポイント登録・署名検証・配信保証の仕組みを、実装コードと自動無効化の条件まで含めて解説します。
Claude Managed Agentsのセッションは数分から数時間動き続けます。常時SSEストリームを張らずに主要な状態変化だけ受け取りたいなら、webhookが使えます。webhookイベントには対象オブジェクトの種別とIDしか含まれず、詳細はGETで取得し直す設計です。署名はAnthropic公式SDKのunwrap()ヘルパーで検証でき、配信は1イベントにつき最大3回試行され、すべて失敗したイベントは再送されずに失われます。
webhookとSSEイベントストリームの役割分担
Managed Agentsのリアルタイムなやり取りの大半はSSEイベントストリームで行われます。webhookはそれとは別の目的を持ち、セッションが待機状態に入った・エラーで終了した・予算上限に達したといった主要な状態変化だけを、ポーリングなしで受け取るための仕組みです。
webhookのペイロードは軽量です。イベントのtypeとidだけが含まれ、フルオブジェクトは含まれません。受信側は対象をGETで取得し直す必要があります。この設計には理由があります。再送があってもペイロードが常に最小限で、古いデータを配信してしまうリスクがありません。受け取ったIDを事後監査に回す例はセッションのオフライン監査にあります。
公式ドキュメントは、この機能にベータ表示(ベータヘッダーmanaged-agents-2026-04-01)を付けています。SDKでもclient.beta.webhooksの下にあるため、イベント名や挙動が今後変わる可能性を見込んでおきます。
対応するイベント種別を把握する
Managed Agentsのwebhookが配信するイベントは、7つのリソース系統にまたがります。セッション・vault・agent・scheduled deployment・deployment run・environment・memory storeです。セッション系だけでも次のように幅があります。
| イベント名 | 発火タイミング |
|---|---|
session.status_run_started | 発火タイミング実行が始まった(状態がrunningに遷移するたび) |
session.status_idled | 発火タイミングツール権限の承認待ちや新規メッセージ待ちなど、入力待ちに入った |
session.budget_reached | 発火タイミング設定した予算に到達し一時停止した(同じ予算値では1回のみ発火し、予算を変更すると再度有効になる) |
session.status_rescheduled | 発火タイミング一時的なエラーが発生し自動的に再試行している |
session.status_terminated | 発火タイミング復旧不能なエラー、またはアーカイブによりセッションが終了した |
session.thread_created / session.thread_idled / session.thread_terminated | 発火タイミングマルチエージェント構成の子エージェントやadvisorの開始・入力待ち・終了 |
session.outcome_evaluation_ended | 発火タイミング1回分のアウトカム評価が完了した |
session.updated / session.deleted | 発火タイミングセッションのプロパティ変更、または完全削除 |
他の系統にも専用のイベントがあります。vaultにはvault.createdやvault_credential.refresh_failedがあります。agent.updatedは新バージョンの公開時だけ発火し、バージョンを作らない更新では発火しません。
deployment.pausedは、依頼による一時停止に加えて、スケジュール実行が復旧不能なエラーで失敗したときの自動一時停止も含みます。復旧不能なエラーとは、アーカイブ済みのサブエージェントや環境などです。レートリミットなど復旧可能な失敗では止まりません。memory_store.deletedはメモリー本体や個々のバージョンの削除イベントを個別には出さず、ストア削除の1イベントだけがシグナルになる点は見落としやすいところです。
スケジュール実行とマルチエージェントの状態変化も拾える
scheduled deploymentsを使っている場合、webhookはデプロイメント本体の変化(deployment.paused・deployment.unpaused・deployment.archived)と、個々の実行の変化(deployment_run.started・deployment_run.succeeded・deployment_run.failed)を別系統で配信します。deployment_run系のイベントはスケジュール実行だけが発火し、手動実行では発火しません。deployment_run.succeededが意味するのは、セッションを作成できたことだけです。セッション自体が成功したかどうかはsession_idを取得してセッションイベントを追う必要があります。
マルチエージェント構成を使っているセッションでは、コーディネーターが呼び出した子エージェントやadvisorの開始・待機・終了もsession.thread_created・session.thread_idled・session.thread_terminatedとして個別に届きます。子スレッドが作業を終えてidleになるのと、スレッドがterminatedになるのは別の状態です。session.thread_terminatedはスレッドのアーカイブか再試行の使い切りで発火し、advisorのスレッドは相談が終わると終了します。メインスレッドの終了はセッション全体のアーカイブを含めてsession.status_terminatedにしか現れず、thread_terminatedは子スレッドにだけ届きます。完了をthread_terminatedで待つと、idleで止まった子スレッドを見落とします。
vault関連では、vault_credential.refresh_failedを購読しておくと、mcp_oauthの認証情報でリフレッシュトークンが失効したり、OAuthサーバー側で復旧不能なエラーが起きたタイミングをポーリングなしで検知できます。認証情報のローテーション処理をこのイベントにフックしておけば、セッション側のエラーを待たずに対処を始められます。
エンドポイントを登録する
Consoleの「Manage > Webhooks」からエンドポイントを登録します。登録時に必要な要素は3つです。
| 要素 | 内容 |
|---|---|
| URL | 内容HTTPS・ポート443・パブリックに解決可能なホスト名が必須 |
| イベント種別 | 内容このエンドポイントが受け取るdata.typeの一覧(購読していない種別は届かない) |
| 署名シークレット | 内容whsec_で始まる32バイトの秘密鍵。作成時に一度だけ表示される |
署名シークレットは再表示されないため、作成直後に安全な場所へ保存します。エンドポイントが受け取るイベント種別は登録時に選べるため、たとえばセッション系のイベントだけを購読して他の系統は購読しない、といった絞り込みができます。
署名を検証する
すべての配信にはwebhook-id・webhook-timestamp・webhook-signatureの3つのヘッダーが付きます。公式SDKのunwrap()を使えば、署名検証とペイロードのパースを1ステップで済ませられます。署名が無効か、ペイロードが5分より古い場合は例外を投げます。
環境変数ANTHROPIC_WEBHOOK_SIGNING_KEYに、エンドポイント作成時に表示されたwhsec_シークレットを設定しておきます。
from flask import Flask, request
import anthropic
client = anthropic.Anthropic() # ANTHROPIC_WEBHOOK_SIGNING_KEYを読む
app = Flask(__name__)
@app.route("/webhook", methods=["POST"])
def webhook():
try:
event = client.beta.webhooks.unwrap(
request.get_data(as_text=True),
headers=dict(request.headers),
)
except Exception:
return "invalid signature", 400
if event.data.type == "session.status_idled":
print("session idled:", event.data.id)
return "", 200TypeScript(Express)で実装する場合の落とし穴は、ボディパーサーにexpress.json()ではなくexpress.raw()を使う点です。署名はraw bytesに対して計算されるため、JSONへパースしてから検証すると失敗します。
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = client.beta.webhooks.unwrap(req.body.toString("utf8"), {
headers: req.headers as Record<string, string>
});
} catch {
return res.status(400).send("invalid signature");
}
// event.data.type で分岐する
res.sendStatus(200);
});イベントを処理してレスポンスを返す
受信ハンドラの処理は、次の順に並べると漏れがありません。
webhook受信の処理順
- 1
署名を検証する
生のボディを
unwrap()に渡します。例外が出たら400を返して終わりです。 - 2
処理済みか確かめる
event.idが記録済みなら、何もせず2xxを返します。 - 3
IDから現在の状態を取る
data.typeで分岐し、data.idで対象リソースをGETします。 - 4
2xxで受信を確定する
公式のサンプルは
204を返します。2xx以外の扱いは後述の無効化条件にまとめています。
イベントのペイロードは次の形です。
{
"type": "event",
"id": "whe_9d5c1f7e...",
"created_at": "2026-03-18T14:05:22Z",
"data": {
"type": "session.status_idled",
"id": "sesn_01XYZ...",
"organization_id": "8a3d2f1e-...",
"workspace_id": "c7b0e4d9-..."
}
}トップレベルのevent.idはイベントごとに一意ですが、配信ごとには一意ではありません。同じevent.idを2回受け取ったら、それは再送なので破棄してかまいません。webhook-idヘッダーと同じ値なので、これで重複排除できます。実装としては、event.idにユニーク制約を張ったテーブルへ処理済みイベントを記録し、INSERTが重複違反で失敗したら処理をスキップする、という単純な仕組みで十分間に合います。
取得処理は、公式のPythonサンプルでは次のように書きます。
if event.data.type == "session.status_idled":
session = client.beta.sessions.retrieve(event.data.id)
notify_user(session)
return "", 204ただし、GETで取り直せないイベントもあります。
取り直せるイベントと取り直せないイベント
取り直せる
session.status_idledやsession.updatedなどは、sessions.retrieveで現在の状態を読めます。イベントは「見に行く合図」と考え、到着順は当てにしません。
取り直せない
session.deleted・vault.deleted・agent.deleted・deployment.deleted・vault_credential.deleted・environment.deleted・memory_store.deletedは、取得するオブジェクトがすでにありません。イベントそのものが最終情報です。vault_credential.deletedは、vaultの削除に伴うcredentialの削除でも発火します。
配信保証の落とし穴 — 順序もキューイングも保証されない
webhookを組み込むときに最も誤解されやすいのが、配信の保証範囲です。
- 順序は保証されない:
session.status_idledがsession.outcome_evaluation_endedより先に届くことがあり、.deletedイベントが同一リソースの.archivedイベントより先に届くこともあります。後から届いた.archivedで削除済みのリソースを復活させない作りにします。 - 購読前のイベントは失われる: あるイベント種別に対して購読中のエンドポイントが1つも無いタイミングで発生したイベントは配信されず、あとから購読を追加しても遡って届きません。必要なイベント種別は使う前に購読しておく必要があります。
- 配信試行は最大3回だけ: 5秒から120秒のジッター付き指数バックオフを挟んで、エンドポイントごと・イベントごとに最大3回試行し、それでも失敗したイベントは、キューに積まれず破棄されます。喪失を知らせるシグナルもありません。すべての遷移を漏れなく把握したい場合は、webhookだけに頼らずAPIでリソースを定期的に一覧・取得して突き合わせる必要があります。
- タイムスタンプは配信のものであってイベント発生時刻ではない:
webhook-timestampヘッダーは再送のたびに再生成される「配信試行の時計」です。イベントが実際に発生した時刻はペイロード内のcreated_atを使います。再送のたびに作り直されるので、SDKの5分の鮮度検査にバックオフ後の再送が弾かれることもありません。
エンドポイントが自動的に無効化される3つの条件
エンドポイントは次の3ケースでdisabledに切り替わり、disabled_reasonにその理由が入ります。
無効化の条件と disabled_reason
3xxを返した
リダイレクトは追跡されず、初回の試行で即座に無効化されます。理由は
auto-disabled: endpoint URL returned a redirect (3xx)です。URLを移したときはConsoleでURLを更新して再度有効化します。URLが非公開IPに解決された
Anthropicが接続した時点で即座に無効化されます。理由は
auto-disabled: endpoint URL resolved to an invalid addressです。配信失敗が続いた
失敗が中断なく続いた期間で判定し、累計回数では数えません。1回でも
2xxが返れば計測はリセットされます。理由はauto-disabled after sustained delivery failuresです。
いずれも復旧可能で、原因を解消したあとConsoleから再度有効化できます。無効化されていた間に発生したイベントは、再有効化後に再送されません。
アーカイブと削除は冪等だが、通知されるとは限らない
environment.updatedは変更されたフィールドが1つ以上あるときだけ発火し、実質的に何も変わらない更新(no-op)はイベントを出しません。environment.archivedもすでにアーカイブ済みの環境を再度アーカイブした場合はイベントを出しません。memory_store.archivedも同様です。「アーカイブAPIを呼んだのにwebhookが来ない」という状況では、状態が変わっていなければイベントは発火しない仕様である点を先に疑います。なおenvironment.deletedは、アーカイブ済みの環境を削除した場合にも発火します。
アーカイブは連鎖することもあります。vaultをアーカイブすると、配下の各credentialに対しても個別にvault_credential.archivedが発火します。同様にdeploymentも、直接アーカイブした場合だけでなく、紐づくagentがアーカイブされた場合に連動してアーカイブされます。ただしagent自体が削除された場合は挙動が違います。deploymentはその場では消えず、次回のスケジュール実行のタイミングでアーカイブされます。スケジュールを持たないdeploymentは自動アーカイブの対象外です。子リソースへの連鎖が起きるのか、単なる状態不変で発火しないだけなのかを取り違えると、「削除したはずのリソースがまだ生きている」ように見える誤診断につながります。
一方で、自己ホスト型サンドボックスの環境が持つ作業項目(work items)は、いかなる状態変化でもwebhookイベントを一切発行しません。自己ホスト環境のジョブ進行状況をリアルタイムに追いたい場合は、webhookではなくAPIでのポーリングか、セッションのSSEイベントストリーム側で状態を追う設計にする必要があります。「環境がwebhookを購読していれば全ての子リソースの変化も拾える」という前提を置くと、自己ホスト構成でだけ想定外の抜け漏れが生まれます。
まとめ
webhookは長時間セッションの状態変化に気づくための合図で、SSEストリームの代わりにはなりません。状態はGETで取り直し、取りこぼしが許されない用途ではManaged AgentsのMCP接続で触れたSSEストリームやAPIの定期確認と併用します。全体の設計はManaged Agentsの設計思想を参照してください。