AI agents · Webhooks · API key security
AI agent webhook authentication: securing inbound triggers and outbound vendor calls
When an AI agent is triggered by a webhook — a Stripe payment event, a Shopify order, a GitHub push, a customer support ticket — it faces two authentication problems that are usually treated as one. The first problem is well-documented: verify the HMAC signature on the inbound payload so you know the event is legitimate and hasn't been replayed. The second problem is almost never discussed: once your agent starts running in response to that verified webhook, it holds a full-access vendor API key and can call Stripe or Twilio for any amount, any endpoint, as many times as the agent loop decides to. Verifying the trigger's legitimacy doesn't constrain what the agent does with its keys after the trigger passes. This page covers both layers.
TL;DR
Layer 1 (inbound): verify the webhook signature using the vendor's HMAC scheme — Stripe uses stripe.webhooks.constructEvent(), Twilio uses twilio.validateRequest(). Include timestamp validation to prevent replay attacks. Layer 2 (outbound): issue a vault key scoped to the event type and event ID immediately after signature validation — before the agent loop starts. The vault key caps what the agent can spend per webhook event, restricts which vendor endpoints it can call, and provides a per-event audit trail. Both layers are necessary: signature verification tells you the trigger is real; the vault key tells you the agent's response is bounded.
The two authentication gaps in webhook-triggered AI agents
Most webhook authentication guides focus entirely on the inbound layer — and that's necessary but not sufficient for AI agent workloads:
| Layer | What it protects against | What it doesn't protect against |
|---|---|---|
| Layer 1: Inbound signature verification | Forged webhook payloads from attackers who don't have the signing secret. Replayed webhook events (with timestamp validation). Event injection that triggers agent actions without a real upstream event. | A legitimate webhook event (real HMAC, real event ID, real timestamp) that triggers an agent which then loops, retries, or misinterprets the event and makes 100 Stripe calls instead of 1. The signature being valid doesn't cap what the agent does in response. |
| Layer 2: Outbound key scoping | An agent loop that fans out or retries beyond what the event semantics require. Agent misinterpretation of event payload that leads to calls on unintended endpoints. A stuck agent that processes the same event repeatedly (replay or queue bug) and accumulates charges. | A forged inbound trigger — Layer 2 alone doesn't verify the trigger is legitimate. Both layers are needed: the signature check prevents fake triggers; the vault key bounds the response to real ones. |
Layer 1: Inbound webhook signature verification
Every major SaaS API that sends webhooks provides a signing secret and an HMAC verification scheme. The pattern is the same across vendors: they sign the payload with a shared secret and include the signature in a header. You recompute the HMAC and compare. For AI agents that react to these events, this must happen before any agent logic runs:
import Stripe from "stripe";
import { createHmac, timingSafeEqual } from "crypto";
// Stripe: uses their SDK's constructEvent for verification
function verifyStripeWebhook(rawBody: Buffer, signature: string, signingSecret: string) {
// Stripe's SDK validates HMAC-SHA256 and timestamp (within 5 min default tolerance)
const event = stripe.webhooks.constructEvent(rawBody, signature, signingSecret);
return event; // throws if invalid or replayed
}
// Twilio: HMAC-SHA1 over sorted params + URL
function verifyTwilioWebhook(url: string, params: Record, signature: string, authToken: string) {
const keys = Object.keys(params).sort();
const data = url + keys.map(k => k + params[k]).join("");
const hmac = createHmac("sha1", authToken).update(data).digest("base64");
const sigBuffer = Buffer.from(signature);
const hmacBuffer = Buffer.from(hmac);
if (sigBuffer.length !== hmacBuffer.length) return false;
return timingSafeEqual(sigBuffer, hmacBuffer);
}
// Generic pattern: always use timingSafeEqual, never string equality
function verifyHmac(payload: Buffer, secret: string, receivedSig: string, algo = "sha256") {
const expected = createHmac(algo, secret).update(payload).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSig));
}
Two subtle verification errors that lead to security gaps:
- String comparison instead of
timingSafeEqual: comparing HMAC signatures with===is vulnerable to timing attacks that allow an attacker to infer the correct signature byte by byte. Always use a constant-time comparison. - No timestamp validation: an HMAC with a valid signature but a stale timestamp (more than 5 minutes old) is a replayed event. Stripe's SDK rejects these by default. If rolling your own verification, parse the timestamp from the signature header and reject events older than your replay window (typically 5-10 minutes).
Layer 2: Outbound vault key scoping per webhook event
After signature verification passes and before the agent logic runs, issue a vault key scoped to this specific webhook event:
import Stripe from "stripe";
async function handleStripeWebhook(req: Request, stripeClient: Stripe) {
// Layer 1: verify the inbound trigger
const signature = req.headers.get("stripe-signature")!;
const rawBody = await req.arrayBuffer();
const event = stripeClient.webhooks.constructEvent(
Buffer.from(rawBody),
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
// Layer 2: scope what the agent can do in response
const vaultKey = await issueVaultKeyForEvent(event);
// Now run agent logic with the scoped key
await runAgentForEvent(event, vaultKey);
}
async function issueVaultKeyForEvent(event: Stripe.Event) {
// Define per-event-type policies
const policies: Record = {
"payment_intent.payment_failed": {
endpoints: ["POST /v1/payment_intents/*/confirm", "POST /v1/refunds"],
cap_usd: 50, // can retry or refund, capped at $50
},
"customer.subscription.deleted": {
endpoints: ["POST /v1/refunds"],
cap_usd: 200, // can issue prorated refund
},
"invoice.payment_failed": {
endpoints: ["POST /v1/payment_intents/*/confirm"],
cap_usd: 500, // can retry payment up to $500
},
};
const policy = policies[event.type] ?? { endpoints: [], cap_usd: 0 };
const res = await fetch("https://proxy.keybrake.com/vault/keys", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KEYBRAKE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
vendor: "stripe",
daily_usd_cap: policy.cap_usd,
allowed_endpoints: policy.endpoints,
expires_in: "30m", // webhook handlers are fast; short TTL
agent_run_label: `webhook/${event.type}/${event.id}`,
}),
});
const { vault_key } = await res.json();
return vault_key;
}
async function runAgentForEvent(event: Stripe.Event, vaultKey: string) {
// Agent uses vault key, not the real Stripe key
const agentStripe = new Stripe(vaultKey);
(agentStripe as any)._api.basePath = "https://proxy.keybrake.com/stripe";
// Agent logic here — bounded by the vault key's policy
// ...
}
Key design choices in this pattern:
- Per-event-type endpoint allowlists: a
payment_intent.payment_failedevent should only let the agent callconfirmorrefund, notPOST /v1/chargesorPOST /v1/customers. The allowlist enforces this at the proxy layer, not just in application code. - Short TTL: webhook handlers run fast — issue a 30-minute key. If the agent requires a long-running process in response to a webhook, issue a longer TTL and revoke explicitly when done.
- Event ID in the label:
webhook/{event.type}/{event.id}makes every vendor call in the audit log traceable to a specific Stripe event. If a webhook is accidentally re-delivered and triggers a duplicate agent run, the duplicate run's vendor calls appear in the audit log under a matching label — detectable before they become a billing incident.
Webhook replay attacks and vault key deduplication
Even with HMAC validation, webhook replay is a real operational risk. Queue systems, CDNs, and load balancers can deliver the same webhook event multiple times due to network failures, retry logic, or misconfiguration. A webhook handler that's correctly idempotent at the application level still needs protection against multiple agent runs each triggering vendor API calls.
The vault key provides a deduplication boundary: use the webhook event ID as the vault key label and check whether a key with that label already exists before issuing a new one. If an active key with the same label already exists, the webhook event has been processed (or is currently being processed) — return 200 immediately without running the agent again. If the key has expired, a legitimate re-delivery (delivery after the original processing completed) can issue a fresh key and run the agent again.
async function issueOrFetchVaultKey(eventId: string, eventType: string) {
// Check if a key for this event already exists (deduplication)
const checkRes = await fetch(
`https://proxy.keybrake.com/vault/keys?label=webhook/${eventType}/${eventId}&active=true`,
{ headers: { Authorization: `Bearer ${process.env.KEYBRAKE_API_KEY}` } }
);
const { keys } = await checkRes.json();
if (keys.length > 0) {
// Active key exists — this is a duplicate delivery
return null; // caller should return 200 without re-processing
}
// No active key — issue a new one and process normally
return issueVaultKeyForEvent({ type: eventType, id: eventId } as any);
}
How Keybrake fits
Keybrake provides the outbound authentication layer for webhook-triggered AI agents. After your inbound HMAC check passes, issue a vault key scoped to the event type (specific allowed endpoints, dollar cap matching the event's expected impact, short TTL, event ID label). The agent calls vendor APIs through the proxy using the vault key. The real Stripe or Twilio secret stays in Keybrake. If a webhook is replayed, delivered twice, or the agent loops unexpectedly, the per-event dollar cap fires before the damage compounds. The per-event audit log entry (queryable by agent_run_label) shows exactly which vendor calls were made in response to which webhook event.
Related questions
How do I verify Stripe webhook signatures correctly in Python?
Use Stripe's official Python SDK: stripe.Webhook.construct_event(payload, sig_header, endpoint_secret). Pass the raw request body as bytes (not decoded to a string), the Stripe-Signature header value, and your webhook endpoint's signing secret (found in the Stripe dashboard under the webhook endpoint configuration). The SDK validates the HMAC-SHA256 signature and rejects events with timestamps more than 300 seconds in the past. Do not decode the payload to a string before verification — HMAC is computed over the raw bytes, and string decoding (especially with different encoding handling) can cause signature mismatches even on valid events.
Should I issue a separate vault key for each webhook event, or can I reuse keys?
Issue a separate key per event. The vault key's job is to bound what the agent can do in response to a specific event — the cap should reflect that event's expected maximum impact, not an accumulated daily budget that carries over across events. Reusing a key across events means a series of small events can collectively hit the cap, causing legitimate later events to fail because earlier events exhausted the budget. Per-event keys make the audit log clean too: each vault key in the audit corresponds to exactly one webhook event, making incident review straightforward.
What should I do when the vault key cap is hit during webhook processing?
Return HTTP 200 to the webhook sender — a non-200 response will trigger a retry delivery, which would just hit the cap again. Log the cap-hit event with the webhook event ID and the cap amount. Depending on your business logic, either: (1) escalate to a human for review (the event was legitimate but the agent's planned response exceeded the budget), or (2) mark the event as "partially processed" in your database and schedule a manual review. Do not return 429 or 5xx to the webhook sender unless you want the event re-delivered and processed again — that would just start another agent run under a new vault key.
Further reading
- AI agent webhook security — broader overview of webhook security for agents including replay protection, endpoint hardening, and signature storage.
- AI agent kill switch patterns — when vault key cap-enforcement is the kill switch: how to stop a webhook-triggered agent mid-execution via key revocation.
- AI agent audit trail schema — how to structure the per-event audit log so you can reconstruct exactly what the agent did in response to each webhook delivery.
- Stripe Agent Toolkit and MCP security — the same two-layer pattern applied to agents using Stripe's official AI toolkit and Model Context Protocol.