Inference Hooks: inline DLP for Claude Enterprise
- What Inference Hooks actually are — an HTTPS POST from Anthropic to a server you run, not a WebSocket and not an on-device agent
- The prompt frame schema — exactly what your AI security server sees (and what it never sees: system prompts, hidden reasoning, raw bytes)
- The verdict JSON: allow, deny with deny_reason, and why there is deliberately no redact action today
- The signing model — Standard Webhooks HMAC-SHA256, the two verification bugs that catch every first integration, and the whsec_ secret format
- The three operational levers that decide whether users get blocked or the model gets uninspected traffic: verdict timeout, failure handling, and the circuit breaker
- A rollout playbook that doesn't blow up on day one — shadow mode → percentage rollout → role exclusions → enforce, in that order
Announced August 5, 2026, Inference Hooks are Anthropic's first-party answer to the question every security team asks after they roll out a Claude Enterprise seat: how do I stop a prompt with regulated data from ever reaching the model? The answer, until now, was a corporate proxy that intercepted TLS traffic to claude.ai — brittle, incomplete, and blind to the Claude Code CLI. Inference Hooks moves the enforcement point inside Anthropic's perimeter: for every governed prompt, Anthropic pauses inference, POSTs the transcript to a server your organization operates, and waits for an allow or deny before the model sees anything.
The one paragraph version
Your organization stands up an HTTPS endpoint. Anthropic sends it every governed prompt as a signed POST (Standard Webhooks HMAC-SHA256). Your server returns {"action": "allow"} and inference proceeds, or {"action": "deny", "deny_reason": "..."} and the user sees the reason and never hits the model. The endpoint covers Claude Enterprise chat, Claude Code, and Cowork with one configuration. If your server times out or 500s, your failure handling setting decides whether the request blocks or proceeds uninspected. Roll it out gradually with shadow mode + percentage rollout + role exclusions before you flip Enforce verdicts on.
Inference Hooks vs the Compliance API
Both exist for the same audience — Claude Enterprise security, legal, and compliance teams — but they operate at opposite ends of the request lifecycle.
| Inference Hooks | Compliance API | |
|---|---|---|
| When | Inline, before inference runs | After the fact |
| What it does | Allows or denies each governed request in real time | Retrieves activity, chats, files, projects, users for audit and export |
| Direction | Anthropic → your server | You → Anthropic |
| Use it to | Stop a leak | Prove what happened |
Most enterprises will run both. Hooks are the tripwire; the Compliance API is the audit log.
How the verdict round trip works
- That's claude.ai chat, Claude Code (web, desktop, CLI), or Claude Cowork. Ancillary requests like conversation-title generation are NOT sent. Voice mode is out of scope for the beta.
- One HTTPS POST to the URL your admin configured. Headers include Content-Type: application/json, User-Agent: anthropic-dlp/1, and the three Standard Webhooks signature headers (webhook-id, webhook-timestamp, webhook-signature).
- Compute HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body bytes}` with the base64-decoded whsec_ secret. Reject anything more than 5 minutes off your clock or without a matching signature.
- Either {"action": "allow"} or {"action": "deny", "deny_reason": "..."}. Anthropic reads at most 64 KiB of the response body and does NOT follow redirects.
- On allow, inference proceeds normally. On deny, the user sees your deny_reason followed by the standing message your admin configured; the model never sees the prompt. Every denial is recorded in the organization's Activity Feed.
The whole point of running on Anthropic's servers, not on user devices, is uniformity: one config, one server, and every governed request on every surface is inspected the same way. There's nothing to install on employee laptops, and there's no per-app integration to keep in sync.
The prompt frame
Every request is a JSON body with these top-level fields:
| Field | Type | Description |
|---|---|---|
type | string | Always "prompt" today. New event types will appear — return allow on unrecognized values so you don't trip the circuit breaker. |
request_id | string | Opaque per-inference-call identifier. Equals the webhook-id header — use it as your idempotency key. |
tenant_id | string | null | Opaque identifier for the org. |
actor | object | Discriminated on type ("user" is the only value today). Carries a tagged id stable across a user's requests and email_address when available. Both fields can be null. |
source | object | {"application": "..."}. Known values: claude-ai, claude-code, config-test (used by the admin "Test connection" button). Open enum — new values will appear. |
session_id | string | null | Opaque conversation identifier. Don't parse it. Best-effort for Claude Code. |
model | string | null | Public model identifier for this request when available. |
messages | array | The conversation transcript up to the point of inference — see Content blocks. |
metadata | object | Reserved extension map. Empty today. Tolerate keys you don't know. |
A minimal live request looks like this:
{
"type": "prompt",
"request_id": "req_abc123",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"actor": {
"type": "user",
"id": "user_01AbCdEfGhIjKlMnOpQrStUv",
"email_address": "alice@example.com"
},
"source": { "application": "claude-ai" },
"session_id": "22222222-2222-2222-2222-222222222222",
"model": "claude-sonnet-5",
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": "Summarize the attached report." },
{
"type": "attachment",
"file_name": "q2-report.pdf",
"media_type": "application/pdf",
"size_bytes": 48213,
"text": "Q2 revenue grew 14% quarter over quarter..."
}
]
}
],
"metadata": {}
}
Content blocks
Each messages[].content[] entry has a type and matches the public Messages API content model. Tool results appear under the user role.
Block type | Fields |
|---|---|
text | text |
tool_use | id, tool_name, input |
tool_result | content (text, joined with newlines; binary parts are placeholder markers), is_error, tool_name, tool_use_id |
attachment | file_name, media_type, size_bytes, text (extracted text, transcript, or link metadata) |
What the transcript never contains
This is the part that trips privacy reviews.
- No system prompts. Anthropic's, yours (via projects/skills), or the model's constitution — none of it is sent.
- No hidden reasoning. Claude's extended-thinking chain is not part of the transcript your server sees.
- No tool definitions. Only the calls and their results.
- No raw bytes. Files and images are represented by metadata and extracted text. Image-only content (a screenshot of a document) will not be inspected.
- No Anthropic-internal context or trust boundaries.
The transcript is the conversation as the end user sees it, plus tool traces. A block or turn whose contents are all excluded is dropped entirely, so don't assume strict user/assistant alternation when you parse.
One size gotcha
Transcripts are sent untruncated up to a 10 MB ceiling. Common defaults are much smaller — nginx client_max_body_size is 1 MB, Express express.json() is 100 kB, most PaaS reverse proxies cap at a few MB. A body your server rejects is a webhook failure, which under Allow the request failure handling means the oversized prompt reaches the model uninspected. Raise your body limits before you enforce.
The verdict schema
Respond with HTTP 200 for both outcomes. The action field discriminates.
Allow:
{ "action": "allow" }
Deny:
{
"action": "deny",
"deny_reason": "This prompt appears to contain customer payment card data, which your organization's policy does not allow.",
"reference_id": "scan_01HXPT4R9V"
}
| Field | Type & limit | Semantics |
|---|---|---|
action | "allow" or "deny"; required | allow lets inference proceed. deny rejects the request. |
deny_reason | string or null; at most 500 chars, longer values truncated | Shown to the end user, appended to the standing message your admin configured. Write it for the user — tell them what to change, not what your scanner rule was called. |
reference_id | string or null; at most 50 chars from [A-Za-z0-9._:/-] | Your own identifier for this evaluation. Recorded on the denial's inference_hooks_request_denied Activity Feed entry, never shown to the end user. Keep it opaque — no request content, no personal data. |
Why there is no redact action
The verdict is deliberately binary. Anthropic could have added {"action": "redact", "rewritten_prompt": "..."} and let your DLP server sanitize the transcript in flight — but that would mean Anthropic ships whatever your box returns to the model, on your organization's authority. The design keeps that trust boundary sharp: your server evaluates content, it doesn't author it. If you need redaction, do it in the client before the user hits send.
A deny is never discarded over formatting
An oversized deny_reason is truncated; a malformed reference_id is silently dropped; the action is still honored. The reverse doesn't hold: anything other than HTTP 200 with a parseable verdict is a webhook failure, not a deny. If you signal blocks with HTTP 403 your denies quietly turn into fail-open allows (or blocks, depending on failure handling) and every one of them counts against the circuit breaker.
The smallest working server
Allow-all AI security server in 12 lines of Python
# Run with: python server.py — expose on an https:// URL your admin configures.
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class VerdictHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep the connection open between verdicts
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", 0)))
verdict = b'{"action": "allow"}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(verdict)))
self.end_headers()
self.wfile.write(verdict)
ThreadingHTTPServer(("", 8000), VerdictHandler).serve_forever()Put this behind a TLS-terminating reverse proxy on port 443, configure it as your endpoint, hit Test connection in the admin console — you'll see the allow verdict. This is exactly the shape of an archival-only integration: return allow unconditionally and persist the frame after responding, as a push alternative to polling the Compliance API. It is not a shape you should enforce with — it accepts every request, including unsigned ones. Add signature verification before you flip Enforce verdicts on.
Signature verification
Signing follows the Standard Webhooks spec. Three headers, lowercase as Anthropic sends them but case-insensitive on lookup (proxies re-case).
| Header | Contents |
|---|---|
webhook-id | Unique per delivery. Equals the body's request_id. Use as your idempotency key. |
webhook-timestamp | Unix time in seconds, as a decimal string. Reject if more than 5 minutes off your clock in either direction — that's the replay window. |
webhook-signature | Space-separated v1,<base64> values. Each is an HMAC-SHA256 over the byte string {webhook-id}.{webhook-timestamp}.{raw body bytes}. Accept the request if any value matches yours — use constant-time comparison. |
The two bugs that catch every first integration
- Verify raw bytes, NOT re-encoded JSON. Compute the HMAC over the body exactly as received, before any parsing or reserialization. A json.loads() → json.dumps() round trip changes whitespace and dies here.
- Decode the secret with a STANDARD base64 decoder, not a URL-safe one. The signing secret is the value after the whsec_ prefix, encoded with the standard alphabet (+ and /). A URL-safe decoder derives the wrong key bytes whenever the secret contains + or /, which is most of the time — and the failure is a silent constant-time mismatch.
Reference Python implementation (compressed from Anthropic's docs):
import base64, hashlib, hmac, time
TOLERANCE_SECONDS = 300
def verify(secret: str, headers: dict[str, str], body: bytes) -> bool:
h = {k.lower(): v for k, v in headers.items()}
try:
msg_id, ts, sigs = h["webhook-id"], h["webhook-timestamp"], h["webhook-signature"]
except KeyError:
return False # unsigned, not from Anthropic
try:
signed_at = int(ts)
except ValueError:
return False
if abs(time.time() - signed_at) > TOLERANCE_SECONDS:
return False # replayed, or clocks disagree
try:
key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
except ValueError:
return False # misconfigured secret
payload = f"{msg_id}.{ts}.".encode() + body
expected = b"v1," + base64.b64encode(hmac.new(key, payload, hashlib.sha256).digest())
return any(hmac.compare_digest(expected, s.encode()) for s in sigs.split())
Secret rotation
Rotation is an immediate cutover on the admin side, but requests signed with the previous secret can still arrive for about a minute afterward, plus anything already in flight. Have your server accept signatures from both the old and new secret during the rotation window so those stragglers aren't rejected as unsigned.
One-time exception
A connection test sent before your organization's first save arrives unsigned, because the signing secret doesn't exist yet. Accept unsigned requests until your admin confirms the secret exists, then reject them.
Operational semantics
Timeout
Your admin sets a verdict timeout between 1 and 10,000 ms, defaulting to 5,000 ms. That budget covers the entire round trip: connection, TLS handshake, request body upload, response body download.
Retry
Anthropic retries exactly once, after a 100 ms delay, and only when the connection attempt fails. Not on 500s. Not on timeouts. Not on parse errors. Once your server has responded — with anything — the exchange is done. The retry shares the same timeout budget and carries the same webhook-id and signature, so it's safe to key deduplication on webhook-id.
Failure handling
Everything else that isn't a clean 200-with-verdict is a webhook failure: timeouts, non-200 statuses (redirects included), unparseable or oversized response bodies, unreachable endpoints. On failure, your organization's setting decides:
- Block the request. Safe default for high-regulation environments. If your DLP server is down, users are blocked. Availability of Claude becomes availability of your scanner.
- Allow the request. Users keep working while your server recovers. Prompts flow uninspected during the outage — an accepted tradeoff for many orgs, but plan how you'll reconcile the gap in your audit trail.
Circuit breaker
Sustained webhook failures attributable to your AI security server trip a circuit breaker that stops enforcement: Anthropic stops calling your server, and failure handling applies to every request. Recovery isn't automatic — fix the server, then have your admin toggle Enforce verdicts back on. In practice this means: an unknown top-level type should return {"action": "allow"}, not an HTTP 500 — a future new event type would otherwise trip you into circuit-breaker territory on rollout day.
Latency
Every governed request in your organization pays your AI security server's round-trip in added latency. Load-test before you roll out to a large org; a 4-second scanner is invisible on a chat prompt but a nightmare on Claude Code tool loops that fire many requests in a row.
Source IP allowlisting
Requests originate from 160.79.106.0/24, part of Anthropic's published outbound IP ranges. Allowlist that block, not the inbound ranges on the same page — different lists. And allowlisting is not a substitute for signature verification: the block carries Anthropic egress beyond Inference Hooks.
The rollout playbook
- The first thing you turn on. Your server evaluates every request but no deny is enforced. You get to tune your rules against a week of real traffic before a single user is blocked.
- When you flip enforcement on, start at (say) 10% and climb. Cuts blast radius if your scanner has a false-positive spike.
- Roles you know will churn prompts faster than your scanner can keep up (senior engineering, on-call SRE) can be exempted while you tune. Not forever — but useful during the ramp.
- Only after the previous three have run clean for a soak period you set. This is when your deny_reason strings finally start reaching users, so review them once more before flipping the switch.
Anthropic's docs put it plainly: blocking employees on day one is how DLP programs die. Shadow mode exists for a reason.
Design your integration
- Deduplicate on webhook-id. It's unique per delivery and matches request_id in the body. A connection-failure retry reuses it, so it's a clean idempotency key.
- Store every verdict with its reference_id. Anthropic records reference_id on the Activity Feed entry for each denial, so you can join denials back to the exact scan decision in your own system.
- For always-allow archival integrations, RESPOND first, then persist. Answering before the write keeps your round trip out of the user's critical path — your storage system is not on the hot path.
- Write deny_reason for the person, not the SIEM. 'Remove the credit card numbers from your prompt and resubmit' beats 'PCI_REGEX_2A tripped, reference 4471'. Users will act on the first.
Coverage matrix
| Surface / access | Inspected by Inference Hooks? |
|---|---|
| claude.ai (web, desktop, mobile) | ✅ Yes |
| Claude Code (web, desktop, CLI) | ✅ Yes (session_id is best-effort, client-asserted) |
| Claude Cowork | ✅ Yes |
| Voice mode | ❌ Not in the beta |
| Conversation title generation, other ancillary | ❌ Not sent |
| System prompts, tool definitions | ❌ Never sent |
| Raw file / image bytes | ❌ Never sent (extracted text is) |
| Image-only content (e.g. screenshot of doc) | ❌ Not inspected |
| Claude Platform API keys (developer access) | ❌ Out of scope of Inference Hooks (Platform orgs, not Enterprise) |
| Amazon Bedrock / Google Cloud deployments | ❌ Not available on those planes |
Common mistakes
- Signalling a block with HTTP 403. That's a webhook failure, not a deny — your policy verdict gets thrown away and failure handling takes over.
- Returning any action other than 'allow' or 'deny'. Same story: webhook failure. If you're tempted to add a third state, do it in your own audit log, not in the verdict.
- Small default body limits (Express 100 kB, nginx 1 MB). A 3 MB transcript with a big PDF's extracted text will 413 at your reverse proxy. Raise limits to accommodate the 10 MB ceiling.
- URL-safe base64 decode of the whsec_ secret. Silent constant-time mismatch on every request until you notice all your requests are 'unsigned'.
- Reserialising the body before HMAC. Verify the raw bytes exactly as received. json.loads + json.dumps changes whitespace and breaks the signature.
- Rejecting unknown top-level `type` with 500. Trips the circuit breaker on the day Anthropic ships a new event type. Return `allow` on unknown types.
- Rejecting `source.application` values you don't recognise. It's an open enum. New values will appear and old integrations must not brick on them.
- Assuming user/assistant alternation. Turns whose blocks are all excluded are dropped from `messages`. Parse defensively.
When to use Inference Hooks vs a client-side proxy
Some orgs still run TLS-intercepting proxies for coverage of everything their employees do online. Inference Hooks aren't a proxy replacement — they're a Claude-specific enforcement point that sits inside Anthropic's perimeter and sees a richer, structured view of the conversation than a proxy that only sees encrypted bytes on the wire.
- Use Inference Hooks when you want structured access to what the model will actually see (tool calls, attachments, transcript), uniform coverage across chat + Code + Cowork, and no per-device install.
- Keep your network DLP for everything else on the box: file uploads to non-Claude services, browser traffic outside claude.ai, email attachments. The two don't overlap.
- Add the Compliance API for after-the-fact audit and export.
Quiz
Check yourself
0/3Next
- The Admin API: automate your Claude org — Enterprise user management and RBAC endpoints that pair naturally with hooks.
- MCP 2026-07-28: The Stateless Spec — the tool-call side of what your hooks will see in
tool_useandtool_resultblocks. - Refusals & Safety — Claude's own in-model refusal signals, which fire after your hook allows a prompt through.
- Current Models & Pricing