Signed Is Not Allowed: The Webhook Intake Layer I Put in Front of Every Automation
A valid webhook signature only proves who sent a request, not what that sender is allowed to change. This is the intake layer I build so webhooks can safely trigger automations and AI agents.
Pavel Duglas
AI Automation & MVP Architect
Almost every automation I build starts with a webhook. A payment succeeds, a form is submitted, a CRM deal moves, a Telegram update arrives, and something on my side wakes up and acts. For years I treated signature verification as the finish line. If the HMAC matched, the request was “trusted” and went straight into business logic. That assumption is wrong. On one client project it nearly let one tenant’s Stripe account refund orders belonging to another tenant. In this article I’ll walk through the intake layer I now put in front of every webhook, especially the ones that trigger AI agents.
What a signature actually proves
A valid signature proves exactly one thing: whoever sent this request knows the shared secret. Nothing more. It says nothing about:
- whether this request is new or a replay of one from last week
- which of your customers the event belongs to
- whether the object referenced in the payload belongs to that customer
- whether that sender is allowed to trigger this specific action
- whether the payload still reflects reality
In a single-tenant hobby project these gaps rarely bite. In a multi-tenant SaaS, where each customer connects their own Shopify, Stripe or HubSpot account, they are the whole attack surface. One leaked secret, one misconfigured integration, or one customer pointing their webhook at the wrong endpoint, and your code happily acts on data it has no business touching.
So I split webhook handling into two layers: intake, which decides whether an event is allowed to exist in my system, and processing, which decides what to do with it. Intake is boring, strict and identical for every provider. Processing is where the interesting logic lives.
The five questions intake must answer
Every incoming webhook has to pass five checks, in this order. Fail any of them and the request gets a response code and a log line, and never touches business logic.
1. Is it authentic?
This is the part everyone already does, and still gets wrong in small ways:
- Verify against the raw body. If your framework parses JSON before you compute the HMAC, whitespace and key ordering change and verification either fails randomly or you disable it “temporarily”. Capture the raw bytes first.
- Use constant-time comparison.
crypto.timingSafeEqualin Node,hmac.compare_digestin Python. Never==. - Support two active secrets. Rotation is impossible if you can only hold one key. I store
currentandpreviousper connection and accept either for a short window. - Scope secrets per connection, not per app. If every tenant shares one signing secret, a signature can’t tell you which tenant sent it. That leads directly into question four.
2. Is it fresh?
A captured, validly signed request can be replayed forever unless you stop it. Most serious providers include a timestamp in the signed content. I reject anything older than five minutes and anything more than a minute in the future. If the provider doesn’t sign a timestamp, I lean harder on the next check.
3. Have I seen it before?
Providers retry. Networks duplicate. Your own load balancer might deliver twice. I store every event ID in a table with a unique constraint and insert before doing anything else. If the insert conflicts, I return 200 and stop. The retry was already handled.
This is not the same as making the downstream work idempotent. You need both. Dedup at intake removes 99% of duplicates cheaply. Idempotent processing handles the rest, like two different event IDs describing the same real-world change.
4. Who does it belong to?
This is the check I skipped for years. The tenant must be resolved from how the request arrived, never from what the payload claims.
In practice that means each connection gets its own endpoint path, like /hooks/stripe/conn_8f2a..., or its own secret, or both. The connection record tells me the tenant ID and the external account ID I expect. Then I compare: if the payload says account: acct_X but this connection is bound to acct_Y, the event is rejected. A signature that matches but an account that doesn’t is exactly the case a naive handler lets through.
5. Is this sender allowed to cause this effect?
Now the real authorization. I keep a small policy table per provider that maps event types to the actions they may trigger and the resources they may touch:
const policy = {
stripe: {
'charge.refunded': { action: 'mark_order_refunded', resource: 'order' },
'invoice.paid': { action: 'extend_subscription', resource: 'subscription' },
},
typeform: {
'form_response': { action: 'create_lead', resource: 'lead' },
},
};
Anything not in the table is stored for debugging and ignored. Then, for the resource the event references, I check ownership: does order 4812 belong to the tenant resolved in step four? If not, reject and alert. A form tool should never be able to trigger a refund, and one tenant’s billing events should never move another tenant’s orders, even if every signature checks out.
Fetch back, don’t trust the payload
For anything that moves money, changes access or sends messages to real people, I treat the webhook as a notification, not as data. The payload tells me “something happened to invoice in_123”. I then call the provider’s API, using the tenant’s own credentials, and fetch the current state of that invoice.
This gives me three things for free:
- If the payload was forged or tampered with, the API returns the truth.
- If events arrive out of order, I act on the latest state instead of a stale snapshot.
- If the invoice doesn’t exist under this tenant’s credentials, that is a very loud authorization signal.
The cost is one API call per event and some rate-limit awareness. For payment and permission events I consider that non-negotiable. For high-volume low-risk events like analytics pings, I skip it.
Acknowledge fast, work later
Intake should finish in milliseconds. Verify, check freshness, dedupe, resolve tenant, check policy, write the event row, push a job to the queue, return 200. Everything else happens in a worker.
If you do real work inside the webhook request, a slow downstream API makes you miss the provider’s timeout, the provider retries, and now you’re processing the same event in parallel with itself. I’ve watched this create duplicate invoices in a client’s accounting system. Fast acknowledgment plus the dedup table plus a queue makes that class of bug disappear.
When the webhook wakes up an AI agent
This is where the stakes go up. More and more of my automations look like this: an email or form arrives, a webhook fires, and an LLM agent reads it, decides what to do, and calls tools.
Three rules I apply here:
The agent inherits the event’s authorization, not the system’s. If the event was resolved to tenant A with permission to create_lead, the agent run gets a scoped token that can only create leads for tenant A. It does not get the admin database connection. The policy table from step five becomes the agent’s tool allowlist for that run.
Payload content is untrusted input. A signed webhook from a form provider still contains whatever a stranger typed into the form. Signature verification tells you the form tool sent it, not that the text inside is safe to put in a prompt next to tool definitions. I wrap it clearly as user content and never let it change which tools are available.
Nothing from a webhook goes into long-term memory unmarked. If your agent stores summaries or “facts” for later runs, anything derived from external input gets tagged with its source event ID. Otherwise one malicious form submission today becomes “something the user told me” next month, and the agent acts on it with full confidence.
The minimal schema
You don’t need a platform for this. Two tables cover it:
webhook_connections: id, tenant_id, provider, external_account_id, secret_current, secret_previous, endpoint_token, statuswebhook_events: id, connection_id, provider_event_id (unique per connection), event_type, received_at, status (accepted, rejected, processed, failed), reject_reason, raw_payload
The reject_reason column is worth more than it looks. When a customer says “your integration didn’t fire”, I can answer in ten seconds: signature failed after they rotated their secret, or the event type isn’t in the policy, or the account ID didn’t match the connection. Without it, you’re reading logs at midnight.
I keep raw payloads for 30 days, then purge. Long enough to debug, short enough not to become a data liability.
The checklist I actually use
Before any webhook endpoint goes to production, I walk through this:
- Raw body captured before parsing
- Constant-time signature comparison, two secrets supported
- Timestamp window enforced, or dedup is strict if no timestamp exists
- Event ID inserted with a unique constraint before any work
- Tenant resolved from endpoint or secret, never from payload
- External account ID in payload matches the connection
- Event type exists in the policy table
- Referenced resource belongs to the resolved tenant
- High-risk events re-fetched from the provider API
- 200 returned after enqueue, work done in a worker
- Agents triggered by the event get a scoped token and a tool allowlist from policy
- Every rejection stored with a reason
It looks like a lot. In practice it’s a few hundred lines of shared code that I copy into every project, and each new provider is just a verification function plus a policy entry. The payoff is that I stop thinking of webhooks as trusted and start thinking of them as what they are: signed messages from the internet, asking permission to change my system.
FAQ
If I already verify the webhook signature, why do I need a separate authorization step?
The signature only proves the sender knows the secret. It doesn't tell you which tenant the event belongs to, whether the referenced object is theirs, or whether that type of event should be allowed to trigger that action. In a multi-tenant product, those gaps are exactly where cross-tenant bugs and abuse happen, so authorization has to be its own explicit check.
Should I always re-fetch data from the provider's API instead of using the webhook payload?
Not always. I re-fetch for anything that moves money, changes permissions or contacts real people, because it protects against tampered, stale and out-of-order events. For high-volume, low-risk events like analytics or activity pings, using the payload directly is fine as long as intake checks have passed.
How do I safely let a webhook trigger an AI agent?
Give the agent run a token scoped to the tenant and the actions allowed by your policy table for that event type, and nothing more. Treat all text in the payload as untrusted user input, keep it out of the part of the prompt that defines tools, and tag anything the agent stores in memory with the source event ID so external input never quietly becomes trusted history.
Related articles
Done for you
I will build a platform with accounts, roles and payments
A user area, an admin area, payment and CRM integrations, and a structure that survives the second version.
from $3,000 · 3 to 5 weeks