Claude
Skills
Sign in
Back

claude-managed-agents-webhooks

Included with Lifetime
$97 forever

Receive and verify Anthropic Claude Managed Agents (CMA) webhooks. Use when setting up Claude Managed Agents webhook handlers, debugging signature verification, or handling agent session and vault events like session.status_idled, session.status_terminated, session.thread_created, vault.created, or vault_credential.refresh_failed.

AI Agents

What this skill does


# Claude Managed Agents Webhooks

## When to Use This Skill

- Setting up Claude Managed Agents (CMA) webhook handlers
- Debugging Anthropic webhook signature verification failures
- Handling agent session state changes (`session.status_idled`, `session.status_terminated`)
- Reacting to multiagent thread events (`session.thread_created`, `session.thread_idled`)
- Processing vault and credential events (`vault.created`, `vault_credential.refresh_failed`)
- Replacing long-poll loops on the Sessions API with push notifications

## Essential Code (USE THIS)

CMA webhooks follow the [Standard Webhooks](https://www.standardwebhooks.com/) spec. Every delivery carries three headers — `webhook-id`, `webhook-timestamp`, and `webhook-signature` — and is signed with HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw-body}`. The signing secret is the `whsec_`-prefixed value shown once at endpoint creation. The Anthropic SDK exposes `client.beta.webhooks.unwrap()` which wraps the same verification. Manual verification is shown here because it works in every framework without an extra SDK dependency.

### Express Webhook Handler

```javascript
const express = require('express');
const crypto = require('crypto');

const app = express();

// Standard Webhooks signature verification for Claude Managed Agents
function verifyClaudeSignature(payload, webhookId, webhookTimestamp, webhookSignature, secret) {
  if (!webhookId || !webhookTimestamp || !webhookSignature || !webhookSignature.includes(',')) {
    return false;
  }

  // Reject payloads older than 5 minutes to prevent replay attacks
  const currentTime = Math.floor(Date.now() / 1000);
  const timestampDiff = currentTime - parseInt(webhookTimestamp);
  if (timestampDiff > 300 || timestampDiff < -300) {
    return false;
  }

  // webhook-signature can carry multiple space-separated "v1,<sig>" pairs
  const payloadStr = payload instanceof Buffer ? payload.toString('utf8') : payload;
  const signedContent = `${webhookId}.${webhookTimestamp}.${payloadStr}`;

  // whsec_ prefix wraps a base64-encoded 32-byte key
  const secretKey = secret.startsWith('whsec_') ? secret.slice(6) : secret;
  const secretBytes = Buffer.from(secretKey, 'base64');

  const expectedSignature = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent, 'utf8')
    .digest('base64');

  return webhookSignature.split(' ').some(pair => {
    const [version, signature] = pair.split(',');
    if (version !== 'v1' || !signature) return false;
    try {
      return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
    } catch {
      return false;
    }
  });
}

// CRITICAL: Use express.raw() for webhook endpoint - signature is over raw bytes
app.post('/webhooks/claude-managed-agents',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const webhookId = req.headers['webhook-id'];
    const webhookTimestamp = req.headers['webhook-timestamp'];
    const webhookSignature = req.headers['webhook-signature'];

    if (!verifyClaudeSignature(
      req.body,
      webhookId,
      webhookTimestamp,
      webhookSignature,
      process.env.ANTHROPIC_WEBHOOK_SIGNING_KEY
    )) {
      return res.status(400).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString());

    // CMA payloads carry the event type under data.type, not the top-level type
    switch (event.data?.type) {
      case 'session.status_idled':
        console.log('Session idled:', event.data.id);
        // Fetch the full session: client.beta.sessions.retrieve(event.data.id)
        break;
      case 'session.status_terminated':
        console.log('Session terminated:', event.data.id);
        break;
      case 'session.thread_created':
        console.log('Multiagent thread created:', event.data.id);
        break;
      case 'vault_credential.refresh_failed':
        console.log('Vault credential refresh failed:', event.data.id);
        break;
      default:
        console.log('Unhandled event:', event.data?.type);
    }

    res.status(200).json({ received: true });
  }
);
```

### Python (FastAPI) Webhook Handler

```python
import os
import hmac
import hashlib
import base64
import time
from fastapi import FastAPI, Request, HTTPException, Header

app = FastAPI()

def verify_claude_signature(
    payload: bytes,
    webhook_id: str,
    webhook_timestamp: str,
    webhook_signature: str,
    secret: str,
) -> bool:
    if not webhook_id or not webhook_timestamp or not webhook_signature or ',' not in webhook_signature:
        return False

    # Reject payloads older than 5 minutes to prevent replay attacks
    try:
        timestamp_diff = int(time.time()) - int(webhook_timestamp)
    except ValueError:
        return False
    if timestamp_diff > 300 or timestamp_diff < -300:
        return False

    signed_content = f"{webhook_id}.{webhook_timestamp}.{payload.decode('utf-8')}"

    # whsec_ prefix wraps a base64-encoded 32-byte key
    secret_key = secret[6:] if secret.startswith('whsec_') else secret
    try:
        secret_bytes = base64.b64decode(secret_key)
    except Exception:
        return False

    expected_signature = base64.b64encode(
        hmac.new(secret_bytes, signed_content.encode('utf-8'), hashlib.sha256).digest()
    ).decode('utf-8')

    # webhook-signature can carry multiple space-separated "v1,<sig>" pairs
    for pair in webhook_signature.split(' '):
        parts = pair.split(',', 1)
        if len(parts) != 2:
            continue
        version, signature = parts
        if version == 'v1' and hmac.compare_digest(signature, expected_signature):
            return True
    return False


@app.post("/webhooks/claude-managed-agents")
async def claude_webhook(
    request: Request,
    webhook_id: str = Header(None, alias="webhook-id"),
    webhook_timestamp: str = Header(None, alias="webhook-timestamp"),
    webhook_signature: str = Header(None, alias="webhook-signature"),
):
    payload = await request.body()
    secret = os.environ.get("ANTHROPIC_WEBHOOK_SIGNING_KEY")

    if not verify_claude_signature(payload, webhook_id, webhook_timestamp, webhook_signature, secret):
        raise HTTPException(status_code=400, detail="Invalid signature")

    event = await request.json()
    # Handle event.data.type ...
    return {"received": True}
```

### Anthropic SDK alternative

If you already use the Anthropic SDK, replace the manual verification with `client.beta.webhooks.unwrap()`. The SDK reads `ANTHROPIC_WEBHOOK_SIGNING_KEY` from the environment, verifies the signature, rejects payloads older than five minutes, and parses the event:

```typescript
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();

// inside your handler, after reading the raw body:
const event = client.beta.webhooks.unwrap(rawBody, { headers });
```

```python
import anthropic
client = anthropic.Anthropic()  # requires: pip install "anthropic[webhooks]"

# inside your handler, after reading the raw body:
event = client.beta.webhooks.unwrap(raw_body, headers=dict(request.headers))
```

> **For complete working examples with tests**, see:
> - [examples/express/](examples/express/) — Full Express implementation
> - [examples/nextjs/](examples/nextjs/) — Next.js App Router implementation
> - [examples/fastapi/](examples/fastapi/) — Python FastAPI implementation

## Common Event Types

CMA webhooks deliver only the event `type` and `id` — fetch the full object via the API (`client.beta.sessions.retrieve(event.data.id)`). The event type lives under `event.data.type`; the top-level `event.type` is always `"event"`.

### Session events

| Event | Description |
|-------|-------------|
| `session.status_run_started` | Agent execution started; fires on every transition to `running`. |
| `session.status_idled` | Agent is awaiting input (tool approval, new user message). |
| `session.status_rescheduled` | Transient error; the session is retrying automatically. |
| `session.stat

Related in AI Agents