Development

Using Signed Webhooks to Trigger Agent Runs Safely

Antonio Evans9 min read

A night-time system diagram on a dark blue background.
Key points

Key points

  1. 1A signature check only protects an agent if it runs before any parsing, tool call or model invocation, and if a failed check produces no side effects.
  2. 2Verify HMAC-SHA256 over the raw request body plus a timestamp, reject requests older than a short tolerance such as five minutes, and compare in constant time.
  3. 3Deduplicate on the provider event ID before enqueuing, then enqueue and return 202 instead of running the agent inline in the request handler.
  4. 4Bound every run with a fixed tool permission set, a step ceiling and a per-event dollar budget so a single forged or looping event cannot drain an account.

The signature is a gate, not a logging step

Most teams that get burned by webhooks do not skip signature verification; they verify it in the wrong place. A handler that parses JSON, looks up a customer, resolves a tool list and then checks the signature has already done work an attacker can influence. The check has to be the first thing that can fail.

That matters more when the receiving end starts an AI agent rather than writing a database row. A row insert is bounded: the schema decides what can happen. An agent decides what to do next. The blast radius of an unverified event is whatever the agent's tools can reach, which is why the signature must be the boundary that decides whether the agent may run at all.

Standard Webhooks exists because every provider was implementing this differently the Standard Webhooks project. Its specification lays out the headers, the encoding and the signing scheme so that a receiver can be written once the specification. Webhooks.fyi tracks the same problem and recommends the spec alongside IETF HTTP Message Signatures standards overview. Kong ships a gateway plugin that validates incoming webhooks against it Kong plugin docs, which is a useful deployment option if you would rather reject bad traffic before it reaches application code.

Field research shows why this cannot be assumed: in a study of 100 webhook providers, HMAC was the most common authentication method, used by over two thirds of them, but only about 30 percent had any control against replay attacks ngrok field guide. If you accept a signature without a timestamp, a captured request is valid forever.

Step one: verify over the raw body, with a timestamp

The mechanics are narrow enough to state precisely. Compute HMAC-SHA256 over the raw bytes as received, not over a re-serialized object. Compare against the header value with a constant-time function. Reject anything missing a signature, with a malformed signature, or with a timestamp outside the tolerance.

The raw body detail is the one that silently breaks integrations. JSON parsers reorder nothing but they do change whitespace and number formatting, so a re-serialized body will produce a different digest than the one the sender signed. Guides on Shopify webhooks call this out explicitly and recommend timing-safe comparison reliable Shopify webhooks. A deeper write-up on HMAC secrets makes the same two points, plus a warning that plain string equality leaks timing information that lets an attacker guess a signature byte by byte HMAC secrets explained.

The timestamp is what makes the signature short-lived. Webhooks.fyi documents the pattern: the provider concatenates the timestamp with the payload, signs the result, and sends both. The listener checks the timestamp is within an acceptable window (their example uses 3 to 5 minutes), then recomputes the digest over payload plus timestamp replay prevention. Svix recommends a five-minute tolerance as the common choice: old enough to absorb clock skew, short enough that a captured request expires before it is useful webhook security best practices. Keep listener clocks synced with NTP, because a drifting clock turns into silent 403s.

Here is the shape of a verifier you can lift. It assumes you can reach the raw body, which in Express means capturing it in a middleware before the JSON parser runs.

const crypto = require('crypto');

function verify(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(signatureHeader.split(',').map(p => p.split('=')));
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return { ok: false, reason: 'no_timestamp' };
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
    return { ok: false, reason: 'stale' };
  }
  const signed = `${parts.t}.${rawBody}`;
  const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1 || '', 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return { ok: false, reason: 'bad_signature' };
  }
  return { ok: true };
}

Notice what the function does not do: it does not parse the payload, does not touch the database and does not call a model. That is the point. It returns a boolean and a reason, and the caller decides.

Step two: deduplicate before you enqueue

At-least-once delivery is the norm, not an edge case. Svix's own material states it plainly: providers retry until they receive a successful response, so a server that processes an event and then crashes before returning 200 will see it again idempotency and deduplication. Hookdeck lists the same causes: the same webhook arriving multiple times is expected, and the application must handle it webhook idempotency guide.

The naive check-then-insert has a race. Two copies arriving at two workers can both pass the "have I seen this" test before either records the event. The fix is to let the datastore arbitrate with a unique constraint and treat the conflict as a duplicate webhook idempotency. One insert, one winner:

