How to Trigger AI Agents from Webhooks: Signature Verification, Retries, and Idempotency
Message me in Slack when Dev finally uploads the Q4 financials. That one line is why webhooks matter for AI agents. Without them, an agent can only act when someone asks it to, or when it is polled on a schedule. Webhook
Message me in Slack when Dev finally uploads the Q4 financials. That one line is why webhooks matter for AI agents. Without them, an agent can only act when someone asks it to, or when it is polled on a schedule. Webhooks are what let an agent react the moment something actually happens somewhere else.
That reactiveness is also what makes webhooks riskier for agent systems than for a typical backend. A webhook that corrupts a database row is a bug. A webhook that triggers an agent to send an email or update a customer record is now a real world action, and a bad one is harder to walk back. Worse, the agent's own actions can generate new webhook events, creating the conditions for the system to react to itself.
This post covers the three things that have to be right before an agent is allowed to act on an incoming event: verifying the signature actually came from the provider, blocking replayed events, and deduplicating retries with idempotency keys, along with when to fall back to polling. It does not cover the self triggered loop problem in depth. That gets its own post, though it is touched on briefly below.
Verifying the Signature
When a webhook only updates a row in a database, a bad payload is a data quality problem. When a webhook can trigger an AI agent to send an email, post to Slack, or update a customer record, a bad payload is an action problem. That difference is why signature verification is not optional for agent triggered workflows the way it might feel optional for a low stakes internal tool.
The standard approach is HMAC SHA256. The provider and your server share a secret key. The provider computes a hash of the request body, often combined with a timestamp as covered in the next section, using that key, and sends the result in a header. Your server recomputes the same hash and compares the two.
That comparison has to run through a constant time function, such as crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, or Rack::Utils.secure_compare in Ruby. A plain == check can leak timing information that lets an attacker guess the correct signature byte by byte across repeated requests.
The raw body problem
This is where most implementations break. Frameworks like Express parse the incoming body into a JavaScript object before your handler ever sees it. If you then call JSON.stringify() on that object to recompute the hash, you are hashing a different set of bytes than the provider sent, because of whitespace, key ordering, and encoding differences. The signature check fails even though the payload is genuine.
The fix is to capture the raw body before anything parses it:
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
}
}));
app.post('/webhooks', (req, res) => {
const isValid = verifyHmacSignature(req.rawBody, req.headers['x-signature']);
if (!isValid) return res.status(401).send('Invalid signature');
res.status(200).send('OK');
});
Every provider also names its header and formats its hash differently, which adds friction once an agent is wired into a dozen apps instead of one:
- Slack sends the signature in
X-Slack-Signatureand the timestamp inX-Slack-Request-Timestamp - GitHub sends
X-Hub-Signature-256, keeping an olderX-Hub-Signatureonly for backward compatibility - HubSpot sends
X-HubSpot-Signature-v3alongsideX-HubSpot-Request-Timestamp - Notion sends
X-Notion-Signature
None of these details are hard on their own, but they add up fast across a real integration stack. A plugin catalog like Corsair's that ships signature verification built into each integration removes this per provider parsing work entirely.
Blocking Replay Attacks
A valid signature only proves a request once came from the provider. It does not prove the request is being sent for the first time. An attacker who intercepts a genuine, signed payload, perhaps from an insecure network or a compromised log, can resend that exact request later and your server will verify it successfully, because the signature itself is still correct.
This is a replay attack, and the defense is a timestamp bound directly into the signature itself.
Most providers send a timestamp header, such as X-Slack-Request-Timestamp or X-HubSpot-Request-Timestamp, alongside the signature. Your server checks that the timestamp falls within a tight freshness window, typically 5 minutes or 300 seconds, and rejects anything older.
Checking the header alone is not enough, since an attacker who controls the request could simply rewrite the timestamp to look fresh. To prevent that, the provider folds the timestamp into the string that gets hashed, not just into a separate header. Slack's format is v0: plus the timestamp plus the raw body; HubSpot concatenates the method, URI, raw body, and timestamp. Because the timestamp sits inside the hashed string, changing it invalidates the signature along with it.
For an agent triggered workflow, this matters more than it would for a typical webhook consumer. A replayed event that updates a timestamp field in a database is low stakes. A replayed event that causes an agent to resend an email or post a duplicate Slack message is a real world consequence a user will actually notice.
Handling Retries Without Duplicating Agent Actions
Webhook providers guarantee at least once delivery, not exactly once. Network blips, slow responses, and timeouts all cause providers to resend events your server already received. For a typical application this means an occasional duplicate database write. For an agent, it means the same instruction, such as send this email or post this update, arriving twice and being capable of running twice.
Acknowledge fast, process separately
Respond with a 2xx status as soon as the signature is verified, before any agent logic runs. Providers enforce short response windows (GitHub expects a response within 10 seconds) and will treat a slow handler as a failed delivery, triggering a retry even though your server is still working on it. The practical pattern is to verify, enqueue the event for background processing, and return 202 Accepted immediately.
What your response code tells the sender
A 5xx response or a timeout tells the provider the failure was temporary, so it backs off and tries again later. A 4xx response tells the provider the request itself was invalid, for example a failed signature check, and that retrying the identical payload will not help. Mixing these up causes either silent data loss or retry storms, so the distinction is worth getting right deliberately rather than by accident.
Backoff and dead letters
If a delivery keeps failing past a handful of attempts, most systems apply exponential backoff with jitter to space retries out and avoid hammering a downed endpoint all at once. After a capped number of attempts, typically 5 to 10, the event is moved to a dead letter queue instead of retrying forever, so it can be inspected and replayed manually rather than silently lost.
Idempotency keys
The actual deduplication mechanism is an idempotency key: typically the event or delivery ID the provider already includes in the payload. Store that ID under a unique constraint, alongside a fingerprint of the request and its processing status. This is especially important for multi tenant webhooks, where the constraint has to be scoped per tenant as well as per event ID, so two customers that happen to share a similar event ID never collide.
When a new event arrives, attempt an atomic insert. If it succeeds, you own the event and process it. If it fails because the key already exists, the event is a duplicate, so you skip the agent action and still return a success status, since an error response would only cause the provider to keep retrying.
Keys do not need to live forever. A 24 hour retention window matches what most providers use for their own retry windows, and a 72 hour window gives a buffer against a bug that ships on a Friday and gets caught the following Monday.
Deduplication answers whether an event has already been seen. It does not answer whether the task that event kicked off actually finished, which is a separate durability problem for longer running agent workflows.
When Webhooks Aren't Enough: Falling Back to Polling
Webhooks are not a complete substitute for polling, and treating them as one leads to quiet gaps in what an agent knows. A few situations call for polling even in a webhook first design.
Reconciliation: webhook delivery depends on network transit, and events do get delayed or dropped. Running a periodic reconciliation poll against a provider's history endpoint catches anything a webhook missed.
Historical backfill: webhook subscriptions only cover events going forward from the moment they are configured. A newly connected account has no event history to react to, so the only way to establish a starting state is to poll a list or history endpoint once at setup.
Providers without webhook support, or with rate capped channels: not every API offers webhooks, and some that do cap how many events they will send per second, silently dropping the rest during a spike. Periodic polling is the fallback for both.
Synchronous reasoning: webhooks are inherently asynchronous. If an agent needs to check current state in the middle of a reasoning step, rather than reacting to a future push, it needs a synchronous read, not a notification that may arrive later.
In practice, most reliable integrations end up running both: webhooks for fast reaction, and a background poll as the safety net underneath. When deciding which providers in a stack need that fallback and which already push reliable events, it is worth checking each plugin's webhook coverage rather than assuming every integration behaves the same way.
The Agent Specific Trap: Self Triggered Loops
There is one failure mode specific to agent systems worth a mention here, even though it is not the focus of this post. When an agent posts a Slack message or updates a CRM record, that action itself generates a webhook event from the provider. If that event flows back into the same agent without any filtering, the agent reacts to its own change, and a cascading loop starts.
The fix at a high level is to filter by actor: check the bot or user ID on the incoming event against the agent's own identity, and drop anything that matches before it reaches the agent's reasoning step. Providers typically include this actor metadata (user_id, bot_id, app_id) directly in the payload, so the check is usually a lookup rather than a guess. If a custom integration needs this filtering built in from scratch, building a plugin on Corsair's SDK is one way to get webhook routing and actor filtering set up together rather than separately.
This failure mode has enough edge cases, recursion depth, timing windows, rate limits, that it earns its own dedicated post rather than a few paragraphs here.
How Corsair Handles This
Everything above, raw body capture, per provider header formats, timestamp binding, retry semantics, is the kind of work that has to be done once correctly and then never thought about again. That is the layer Corsair is built to take off your plate.
Every plugin ships with a typed, signature verified webhook handler already wired in, and every provider routes through a single endpoint on your server rather than one per integration:
app.post('/webhooks', async (req, res) => {
const webhook = processWebhook(corsair, req.headers, req.body);
res.status(200).send('OK');
});
Signature checks, raw body handling, and timestamp validation happen inside that call, scoped per tenant for multi tenant setups. What reaches your integration code is already verified and deduplicated: the event your agent acts on, not the raw request your agent has to defend itself against. That leaves your own code free to focus on what the event should cause the agent to do, rather than whether the event can be trusted in the first place.
Closing Thoughts
Getting webhooks right for an agent comes down to three checks done in order: confirm the request genuinely came from the provider, confirm it has not been seen before, and only then let the agent act on it. Everything else, retries, polling fallbacks, backoff timing, builds on getting that order right first.
Two related failure modes are worth reading next if you are building this out fully: how to stop an agent from triggering an infinite loop off its own actions, and how to keep a long running agent task durable once a webhook has kicked it off.
If you would rather not build and maintain this layer yourself, Corsair handles signature verification, retries, and deduplication for every integration it ships, so your agent only ever sees events that have already been checked and confirmed real. It is open source, so if a provider or plugin is missing, you can add it yourself rather than waiting on a roadmap. Self hosting is free, and credentials never leave your own database. You can see the full set of supported integrations and try it directly at corsair.dev.
Frequently Asked Questions
Why does my webhook signature verification keep failing even though the secret key is correct? This almost always comes from body parsing. Most frameworks convert the incoming payload into an object before your handler runs, and re serializing that object to check the signature produces different bytes than the provider originally sent. Capture and hash the raw, unparsed body instead of the parsed object.
Is HMAC SHA1 still acceptable for new webhook integrations? No. SHA1 is considered cryptographically weak and is kept by some providers, like GitHub's older X-Hub-Signature header, purely for backward compatibility. New integrations should use SHA256 variants wherever the provider offers them.
How long should I keep idempotency keys before deleting them? 24 hours matches the retry window most providers use and is a reasonable default. 72 hours gives extra protection against a bug shipped on a Friday that is not caught until the following week. Keys are meant to guard against near term retries, not serve as a permanent log.
Should every integration use webhooks, or is polling sometimes the better choice? Webhooks are better for fast reaction, but they should rarely stand alone. Polling is still needed for historical backfill, for providers that do not support webhooks at all, and as a reconciliation check against events a webhook silently dropped.
What is the difference between returning a 4xx and a 5xx from a webhook handler? A 5xx tells the provider the failure was temporary and it should retry later. A 4xx tells the provider the request itself was invalid, such as a failed signature check, and retrying the same payload will not change the outcome. Returning the wrong one either causes needless retries or silent data loss.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.