# 订阅 webhook

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

---

会话是长时间运行的交互。虽然大多数实时交互通过 [SSE 事件流](/docs/en/managed-agents/events-and-streaming)进行，但 webhook 会在重大状态变化时通知您。

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

## 支持的事件类型

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

## 注册端点

访问 [Console](https://platform.claude.com/settings/workspaces/default/webhooks) 中的 **Manage > Webhooks**。

Webhook 端点包括：

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

## 验证签名

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

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

<CodeGroup>
```python Python nocheck
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
```

```typescript TypeScript nocheck
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);
});
```

```csharp C# nocheck
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($"session idled: {idled.ID}");
    }
    // handle other event types

    return Results.Ok();
});
```

```go Go nocheck
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)
}
```

```java Java nocheck
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();
    });
}
```

```php PHP nocheck
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);
```

```ruby Ruby nocheck
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
```
</CodeGroup>

## 处理事件

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

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

```json
{
  "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-..."
  }
}
```

<CodeGroup>
```python Python nocheck
if event.data.type == "session.status_idled":
    session = client.beta.sessions.retrieve(event.data.id)
    notify_user(session)
return "", 204
```

```typescript TypeScript nocheck
if (event.data.type === "session.status_idled") {
  const session = await client.beta.sessions.retrieve(event.data.id);
  notifyUser(session);
}
res.sendStatus(204);
```

```csharp C# nocheck
if (webhookEvent.Data.TryPickSessionStatusIdled(out var idled))
{
    var session = await client.Beta.Sessions.Retrieve(idled.ID);
    NotifyUser(session);
}
return Results.StatusCode(204);
```

```go Go nocheck
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)
```

```java Java nocheck
event.data().sessionStatusIdled().ifPresent(idled -> {
    var session = client.beta().sessions().retrieve(idled.id());
    notifyUser(session);
});
exchange.sendResponseHeaders(204, -1);
```

```php PHP nocheck
if ($event->data->type === 'session.status_idled') {
    $session = $client->beta->sessions->retrieve($event->data->id);
    notifyUser($session);
}
http_response_code(204);
```

```ruby Ruby nocheck
if event.data.type == "session.status_idled"
  session = client.beta.sessions.retrieve(event.data.id)
  notify_user(session)
end
status 204
```
</CodeGroup>

顶层 `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 中手动重新启用。