订阅 webhook

在重大事件发生时收到通知,无需轮询。


会话是长时间运行的交互。虽然大多数实时交互通过 SSE 事件流进行,但 webhook 会在重大状态变化时通知您。

Webhook 事件返回事件 typeid,而不是完整对象。当您收到 webhook 事件时,需要使用 GET 调用直接获取对象。这避免了在重试时传递过时数据,并使每次传递保持较小。

支持的事件类型

事件触发条件
session.status_run_started代理执行已启动。每次会话状态转换为 running 时触发。
session.status_idled代理等待输入,例如工具权限批准或新的用户消息。
session.status_rescheduled发生了瞬态错误,会话正在自动重试。
session.status_terminated会话遇到终端错误。
session.thread_created新的多代理线程已打开,意味着协调器调用的附加代理正在开始工作。
session.thread_idled多代理交互中的代理正在等待输入。
session.thread_terminated多代理线程已归档。
session.outcome_evaluation_ended单次迭代的目标评估已完成。
事件触发条件
vault.created保险库已成功创建。
vault.archived保险库已归档。还会为每个底层凭据发出 vault_credential.archived 事件。
vault.deleted保险库已删除。还会为每个底层凭据发出 vault_credential.deleted 事件。
vault_credential.created凭据已成功创建。
vault_credential.archived凭据已归档,直接归档或因保险库归档而导致。
vault_credential.deleted凭据已删除,直接删除或因保险库删除而导致。
vault_credential.refresh_failedmcp_oauth 凭据无法刷新(无效的刷新令牌,或 OAuth 服务器的不可恢复错误)。

注册端点

访问 Console 中的 Manage > Webhooks

Webhook 端点包括:

  • **URL:**必须是端口 443 上的 HTTPS,具有可公开解析的主机名。
  • **事件类型:**此端点接收的 data.type 值列表。端点只接收其订阅的事件,加上测试事件(请参阅传递行为)。
  • **签名密钥:**创建时生成的 32 字节 whsec_ 前缀密钥。仅显示一次,因此请安全存储以验证 webhook 传递。

验证签名

每次传递都携带 X-Webhook-Signature 头。使用 SDK 的 unwrap() 辅助函数一步验证签名并解析事件。如果签名无效或有效载荷超过五分钟,它会抛出异常。

ANTHROPIC_WEBHOOK_SIGNING_KEY 设置为端点创建时显示的 whsec_ 前缀密钥。

from flask import Flask, request
import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_WEBHOOK_SIGNING_KEY from env
app = Flask(__name__)


@app.route("/webhook", methods=["POST"])
def webhook():
    try:
        # unwrap() raises if the signature is invalid or the payload is stale
        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)
    # handle other event types

    return "", 200
import express from "express";
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic(); // reads ANTHROPIC_WEBHOOK_SIGNING_KEY from env
const app = express();

// IMPORTANT: use express.raw(), not express.json(). The signature is computed over raw bytes.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    // unwrap() throws if the signature is invalid or the payload is stale
    event = client.beta.webhooks.unwrap(req.body.toString("utf8"), {
      headers: req.headers as Record<string, string>
    });
  } catch {
    return res.status(400).send("invalid signature");
  }

  switch (event.data.type) {
    case "session.status_idled":
      console.log("session idled:", event.data.id);
      break;
    // handle other event types
  }

  res.sendStatus(200);
});
using Anthropic;

var client = new AnthropicClient(); // reads ANTHROPIC_WEBHOOK_SIGNING_KEY from env
var app = WebApplication.Create(args);

app.MapPost("/webhook", async (HttpRequest request) =>
{
    using var reader = new StreamReader(request.Body);
    var body = await reader.ReadToEndAsync();
    var headers = request.Headers.ToDictionary(header => header.Key, header => header.Value.ToString());

    UnwrapWebhookEvent webhookEvent;
    try
    {
        // Unwrap() throws if the signature is invalid or the payload is stale
        webhookEvent = client.Beta.Webhooks.Unwrap(body, headers);
    }
    catch
    {
        return Results.BadRequest("invalid signature");
    }

    if (webhookEvent.Data.TryPickSessionStatusIdled(out var idled))
    {
        Console.WriteLine({{CONTENT}}quot;session idled: {idled.ID}");
    }
    // handle other event types

    return Results.Ok();
});
package main

import (
	"fmt"
	"io"
	"net/http"

	"github.com/anthropics/anthropic-sdk-go"
)

var client = anthropic.NewClient() // reads ANTHROPIC_WEBHOOK_SIGNING_KEY from env

func webhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "could not read body", http.StatusBadRequest)
		return
	}

	// Unwrap returns an error if the signature is invalid or the payload is stale
	event, err := client.Beta.Webhooks.Unwrap(body, r.Header)
	if err != nil {
		http.Error(w, "invalid signature", http.StatusBadRequest)
		return
	}

	switch event.Data.Type {
	case "session.status_idled":
		fmt.Println("session idled:", event.Data.ID)
		// handle other event types
	}

	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/webhook", webhook)
}
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.core.UnwrapWebhookParams;
import com.anthropic.core.http.Headers;
import com.sun.net.httpserver.HttpServer;