CREATE TABLE processed_events (
  event_id   text PRIMARY KEY,
  payload_sha256 text NOT NULL,
  status     text NOT NULL DEFAULT 'queued',
  received_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO processed_events (event_id, payload_sha256)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;

No row returned means you have seen this event. Return 200 or 202 and do nothing else. A row returned means you own the event and may enqueue it.

One subtlety worth building in: dedup on the provider event ID alone lets an attacker who learns an event ID pre-register it and block the genuine delivery. Storing the payload hash alongside the ID closes that, because a mismatch between the stored hash and the incoming body is a signal, not a duplicate. Practical guides suggest keeping the dedup record for longer than the provider's retry window; Prismatic points out that a one-hour TTL against a 72-hour retry period protects only the first hour webhook processing idempotency.

Step three: return 202, run the agent in a worker

The single biggest performance and reliability change is refusing to do the work in the request. A handler that verifies, deduplicates and enqueues can finish in tens of milliseconds. A handler that starts an agent finishes in seconds to minutes, and the sender will time out and retry long before it returns.

Documented consumer timeouts vary enough to design conservatively: Svix expects responses within 15 seconds and GitHub within 10 seconds webhook reliability and retries. Neither is a budget you want an LLM loop inside. The Shopify-oriented guide recommends responding in under 500 milliseconds and moving heavy work to a worker queue reliable Shopify webhooks. Returning 202 Accepted is the honest status: the request was accepted, the work has not happened yet.

ConcernInline in the handlerEnqueue and return 202
Sender timeout riskHigh, retries arrive mid-runLow, response is immediate
Duplicate runsLikely under retryPrevented by dedup at enqueue
Agent failure recoveryLost with the requestRetried by the queue or sent to a DLQ
Cost of an abusive eventCharged immediatelyBounded by worker policy
ObservabilityRequest logs onlyJob records with event ID and hash
BackpressureEndpoint threads saturateQueue depth is a visible signal

Stripe and its peers give a rough sense of how long a sender will keep trying: Stripe retries for up to three days, Svix uses a fixed eight-attempt schedule and GitHub does not auto-retry at all webhook reliability and retries. Since you cannot know which behavior you will get from a given provider, the queue is what makes the two cases equivalent, because retries land on a durable job rather than a half-finished model call.

The worker does the second half of the trust decision. It reads the job, re-checks that the payload hash matches the record, and only then assembles the agent context. Guides on payment webhooks make the same structural point for money movement: verify signature, validate the payload, handle idempotency, then process handling payment webhooks reliably. An agent run deserves the same skepticism.

Step four: map the verified event to a minimal tool set

The signature proves who sent the event. It says nothing about what the event is allowed to cause. That is a separate decision, and it should be a lookup, not a model judgment.

Map event type to tools with an explicit table. Anything not in the table gets no tools and no model call.

Event typeAllowed toolsForbidden by default
order.paidread order, issue receiptrefunds, customer email, catalog writes
ticket.escalatedread ticket, read account, draft replyoutbound send, plan changes
invoice.overdueread invoice, draft reminderpayment capture, account suspension
user.deletedread user, schedule purgeany read of other users

Three rules make this hold. First, the mapping lives in configuration that a code review can read, not in a prompt. Second, tool credentials are scoped to the run: a read-only token for a read-only event. Third, the verified event body is untrusted input to the model, so tool arguments get validated the same way you would validate a user form. An invoice number arrived inside a signed payload, which means the provider believed it, not that it is safe in a shell command or a SQL string.

This is where the signature boundary earns its keep. If a forged request could reach the agent, it could name an event type that unlocks tools. Because the type is read from a body you have verified, and the tool set is selected from your table rather than from the model's own reasoning, forgery and prompt injection collapse into the same failure mode: the run proceeds with only the tools your table already permitted.

Step five: bound cost and steps per event

An agent loop is not a single API call. It is a sequence of calls whose length depends on the model's output, which means an event you accept can turn into an unknown number of billable requests. Anyone operating agents needs a per-event ceiling.

Set three numbers per event type and enforce them in the worker, not in the prompt:

  • Step ceiling. A hard cap on tool calls or model turns per run. When it is hit, the run stops and hands its state to a human or a dead-letter path.
  • Token and dollar budget. A prepaid account makes this concrete: track spend per run and abort when the counter exceeds the ceiling.
  • Wall clock. A maximum runtime, so a retried job cannot overlap with a stuck one.

Add a rate limit per sender and per event type at the enqueue step. A flood of valid signatures is still a flood, and the timestamp window means a burst of captured requests from the last five minutes can all pass verification at once.

A worked example shows the arithmetic. Suppose a run averages eight model turns at roughly 2,000 input and 400 output tokens per turn. A ceiling of twelve turns and a budget of three times the observed average gives you a boundary that normal runs never touch and runaway runs hit quickly. When the budget trips, the worker marks the event budget_exceeded, records the spend, and does not retry. Prismatic's guidance on failure states applies directly: track processing, completed and failed as separate states, and reclaim stuck work with a lease so a slow worker and a recovery worker do not both act webhook processing idempotency.

Building that plumbing, then wiring every provider into one client, is usually two jobs. Quanta Inference gives you text, image, video, audio and embedding models through one OpenAI-compatible API key with prepaid pricing in US dollars, which suits a worker that needs to cap spend per run Quanta Inference.

The checklists worth keeping

Keep these two lists next to the code. The first covers the receiving endpoint; the second covers the worker.

Endpoint checklist

  • Reject if the signature header is absent, malformed or not hex or base64 of the expected length.
  • Reject if |now - timestamp| exceeds the tolerance (300 seconds is common).
  • Compute the HMAC over raw bytes, with the timestamp inside the signed string.
  • Compare in constant time.
  • Insert the event ID and payload hash with a conflict clause before any other work.
  • Enqueue, then return 202 with the event ID.
  • Log the outcome and reason for every request, accepted or not, with the signature omitted.

Worker checklist

  • Re-verify the payload hash against the stored record.
  • Select tools from the event-type table; default to none.
  • Issue credentials scoped to that tool set and expire them with the run.
  • Enforce step, budget and wall-clock ceilings, and write the spend to the event record.
  • On failure, retry a bounded number of times with jitter, then move the job to a dead-letter queue with the error and attempt count.
  • Never delete a failed event silently; alert on dead-letter depth and age.

The observability advice from the webhook reliability literature is worth taking literally here: track endpoint 4xx and 5xx rates, acknowledgement latency, worker latency and success rate, and dead-letter size and age reliable Shopify webhooks. With an agent behind the queue, add spend per event type to that list, because cost is the failure mode that does not page anyone until the invoice arrives.

FAQ

FAQ

Do I still need the signature check if the webhook comes from a provider I trust?

Yes. The signature is not about trusting the provider's intent; it is about proving a specific request came from that provider and was not altered in transit. Svix describes the attack directly: an unmodified captured request still verifies, which is why the timestamp and idempotency ID belong inside the signed content webhook security best practices. Without the check, anyone who learns your endpoint URL can attempt to trigger a run.

What tolerance should I use for the replay window, and what breaks if it is too tight?

Five minutes is the common default and appears in multiple vendor recommendations webhook security best practices. The trade-off is clock skew: a listener whose clock drifts past the window rejects legitimate events, and the sender eventually gives up. Sync with NTP and monitor 403 rates after any infrastructure change, since a burst of stale rejections is usually a clock problem, not an attack.

Can I run the agent inline if it usually finishes in a second or two?

You can, but you inherit the sender's timeout instead of your own. GitHub expects a response within 10 seconds and Svix within 15, and either may retry webhook reliability and retries. The moment a model call is slow, a retry arrives while the first run is still going. Enqueuing removes that coupling for very little code.

How do I stop a valid event from starting an expensive run?

Treat cost as a configuration value per event type: a step ceiling, a token or dollar budget, and a wall clock. Enforce all three in the worker so the model cannot talk its way past them, and record the spend on the event row. If a run trips the budget, mark it and stop rather than retrying, because the retry will trip it again.

What if the provider does not send an event ID?

Every provider includes some identifier for the webhook itself, and some send a dedicated header for it webhook idempotency guide. If none is available, derive a deterministic key from the timestamp header plus a hash of the raw body. Storing the payload hash also gives you a second check, so a replayed body with a new ID is still visible in your records.

Sources

Sources

  1. Standard Webhooks
  2. standard-webhooks.md - GitHub
  3. Standardization Efforts - Docs - Webhooks.fyi
  4. Standard Webhooks - Plugin - Kong Docs
  5. Webhook Security in the Real World
  6. Reliable Shopify Webhooks: Idempotency, Retries, and ...
  7. HMAC Secrets Explained: Authentication You Can Actually ...
  8. Replay prevention - Docs
  9. Webhook Security Best Practices | Svix Resources
  10. Idempotency and Deduplication | Svix Resources
  11. How to Implement Webhook Idempotency - Hookdeck
  12. How Do I Make Webhook Processing Idempotent - Prismatic.io
  13. Webhook reliability: retries, idempotency, and the design ...
  14. Handling Payment Webhooks Reliably (Idempotency, Retries ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles