Webhooks · Agent security · API safety
AI agent webhook security: four controls for agents that send and receive webhooks
An AI agent that acts on Stripe or Twilio webhooks has a compound attack surface: a forged inbound event can trigger real outbound API calls — charges, SMS sends, refunds — that cost real money. Signature verification is necessary but not sufficient. This page covers the four controls that close both sides of the surface.
TL;DR
The four controls are: 1) verify webhook signatures before the event reaches the agent, 2) validate the event type and payload before calling any tool, 3) use a vault key with an endpoint allowlist for the agent's outbound API calls, and 4) log both the triggering webhook and the resulting API calls in the same audit trail under a shared event_id. Controls 1 and 2 protect inbound; controls 3 and 4 protect outbound. You need all four.
Why webhooks + agents = compound risk
In a non-agent system, a webhook handler validates the signature and queues a database update. The blast radius of a forged event is one database row. In an agent system, the webhook handler validates the signature and then invokes an agent that can call arbitrary tools. The blast radius of a forged event is every action the agent can take.
The pattern is increasingly common: a Stripe payment_intent.succeeded webhook triggers an order fulfillment agent; a Twilio SMS inbound webhook triggers a customer service agent; a Resend email.bounced webhook triggers a list-hygiene agent. Each pattern compounds the risk:
| Webhook trigger | Agent actions | Worst-case forged event |
|---|---|---|
Stripe payment_intent.succeeded |
Creates order, sends confirmation, triggers fulfillment | Attacker forges 500 events → 500 orders created, 500 confirmation emails sent |
| Twilio inbound SMS | Reads message, sends reply, updates CRM | Attacker sends 10,000 messages → 10,000 SMS replies billed to your account |
Stripe customer.subscription.deleted |
Downgrades account, sends notice, revokes access | Attacker forges deletion for paying customer → legitimate customer locked out |
Control 1: Verify webhook signatures before the event reaches the agent
Every major SaaS webhook provider signs events. Stripe uses HMAC-SHA256 with a Stripe-Signature header; Twilio signs requests with HMAC-SHA1 of the URL + parameters; Resend uses an svix-signature header (Svix). Verify before you do anything else — before parsing the payload, before passing to the agent, before any logging that could be a side effect.
import stripe
from fastapi import Request, HTTPException
STRIPE_WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
# Only now pass to the agent
await dispatch_to_agent(event)
return {"status": "ok"}
The signature check also prevents replay attacks: Stripe includes a t timestamp in the signature header, and the SDK rejects events with timestamps older than 300 seconds by default. Use this default — don't increase it to avoid false positives.
Control 2: Validate event type and payload before calling any tool
After signature verification, the next gate is event-type filtering. Your agent should only receive the specific event types it was designed to handle. An agent designed to fulfill orders on payment_intent.succeeded should explicitly reject payment_intent.payment_failed, customer.subscription.deleted, and every other event type — even if those events are legitimately signed by Stripe. This prevents confused-deputy attacks where Stripe itself sends a legitimate event of the wrong type.
ALLOWED_EVENT_TYPES = {"payment_intent.succeeded"}
async def dispatch_to_agent(event: stripe.Event):
if event["type"] not in ALLOWED_EVENT_TYPES:
logger.warning(f"Rejected event type: {event['type']}")
return # Silently drop — not an error, just not our event
intent = event["data"]["object"]
# Validate required fields before passing to agent
if not intent.get("metadata", {}).get("order_id"):
raise ValueError("Missing order_id in payment intent metadata")
await fulfillment_agent.run(
f"Fulfill order {intent['metadata']['order_id']}",
deps=AgentDeps(order_id=intent["metadata"]["order_id"])
)
Control 3: Use a vault key with an endpoint allowlist for outbound calls
Once you've verified the event is authentic and of the right type, you still need to limit what the agent can do with it. A vault key with an endpoint allowlist scopes the agent's Stripe access to only the operations the fulfillment workflow requires:
async def dispatch_to_agent(event: stripe.Event):
# ... validation above ...
# Issue a vault key scoped to this fulfillment run
vault_key = create_vault_key(
agent_run_id=f"fulfill_{event['id']}",
vendor="stripe",
allowed_endpoints=[
"GET /v1/payment_intents/*",
"POST /v1/shipping_rates",
],
daily_usd_cap=0, # Read-only + shipping rate creation, no charges
expires_in="30m",
)
await fulfillment_agent.run(
f"Fulfill order {intent['metadata']['order_id']}",
deps=AgentDeps(
vault_key=vault_key,
order_id=intent["metadata"]["order_id"],
triggering_event_id=event["id"],
)
)
The vault key for a fulfillment agent doesn't need POST /v1/payment_intents — that would let the agent create new charges, which is not part of fulfillment. Limiting the endpoint allowlist means a forged or malformed event can't cause the agent to do things outside its designed scope, even if control 1 or 2 fails.
Control 4: Log the triggering webhook and resulting API calls together
The audit trail value is in correlation: being able to answer "what Stripe API calls happened as a direct result of this webhook event?" This requires logging the triggering event_id alongside every outbound API call the agent makes:
# In the vault key policy
{
"triggering_event_id": "evt_1ABC...", # Stripe event ID
"agent_run_id": "fulfill_evt_1ABC...",
"vendor": "stripe"
}
# Querying the audit log
SELECT * FROM audit_log
WHERE triggering_event_id = 'evt_1ABC...'
ORDER BY created_at;
When an incident occurs — a customer says their order was never fulfilled, or they were charged twice — this query gives you the complete picture in one result: which webhook triggered the run, what the agent decided, and every API call it made.
How Keybrake fits
Keybrake handles controls 3 and 4: it issues vault keys with endpoint allowlists (control 3) and logs every proxied API call with your agent_run_id and triggering_event_id metadata (control 4). Controls 1 and 2 live in your webhook handler — they're your responsibility, and Keybrake's vault key policy adds a second layer of defense beneath them. The Free tier covers 1,000 proxied requests/month; the Hobby tier ($29/month) adds all vendors and 30-day log retention.
Related questions
Can I use a single vault key for multiple webhook events, or should it be one per event?
One vault key per agent run — and each webhook event should trigger a new agent run. This gives you per-event audit isolation and lets you revoke a specific run's access without affecting other concurrent runs. If you use one shared vault key for many events, revoking it during an incident stops all in-flight fulfillments. Per-event keys have a very low overhead (a single API call to issue them) and make incident response dramatically simpler.
What if the webhook provider doesn't sign events?
If the webhook has no signature, treat it as untrusted input — add your own shared secret as a query parameter or header, and validate it before passing the payload to the agent. Never trust the source IP alone; IPs are spoofable and CDN-fronted services may route from unexpected addresses. If you can't add any form of authentication to an incoming webhook, consider proxying it through a validation service before your agent sees it.
Does Keybrake intercept inbound webhooks, or only outbound API calls?
Keybrake proxies outbound API calls — the calls your agent makes to Stripe, Twilio, and Resend. It doesn't sit in the inbound webhook path. Controls 1 and 2 (signature verification and event-type filtering) are your webhook handler's responsibility. Keybrake's role is to enforce spend caps and endpoint allowlists on the API calls the agent makes after it processes the event.
Further reading
- AI agent audit trail — the schema and SQL queries for correlating agent actions back to triggering events.
- AI agent kill switch patterns — four ways to stop a runaway agent and their latencies, including mid-run vault key revocation.
- Stripe Agent Toolkit + MCP — the same security controls applied to agents using Stripe's official MCP server.
- AI agent Twilio security — four controls that prevent the $1,200 SMS bill, including per-agent spend caps for Twilio.