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 triggerAgent actionsWorst-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.

Get early access

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