Inbound email webhooks are fast, automation-friendly, and ideal for LLM agents that need to react to verification codes, magic links, intake emails, or operational messages. They also create a new trust boundary: your webhook endpoint is public, and anyone on the internet can try to POST data that looks like an inbound email event.
That is why verification must happen before parsing, routing, queueing, storing, or showing email content to an AI agent. A safe inbound email workflow treats the webhook request as untrusted until it passes cryptographic checks, freshness checks, replay detection, and schema validation.
This guide walks through a provider-neutral pattern for verifying webhook payloads for inbound email, with practical notes for AI agents, QA automation, and JSON-based email pipelines.
Webhook verification is not the same as email authentication
Email systems already have authentication mechanisms such as SPF, DKIM, and DMARC. Those help receiving mail servers evaluate whether an email was authorized by a domain. They do not prove that the HTTP request hitting your webhook endpoint came from your inbound email provider.
For webhook consumers, the threat model is different.
| Layer | What it helps prove | What it does not prove |
|---|---|---|
| SPF, DKIM, DMARC | The sender domain relationship for the original email | That the HTTP webhook request is authentic |
| Webhook signature | The payload was signed by the webhook provider using your shared secret or signing key | That the original email sender is trustworthy |
| Timestamp freshness | The signed request is recent enough to process | That the payload should be processed twice |
| Replay detection | A delivery has not already been accepted | That the email content is safe for an LLM |
| JSON schema validation | The payload shape matches your processing contract | That links, HTML, attachments, or text are benign |
For inbound email automation, you need both ideas. Email authentication may be useful metadata. Webhook payload verification is mandatory for securing your application boundary.
The verification sequence to use
A robust webhook handler should be boring, deterministic, and fail-closed. The safest sequence is:
- Capture the raw request body exactly as received.
- Read the provider’s signature metadata from headers.
- Enforce a timestamp tolerance to reject stale requests.
- Recompute the expected signature over the exact signed content.
- Compare signatures using a constant-time comparison.
- Reject replays using a delivery ID or equivalent unique event key.
- Parse and validate the JSON payload only after authenticity checks pass.
- Enqueue processing and return a quick success response.
The order matters. If your code parses JSON before verifying the raw body, a framework might normalize whitespace, reorder fields, or alter encodings. That can break legitimate signatures, or worse, cause your application to trust data before it knows where it came from.
Step 1: Capture the raw body
Most webhook signatures are calculated over the exact bytes of the HTTP request body, sometimes combined with a timestamp or other signing metadata. Do not verify against a reserialized JSON object.
Common mistakes include:
- Calling
JSON.parse()before signature verification. - Using middleware that consumes or mutates the request body.
- Verifying a pretty-printed or re-encoded JSON string.
- Ignoring character encoding differences.
- Reading the body twice and accidentally verifying an empty buffer.
For frameworks like Express, Fastify, Next.js, Django, Rails, or FastAPI, configure the webhook route so it can access the raw body. Keep that route separate from ordinary JSON API routes if needed.
Step 2: Read the signature metadata
Inbound email webhook providers commonly include some combination of:
| Metadata | Why it matters |
|---|---|
| Signature header | Lets you verify the payload was signed by the provider |
| Timestamp header | Lets you reject stale or replayed requests outside a time window |
| Delivery or event ID | Lets you deduplicate webhook retries and block replay attempts |
| Signature version | Lets providers rotate algorithms without breaking old events |
| Key ID | Lets you support secret rotation or multiple signing keys |
Do not guess the header names, signing string, or algorithm. Use your provider’s official contract. For Mailhook integrations, the canonical machine-readable reference is the Mailhook llms.txt file, which should be treated as the source of truth for exact API and payload details.
Step 3: Enforce timestamp freshness
A valid signature proves that a payload was signed. It does not prove that the request is fresh. An attacker who obtains a valid signed request from logs, browser history, a proxy, or a misconfigured queue could try to send it again.
Timestamp tolerance reduces that risk. A common production pattern is to accept requests only if the signed timestamp is within a short window, often a few minutes. The exact tolerance depends on your provider’s retry behavior, your clock synchronization, and your operational requirements.
If the timestamp is missing, malformed, too far in the past, or too far in the future, reject the request before parsing the body.
Step 4: Verify the signature over the exact signed content
Many webhook systems use HMAC, defined in RFC 2104, because it is simple, efficient, and well understood. The provider signs a deterministic string using a secret known only to the provider and your application. Your application recomputes the signature and compares it with the signature sent in the request.
The signing input might be just the raw body, or it might be a compound string such as timestamp.raw_body. Do not assume. Follow the provider’s documented format.
Here is provider-neutral pseudocode:
function verifyInboundEmailWebhook(request) {
const rawBody = request.rawBody;
const signature = request.headers[WEBHOOK_SIGNATURE_HEADER];
const timestamp = request.headers[WEBHOOK_TIMESTAMP_HEADER];
const deliveryId = request.headers[WEBHOOK_DELIVERY_ID_HEADER];
if (!rawBody || !signature || !timestamp || !deliveryId) {
throw new Error("missing webhook verification metadata");
}
if (!isWithinTolerance(timestamp, 5 * 60)) {
throw new Error("stale webhook timestamp");
}
const signedContent = buildSignedContent(timestamp, rawBody);
const expectedSignature = hmacSha256(WEBHOOK_SECRET, signedContent);
if (!constantTimeEqual(signature, expectedSignature)) {
throw new Error("invalid webhook signature");
}
if (replayStore.has(deliveryId)) {
throw new Error("duplicate or replayed delivery");
}
replayStore.put(deliveryId, { ttlSeconds: 24 * 60 * 60 });
const event = JSON.parse(rawBody);
validateInboundEmailEvent(event);
return event;
}
This is intentionally not tied to a specific provider’s header names. Replace WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, WEBHOOK_DELIVERY_ID_HEADER, and buildSignedContent() with the exact contract from your provider.
Step 5: Use constant-time comparison
Do not compare signatures with a normal string equality operator if your language provides a timing-safe comparison function. A normal comparison may return as soon as it finds the first different character. In some systems, that can leak information through timing differences.
Use a standard constant-time comparison utility, such as crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, or the equivalent in your runtime. Also normalize encodings before comparison, for example by decoding hex or base64 signatures into fixed-length byte arrays.
Step 6: Detect replays and handle retries idempotently
Webhook providers may retry delivery when your endpoint times out or returns a non-2xx status. That is good for reliability, but it means your handler must be idempotent.
Replay protection and idempotency are related, but not identical.
| Control | Purpose | Example key |
|---|---|---|
| Replay detection | Reject a previously accepted HTTP delivery | delivery_id |
| Message dedupe | Avoid storing the same email message twice |
message_id plus inbox_id
|
| Artifact dedupe | Avoid consuming the same OTP or magic link twice | artifact hash plus attempt ID |
| Workflow idempotency | Avoid running the same business action twice | signup attempt ID or agent task ID |
For inbound email, do not rely only on an email Message-ID. Retries of the same webhook delivery, duplicate inbound messages, and resend flows are different events. Track the delivery layer separately from the message layer.
A practical handler flow is to verify the signature, write the delivery ID to a replay store with a TTL, enqueue the event, and return quickly. Downstream workers can then perform slower parsing, extraction, storage, and agent handoff with their own idempotency checks.
Step 7: Validate the JSON payload after authenticity passes
A signed payload is authentic, but it is still data from the outside world. The original email can contain hostile HTML, misleading display names, malicious links, malformed headers, oversized attachments, or prompt injection text aimed at an LLM.
After signature verification, validate the event shape before processing. For inbound email JSON, useful checks include:
- The event type is one you expect.
- The
inbox_idexists and belongs to the workflow waiting for it. - The recipient address matches the inbox descriptor you created.
- Message identifiers and timestamps are present and well formed.
- Text, HTML, headers, and attachments respect your size limits.
- Optional artifacts, such as OTPs or links, match strict policy rules.
If you use Mailhook, inbound messages are delivered as structured JSON, which is much easier to validate than raw MIME or rendered HTML. The exact fields and payload conventions should still be read from Mailhook’s llms.txt integration reference.
Step 8: Route by inbox, not by model interpretation
For LLM agents, webhook verification is only the first gate. The model should not decide whether a webhook is authentic, which inbox it belongs to, or whether a link is safe. Those are application responsibilities.
A safer agent pipeline looks like this:
- Your backend verifies the webhook signature, timestamp, and replay status.
- Your backend validates the JSON schema and routes by
inbox_idor a known attempt ID. - Deterministic code extracts the minimal artifact, such as an OTP or one allowed URL.
- The agent receives only the minimized result needed for its task.
- Security-sensitive actions, such as opening a link or submitting a code, remain policy-checked in code.
This pattern reduces prompt injection risk. An email can still contain text like “ignore previous instructions,” but the agent never needs to see the raw email to complete a verification task.
A reference architecture for inbound email webhooks
For production systems, keep the webhook endpoint small. It should authenticate, dedupe, enqueue, and respond. It should not run long LLM calls, scrape HTML, execute links, or perform complex business logic inline.
A typical architecture is:
| Component | Responsibility |
|---|---|
| Webhook endpoint | Raw-body capture, signature verification, timestamp check, replay detection |
| Event queue | Buffers verified events and absorbs retry bursts |
| Email worker | Validates JSON, stores normalized records, extracts artifacts |
| Workflow store | Maps inbox_id to test run, signup attempt, agent task, or client operation |
| Agent-safe tool | Returns minimal structured results to the LLM |
| Polling reconciler | Fetches missed messages when webhook delivery is delayed or unavailable |
This design pairs well with a webhook-first, polling-fallback strategy. Webhooks provide low-latency delivery. Polling gives you a deterministic recovery path for CI jobs, local development, and operational reconciliation.
Mailhook supports this kind of architecture with programmable disposable inboxes, real-time webhook notifications, signed payloads, structured JSON emails, REST API access, and a polling API fallback.
Secret management and rotation
Webhook secrets should be treated like API credentials. Store them in a secret manager or secure environment configuration, not in source code, CI logs, or agent prompts.
For rotation, support a short overlap period where your verifier can accept signatures from the old and new secret. If your provider includes a key ID or signature version, use it to select the correct secret. If not, try the active secret first and the previous secret second, then log which one matched without logging the secret or full payload.
Good operational rules include:
- Redact signatures and raw email bodies from ordinary logs.
- Log stable identifiers, such as delivery ID, inbox ID, message ID, and verification result.
- Store hashes of raw payloads when you need forensic correlation.
- Keep separate webhook secrets for development, staging, and production.
- Rotate secrets when team access changes or when logs may have leaked request metadata.
What to log when verification fails
Webhook verification failures should be observable, but logs must not leak sensitive email content or signing material.
Log enough to debug the failure class:
| Failure | Useful log fields | Avoid logging |
|---|---|---|
| Missing signature | request ID, source IP, route, missing header name | raw body, secret |
| Invalid signature | request ID, signature version, body hash | expected signature, secret |
| Stale timestamp | request ID, timestamp, server time, skew | full payload |
| Replay detected | delivery ID, first-seen time, current request ID | email body |
| Schema invalid | event type, validation error path, inbox ID if present | full HTML, tokens, OTPs |
Monitor failure rates. A sudden increase in invalid signatures may indicate a configuration mismatch, a secret rotation issue, a framework raw-body bug, or probing traffic.
Common implementation bugs
Webhook verification bugs tend to be simple and painful. The most common ones are:
- Verifying the parsed JSON instead of the raw request body.
- Letting global body-parsing middleware consume the body before the webhook route.
- Using the wrong secret for the environment.
- Ignoring clock drift between servers.
- Accepting valid signatures with no replay cache.
- Returning 200 before verification completes.
- Passing raw email content directly into an LLM after verification.
The last point is easy to miss. Signature verification proves the payload came through your provider. It does not make the email text safe, truthful, or instruction-worthy.
Where Mailhook fits
Mailhook is built for developer and agent workflows that need disposable email inboxes via API and inbound messages as structured JSON. For webhook-driven flows, the relevant capabilities are:
- Disposable inbox creation through a RESTful API.
- Structured JSON email output for automation and LLM-safe pipelines.
- Real-time webhook notifications for inbound messages.
- Signed payloads so your endpoint can verify authenticity.
- Polling API support for fallback and reconciliation.
- Shared domains for fast setup and custom domain support for controlled environments.
- Batch email processing for higher-volume automation.
For exact signature verification details, payload fields, and integration conventions, use the Mailhook llms.txt contract as the authoritative reference.
Verification checklist
Before shipping an inbound email webhook endpoint, confirm that:
- The endpoint captures the exact raw body.
- Signature verification happens before JSON parsing.
- Timestamp tolerance is enforced.
- Signature comparison is constant-time.
- Delivery IDs are stored to detect replays.
- Processing is idempotent across retries.
- JSON schema validation runs before business logic.
- Email content is treated as untrusted input.
- LLM agents receive minimized artifacts, not raw HTML.
- Polling exists as a fallback for missed or delayed webhook events.
- Logs contain identifiers and failure reasons, not secrets or tokens.
Frequently Asked Questions
Should I verify webhook payloads before parsing JSON? Yes. Capture the raw body and verify the signature first. Parsing or reserializing JSON can change the bytes used for signing and can cause you to trust unverified data.
Is DKIM enough if the original email is signed? No. DKIM helps authenticate the original email sender domain. It does not authenticate the HTTP webhook request sent to your application.
What should I do with duplicate webhook deliveries? Treat duplicates as normal. Verify the signature, check the delivery ID against a replay or idempotency store, and return a successful response if the event was already accepted.
Can an LLM verify webhook signatures? No. Signature verification should happen in deterministic application code using cryptographic libraries. The LLM should only receive minimized, policy-checked results after verification and extraction.
Do I still need polling if I use webhooks? Polling is useful as a fallback and reconciliation mechanism. Webhooks are best for low-latency delivery, while polling helps recover from missed callbacks, local development issues, or transient endpoint failures.
Build safer inbound email automation
If your agents or test suites depend on email, do not expose a shared mailbox and hope the right message appears. Use disposable inboxes, verify signed webhook payloads, validate structured JSON, and hand agents only the artifact they need.
Mailhook provides programmable temp inboxes, structured JSON emails, real-time webhooks, signed payloads, polling fallback, and custom domain support for automation and LLM workflows. Start with the Mailhook llms.txt reference to wire your integration against the current contract.