// reads ANTHROPIC_WEBHOOK_SIGNING_KEY from env
AnthropicClient client = AnthropicOkHttpClient.fromEnv();

void main() throws Exception {
    var server = HttpServer.create(new InetSocketAddress(8000), 0);
    server.createContext("/webhook", exchange -> {
        var body = new String(exchange.getRequestBody().readAllBytes());
        var headers = Headers.builder();
        exchange.getRequestHeaders().forEach(headers::put);

        try {
            // unwrap() throws if the signature is invalid or the payload is stale
            var event = client.beta().webhooks().unwrap(
                UnwrapWebhookParams.builder()
                    .body(body)
                    .headers(headers.build())
                    .build());

            event.data().sessionStatusIdled().ifPresent(idled ->
                IO.println("session idled: " + idled.id()));
            // handle other event types

            exchange.sendResponseHeaders(200, -1);
        } catch (Exception _) {
            exchange.sendResponseHeaders(400, -1);
        }
        exchange.close();
    });
}
use Anthropic\Client;
use Anthropic\Core\Exceptions\WebhookException;

$client = new Client(); // reads ANTHROPIC_WEBHOOK_SIGNING_KEY from env

$body = file_get_contents('php://input');
$headers = getallheaders();

try {
    // unwrap() throws if the signature is invalid or the payload is stale
    $event = $client->beta->webhooks->unwrap($body, headers: $headers);
} catch (WebhookException) {
    http_response_code(400);
    exit('invalid signature');
}

match ($event->data->type) {
    'session.status_idled' => print "session idled: {$event->data->id}\n",
    // handle other event types
    default => null,
};

http_response_code(200);
require "sinatra"
require "anthropic"

client = Anthropic::Client.new # reads ANTHROPIC_WEBHOOK_SIGNING_KEY from env

post "/webhook" do
  headers = request.env
    .select { |key, _| key.start_with?("HTTP_") }
    .transform_keys { it.delete_prefix("HTTP_").downcase.tr("_", "-") }

  begin
    # unwrap raises if the signature is invalid or the payload is stale
    event = client.beta.webhooks.unwrap(request.body.read, headers: headers)
  rescue StandardError
    halt 400, "invalid signature"
  end

  if event.data.type == "session.status_idled"
    puts "session idled: #{event.data.id}"
  end
  # handle other event types

  status 200
end

处理事件

解析请求体,根据 data.type 进行切换,并通过 ID 获取资源。返回任何 2xx 以确认。其他任何内容(包括 3xx)都被视为失败并触发重试。

每个事件有效载荷都有相同的结构,包括事件类型、标识符和对象创建时间的时间戳。

{
  "type": "event",
  "id": "event_01ABC...",
  "created_at": "2026-03-18T14:05:22Z",
  "data": {
    "type": "session.status_idled",
    "id": "sesn_01XYZ...",
    "organization_id": "8a3d2f1e-...",
    "workspace_id": "c7b0e4d9-..."
  }
}
if event.data.type == "session.status_idled":
    session = client.beta.sessions.retrieve(event.data.id)
    notify_user(session)
return "", 204
if (event.data.type === "session.status_idled") {
  const session = await client.beta.sessions.retrieve(event.data.id);
  notifyUser(session);
}
res.sendStatus(204);
if (webhookEvent.Data.TryPickSessionStatusIdled(out var idled))
{
    var session = await client.Beta.Sessions.Retrieve(idled.ID);
    NotifyUser(session);
}
return Results.StatusCode(204);
if event.Data.Type == "session.status_idled" {
	session, err := client.Beta.Sessions.Get(r.Context(), event.Data.ID, anthropic.BetaSessionGetParams{})
	if err != nil {
		panic(err)
	}
	notifyUser(session)
}
w.WriteHeader(http.StatusNoContent)
event.data().sessionStatusIdled().ifPresent(idled -> {
    var session = client.beta().sessions().retrieve(idled.id());
    notifyUser(session);
});
exchange.sendResponseHeaders(204, -1);
if ($event->data->type === 'session.status_idled') {
    $session = $client->beta->sessions->retrieve($event->data->id);
    notifyUser($session);
}
http_response_code(204);
if event.data.type == "session.status_idled"
  session = client.beta.sessions.retrieve(event.data.id)
  notify_user(session)
end
status 204

顶层 event.id 对每个事件是唯一的,而不是对每次传递是唯一的。如果您收到相同的 event.id 两次,这是重试,您可以丢弃它。

传递行为

  • 不保证顺序。session.status_idled 可能在 session.outcome_evaluation_ended 之前到达,即使结果先产生。如果顺序很重要,请使用 created_at 时间戳进行排序。
  • **重试:**Anthropic 至少重试一次。重试传递相同的 event.id
  • 不跟随重定向。3xx 被视为失败。如果您的端点移动了,请在 Console 中更新 URL。
  • **自动禁用:**在大约连续 20 次失败传递后,端点会自动设置为 disabled 并带有机器可读的 disabled_reason,或者如果主机名解析为私有 IP 或端点返回重定向则立即禁用。解决问题后在 Console 中手动重新启用。