What the webhook does and does not do
If you already send the anthropic-user-profile-id header on every request, an enforcement action lands on one end customer instead of your whole organization 1. That is attribution. The next question is how fast your own systems learn that a customer was restricted, and what they do about it.
Anthropic added organization webhooks to the Admin API on 20 October 2026, in beta with the webhooks-2026-10-07 header. They cover two event types: one when Anthropic restricts or bans one of your organization's user profiles, and one when that restriction or ban ends. Access is by request through your Anthropic account team 2.
Read this part of the docs before you build anything: the organization webhooks page says event delivery isn't live, that Anthropic doesn't send the two events, and that an endpoint you register receives nothing. It adds that you can register endpoints and build your handler from the documented event format 3. That was the state of the page on 1 November 2026. Re-read it before you rely on events.
So the design below has two inputs into one state machine: webhook events when they arrive, and a polling reconciler that works today. The webhook makes enforcement fast. The reconciler makes it correct.
Register the endpoint
You need an organization on the Claude API with organization webhooks turned on and without HIPAA readiness enabled. You also need an Admin API key, an OAuth bearer token with the org:admin scope, or a personal or service account key that isn't scoped to a workspace. Creating, changing or deleting an endpoint needs the admin, owner or primary owner role 3.
The endpoint URL must be HTTPS on port 443, with a public domain name rather than an IP address, and two enabled endpoints can't share a URL. Anthropic disables an endpoint that returns a redirect, resolves to a non-public IP address, or keeps failing, and it doesn't send skipped events later 3.
import anthropic
client = anthropic.Anthropic() # Admin API key in ANTHROPIC_API_KEY
endpoint = client.beta.organization.webhooks.create(
url="https://hooks.example.com/anthropic-enforcement",
name="Enforcement actions",
enabled_events=[
"user_profile.enforcement_action_opened",
"user_profile.enforcement_action_resolved",
],
)
print(endpoint.id) # wep_...
store_secret(endpoint.signing_secret) # whsec_..., returned only nowThe whsec_ signing secret is returned only by the response that creates the endpoint or replaces the secret, so put it in your secret store immediately. If you regenerate it, the old secret stops working at once, and later deliveries, including retries of earlier events, are signed with the new one 3. Add an alert on the endpoint's state: poll GET /v1/organizations/webhooks and page someone if it reads disabled, because a disabled endpoint silently stops delivering and you re-enable it with an update that sends "state": "enabled" 3.
Two more details trip people up. Every request needs the anthropic-beta: webhooks-2026-10-07 header (the SDK sends it), and a missing header returns a 404, not a 400 3. And any request whose anthropic-beta header still carries user-profiles-2026-09-04 or an earlier value gets responses without the enforcement_status and enforcement_actions fields, and the list filter returns a 400. Remove that value from your requests 3.
Verify, deduplicate, acknowledge
Deliveries follow the Standard Webhooks specification, with webhook-id, webhook-timestamp and webhook-signature headers. Verify the signature with the endpoint's secret, using any Standard Webhooks library, before you trust the body. Return any 2xx within 5 seconds and do slow work afterward; anything else counts as a failed attempt. The same event can arrive more than once, with an id equal to the webhook-id header, so deduplicate on it 3.
The event holds IDs and nothing else: data.id is the profile id and data.enforcement_action_id starts with enac_ 3. The handler therefore does very little.
from standardwebhooks import Webhook # any Standard Webhooks library
from flask import Flask, request, abort
app = Flask(__name__)
verifier = Webhook(load_secret()) # the whsec_ secret
@app.post("/anthropic-enforcement")
def receive():
body = request.get_data() # raw bytes: verify before parsing
try:
verifier.verify(body, dict(request.headers))
except Exception:
abort(400)
event_id = request.headers["webhook-id"]
if not seen.add_if_new(event_id): # unique constraint in your database
return "", 204 # duplicate: acknowledge, do nothing
profile_id = request.get_json()["data"]["id"]
queue.enqueue("sync_profile", profile_id) # slow work happens elsewhere
return "", 204Verify against the raw bytes, not a re-serialized JSON object, or the signature will not match. Record the event id after verification, not before, so a forged request can't poison your dedupe table. Note what the handler does not do: it never reads the event type to decide what to apply. It only learns which profile changed.
Act on the profile's current state
Anthropic says events might arrive late or out of order, and that you should use the profile's current state, not the order events arrive in 3. It also sends an event only after the profile shows the change, so a retrieve returns that change or a later one. Hence the worker pattern: retrieve, compute, apply.
GET /v1/user_profiles/{id} returns enforcement_status and an enforcement_actions list. Each action has an id, a type of restriction or ban, a reason, opened_at, and expires_at or null; the reference example also shows an action field 34. The status is banned while a ban is open, restricted while any other action is open, and unrestricted when none is. Treat any other value as restricted. After a resolved event the resolved action is gone from the list, and a 404 means the profile was deleted 3.
def sync_profile(profile_id: str) -> None:
try:
p = client.user_profiles.retrieve(profile_id)
except anthropic.NotFoundError:
desired = "deleted"
else:
desired = {"unrestricted": "open", "banned": "ban"}.get(
p.enforcement_status, "restrict") # unknown values -> restrict
customer = customers.by_profile_id(profile_id)
if customer is None:
alert("enforcement for unmapped profile", profile_id)
return
apply_gateway_policy(customer, desired, actions=getattr(p, "enforcement_actions", []))Deleting a profile ends its open actions without a resolved event 3. Your own account-closure flow deletes profiles, so a deleted outcome should clear the mirrored policy and write an audit note, not leave a block behind for a customer who no longer exists.
Mirror the restriction as a gateway policy
A restriction on a profile is Anthropic's record about Anthropic traffic. The same customer can still send requests to your other providers through the same gateway, and whether that should continue is your decision. For a ban for abuse, most teams want the customer stopped everywhere until a person has looked. For a narrow restriction, they may want to keep serving the customer on another provider or lower the allowance. Decide this per status, in writing, before the first event.
The mapping from Anthropic's profile to your gateway needs one join: your gateway's end-customer id. Anthropic's profile carries a reference_id that you set to your own stable identifier, and it need not be unique 1. If you set it to the same opaque id you send the gateway as the end-customer id, the join is trivial; otherwise keep the table uprof_ -> customer id on the customer record, where Anthropic's guide already says to store the profile id 1.
With INFRO, two shipped features carry this. An end-customer cap rule sets a default per period for every end customer, and an override changes the amount for one customer. An override of 0 refuses every priced request for that customer, and an override applies from the next request 5. The customer is identified by the id your app sends in metadata.user_id, an opaque string up to 128 characters 5. Routing rules on a route can match end_user_id and send those requests to their own targets, with the first matching rule deciding 6.
{
"name": "Restricted at Anthropic",
"match": "all",
"conditions": [
{ "field": "end_user_id", "op": "in", "value": ["cust-8f2a1c"] }
],
"targets": [{ "connection_id": "conn_…", "model": "openai/gpt-6-sol" }],
"fallthrough": false
}The shape follows the rule example in INFRO's routes docs 6; the customer id, connection and model are placeholders. Because rules are checked in order and the first match decides, keep enforcement rules above ordinary tier rules. Set fallthrough to false so the default targets, which may include Anthropic, aren't tried when your restricted-customer targets fail.
Both writes can come from your sync worker through the management API, which serves end-customer caps and routes with a scoped, expiring token. Per its documentation, PATCH and DELETE need If-Match and POST accepts Idempotency-Key 7, which suits a worker that may run the same sync twice. I did not read the exact override endpoint, so take its path and body from the management API reference rather than from this post.
Restore what you overwrote
When the profile returns to unrestricted, the worker has to put things back. Don't assume the previous value was "no override". A customer may have had a negotiated override of $200 a day. In INFRO, an override of null means that customer is not capped under the rule, which is a different thing from having no override, where the default amount applies 5. If your worker writes null on resolution, it can accidentally uncap a customer who had been under the default.
Store the prior state before the first enforcement write.
def apply_gateway_policy(customer, desired, actions):
if desired in ("ban", "restrict") and not customer.saved_policy:
customer.saved_policy = gateway.read_policy(customer.id) # once
if desired == "ban":
gateway.set_cap_override(customer.id, amount=0)
elif desired == "restrict":
gateway.add_reroute_rule(customer.id)
else: # open or deleted
gateway.restore(customer.id, customer.saved_policy)
customer.saved_policy = None
audit.log(customer.id, desired, [a.id for a in actions])Writing the desired state, rather than a toggle, makes the function safe to run for every event, retry and reconciler pass. It also leaves a record: INFRO's audit log records policy changes with actor and timestamp, and your own log of the enac_ ids ties each change to Anthropic's action.
The reconciler is the part that has to work
Several situations produce no event at all 3:
- The action opened before Anthropic turned on delivery for your organization, or before you registered the endpoint.
- Every delivery attempt failed. Anthropic retries a few times over several minutes, then drops the event.
- The endpoint was disabled, and Anthropic skips events for a disabled endpoint without sending them later.
- Delivery isn't live yet, which is the documented state now.
The catch-up path is also documented: list your profiles with enforcement_status[]=restricted and enforcement_status[]=banned. A page can contain fewer profiles than limit, or none, before the list ends, so keep requesting until next_page is null 3. The filter arrived on 21 October 2026 together with the enforcement fields 2.
def reconcile():
flagged, page = set(), None
while True:
res = client.user_profiles.list(
enforcement_status=["restricted", "banned"], page=page)
flagged |= {p.id for p in res.data}
page = res.next_page
if page is None:
break
for pid in flagged:
queue.enqueue("sync_profile", pid)
for c in customers.with_saved_policy(): # mirrored but no longer flagged
if c.profile_id not in flagged:
queue.enqueue("sync_profile", c.profile_id)The second loop matters as much as the first. A missed resolved event leaves a customer blocked in your gateway after Anthropic cleared them, and only a comparison from your side finds it. Run the reconciler at deploy time, on a schedule you can defend, and whenever the endpoint's state changes. The schedule is your exposure window if events never arrive: choose an interval from how long you can tolerate serving a restricted customer, not from API cost.
Limits, and what a gateway cannot cover
- Traffic outside the gateway. A policy in the gateway constrains requests that go through it. Calls made direct to a provider with a copy of a key bypass it, so keep provider keys out of applications.
- Header forwarding. Check that the profile id still reaches Anthropic on the path your gateway uses, since the header is what scopes enforcement to one customer 1.
- Passthrough profiles. A passthrough profile stands for a company, not a person 1. Blocking the company id blocks everyone behind it, so decide whether a restriction should stop the whole company.
- Beta surface. Both the webhooks and their access are in beta, and the documented contract can change. Pin the beta header in code and re-read the page when you upgrade.
- Your own users' experience. A refused request should become a message your app can show. INFRO's cap refusal is built to return an error your app can show to that user 5, so map it to your own copy and don't retry it.
Rollout checklist
- Request organization webhooks access from your Anthropic account team, and confirm HIPAA readiness is off and CMEK isn't set on the default workspace 3.
- Remove
user-profiles-2026-09-04and earlier values fromanthropic-betaon profile requests. - Register the endpoint; store the
whsec_secret; alert onstate: disabled. - Verify against raw bytes, dedupe on
webhook-id, return 2xx inside 5 seconds. - Map every
uprof_id to the gateway's end-customer id; alert on unmapped profiles. - Write the status-to-policy table, including
deletedand unknown values. - Store prior policy before the first write; restore it, never
null. - Run the reconciler at startup and on a schedule; test it with a dropped-event drill.
- Log every change with the
enac_id, and review the audit trail.
