Inbound email webhook signatures protect the HTTP boundary between your email provider and your AI agent. They let your receiver detect forged or altered deliveries. But a signature alone won’t stop someone from replaying an authentic request—or prevent a provider retry from triggering the same action twice.
The stakes change when an email can tell an agent to update a customer record, disclose a document, or issue a refund. A valid webhook should create one durable processing task. It should not grant unlimited permission to repeat the email’s contents.
The boundary covered here includes raw-body verification, freshness checks, durable replay protection, and safe handoff to an agent worker. Mailbox routing and general inbound parsing are outside that scope.
Author: Mails.ai Team
What does a webhook signature actually prove?
A valid webhook signature proves that the signed bytes match a request authenticated with the provider’s signing key, assuming the key stays secret and the verification scheme is sound. Nothing more. It does not prove the email sender’s identity, authorize an agent action, or establish that this is the first delivery. Each needs its own check, with its own failure modes.
Many providers use an HMAC: a keyed message authentication code. Others use asymmetric signatures. RFC 2104 specifies HMAC, but each provider defines its own signed input, encoding, headers, and key handling.
For example, a provider might authenticate:
HMAC(secret, timestamp + separator + raw_request_body)
This illustrates one construction, not a universal webhook format. Some schemes also sign a delivery identifier; others authenticate only the body. Don’t guess the format from another provider’s documentation.
Keep these trust boundaries separate:
| Check | What it establishes | What it does not establish |
|---|---|---|
| Webhook signature | Provider-authenticated request bytes | First delivery or safe email contents |
| Signed timestamp check | Delivery falls within an accepted time window | No duplicate inside that window |
| Durable event deduplication | Previously accepted event is not enqueued again | Business action is authorized |
| SPF, DKIM, DMARC | Email authentication results and domain alignment | Human identity or permission to act |
| Application authorization | Requested operation is allowed | Webhook transport authenticity |
Your inbound email parsing pipeline belongs downstream of webhook authentication. Email headers such as From and Message-ID cannot replace that authentication.
How should you verify the original request body?
Capture the raw HTTP request body and pass it unchanged to the provider’s supported verifier. Do this before parsing JSON, routing by schema, or logging message contents. Parsing and serializing JSON can change whitespace, escaping, or key order. A reconstructed object is no reliable substitute for the bytes the provider signed.
In Express, register a raw-body route before any global JSON middleware that would consume the body. Set a body-size limit, too. Unauthenticated requests should not get unlimited access to memory or CPU.
For Mails.ai, verification uses mails.webhooks.verify(rawBody, signatureHeader, secret) and the X-Mails-Signature header. It returns the verified event or null.
import express from "express";
import { mails, type TypedEvent } from "@mailsai/sdk";
type AcceptEvent = (event: TypedEvent) => Promise<void>;
export function buildApp(
webhookSecret: string,
acceptEvent: AcceptEvent
) {
const app = express();
// Register before any app.use(express.json()).
app.post(
"/webhooks/email",
express.raw({ type: "application/json", limit: "1mb" }),
async (req, res) => {
const signature = req.get("X-Mails-Signature");
if (!signature || !Buffer.isBuffer(req.body)) {
res.sendStatus(400);
return;
}
let event: TypedEvent | null;
try {
event = mails.webhooks.verify(
req.body.toString("utf8"),
signature,
webhookSecret
);
} catch {
res.sendStatus(401);
return;
}
if (!event) {
res.sendStatus(401);
return;
}
try {
// Resolves only after durable acceptance or confirmed duplication.
await acceptEvent(event);
res.sendStatus(204);
} catch {
// Let the provider retry when durable acceptance failed.
res.sendStatus(503);
}
}
);
return app;
}
Here, /webhooks/email is your application’s route, not a provider API endpoint. Your secret manager supplies the secret. The 1mb cap is an example deployment choice; choose yours from documented payload sizes and test how the route handles oversized requests.
Do not trim the body, normalize newlines, or swap the verifier for a plain hash. If you must implement a documented HMAC scheme yourself, decode signatures strictly and reject malformed lengths. Compare equal-length byte arrays using a constant-time comparison.
Why can a valid signature still permit a replay?
Resending a signed request does not invalidate its signature. To limit replay, verify a signed timestamp when the provider supports one, then deduplicate authenticated event IDs in durable storage. Timestamp validation limits how long a captured request stays usable. Deduplication prevents repeated acceptance within that window and across legitimate retries.
The attacker doesn’t need the signing secret. Access to a request dump, debugging proxy, or compromised logging system may be enough to replay a captured request.
Suppose a provider signs its timestamp and your application accepts requests within five minutes of local time. You could express the timestamp check as:
Reject if signed_time < current_time - allowed_age
Reject if signed_time > current_time + allowed_clock_skew
Five minutes is an example policy, not a product guarantee. Choose a window that accounts for the provider’s documented signing and retry behavior, network delays, and your clock accuracy.
Two details matter here:
- The timestamp must be authenticated. An attacker can replace an unsigned timestamp header.
- Both directions need bounds. A far-future timestamp should not stay acceptable indefinitely.
Don’t assume the SDK verification call above enforces a particular timestamp window. Check the documented signing contract. Without an authenticated delivery timestamp, durable deduplication still prevents previously recorded events from running again. It cannot prove that a request you’ve never seen is recent.
A local received_at time tells you when your server accepted the request—not when the provider created it.
How do you deduplicate events without creating a race?
Use a database uniqueness constraint and commit event acceptance together with durable work creation. A lookup followed by a separate insert leaves a race: concurrent deliveries can both pass the lookup. Let the database decide which request wins. The losing request should acknowledge the accepted event without creating another job.
Use the provider’s authenticated event ID, scoped to a stable provider account or webhook source. Don’t use the email’s RFC Message-ID, sender address, subject, or body hash as the primary replay key.
An email Message-ID identifies a message, not a webhook event. Different events can concern the same message. Email senders can also supply message identifiers themselves.
This PostgreSQL schema belongs to your application; it is not a provider payload definition:
CREATE TABLE webhook_receipts (
source_scope text NOT NULL,
event_id text NOT NULL,
accepted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (source_scope, event_id)
);
CREATE TABLE email_jobs (
source_scope text NOT NULL,
event_id text NOT NULL,
payload jsonb NOT NULL,
state text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (source_scope, event_id),
FOREIGN KEY (source_scope, event_id)
REFERENCES webhook_receipts (source_scope, event_id)
);
After verification and required event-schema checks, run this parameterized statement inside a transaction:
WITH accepted AS (
INSERT INTO webhook_receipts (source_scope, event_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
RETURNING source_scope, event_id
)
INSERT INTO email_jobs (source_scope, event_id, payload)
SELECT source_scope, event_id, $3::jsonb
FROM accepted;
$1 comes from trusted endpoint configuration. $2 is the verified event’s id, and $3 is its serialized payload. Commit before returning success. A duplicate inserts neither a receipt nor a job.
Keep the signing-key version out of the uniqueness scope. Including it would allow the same event to be accepted again during key rotation.
The receiver’s acceptance boundary then looks like this:
flowchart LR A[HTTP request] --> B[Verify raw body] B --> C[Validate event] C --> D[Atomic receipt and job] D --> E[Acknowledge delivery] D --> F[Worker claims job] F --> G[Authorize action] G --> H[Run agent task]
If workers use an external queue, publish through a transactional outbox or use the database jobs table as the queue. Don’t write a receipt and publish separately. A crash between those steps leaves an event marked accepted but never processed.
When should the webhook return a success response?
Return success after you durably commit the verified event and its work item. Don’t wait for the agent to finish reasoning. Return success for an already accepted duplicate, too. If durable storage fails, return a retryable error instead of acknowledging work you might lose.
Keep model calls and business operations outside the webhook request.
Consider three crash points:
- Before the transaction commits: no success response is sent; a retry can create the job.
- After commit but before the response: the retry finds the receipt and creates no second job.
- During worker execution: the job remains recoverable under your worker’s lease and retry policy.
That third case needs more than webhook deduplication. A worker might issue a refund, then crash before recording completion. Another attempt could issue the refund again. Use a stable business-operation key with the downstream service’s idempotency support, or enforce an equivalent transactional business constraint.
This is not end-to-end "exactly once" processing. It is at-least-once delivery with deduplicated intake and separately protected effects.
Set an explicit policy for unsupported but authenticated event types. For example, durably record and ignore them. Don’t repeatedly fail a valid event just because your application has no handler for it.
How long should replay records be retained?
Keep replay records for at least the provider’s documented retry and redelivery period, plus an operational safety margin. Without authenticated timestamps, deleting a receipt makes an old captured request eligible for acceptance again. Compact event-ID tombstones may therefore need much longer retention than the email payload itself.
Give these three kinds of data separate retention policies:
- Replay receipts: source scope, event ID, and acceptance time.
- Work payloads: email contents needed by the worker.
- Audit records: action decisions and processing outcomes.
That separation lets you delete sensitive email bodies without immediately losing duplicate protection. A deployment might, for example, retain work payloads for seven days while keeping receipts longer. These are illustrative settings. Choose actual periods based on retry guarantees, incident needs, and privacy requirements.
Redis cache entries do not count as durable replay protection unless their persistence and failure behavior meet your requirements. A restart, eviction, or early TTL expiry can reopen acceptance. Check disaster recovery, too: restoring an old backup can remove receipts for events you already processed.
Never log signing secrets, full signature headers, or raw email bodies by default. Log the event ID, validation outcome, duplicate status, and processing result instead.
What checks still belong between a verified event and an agent?
A verified webhook contains provider-authenticated data, not trusted instructions. Before invoking an agent, enforce tenant ownership, validate the event schema, apply your content-safety policy, and authorize any requested action. Signature verification protects the delivery channel. It does not make an email safe to execute or give its sender access to your tools.
For example, the SDK event includes agent_id, source_message_id, thread_id, and nested email content such as data.from.address and data.extracted_text. Resolve agent_id against server-owned configuration. Never let email text select another tenant’s credentials or override tool permissions.
The architecture should keep transport acceptance separate from agent execution. Correctly verified events still have to pass application controls.
Every inbound event also receives an injection scan. An injection_score of at least 0.95 sets quarantined: true, but the event still reaches your handler. Your worker can skip quarantined events or route them for review. The label is not a delivery gate.
Likewise, email classification is not authorization. Where enabled, it applies to first-contact message.received events. Existing-thread reply.received events have intent general_reply. Neither an intent nor a thread match should authorize a sensitive operation.
What should you test before deploying the receiver?
Test authenticity, concurrent delivery, storage failure, and recovery independently. A valid fixture only proves the happy path works. Your tests should show that modified requests fail verification, duplicates create one durable job, and crashes never turn acknowledged events into lost work. Test worker effects separately.
Generate fixtures with the provider’s documented signing scheme or official test tooling. Don’t invent a signer that merely agrees with your own verifier.
| Test | Expected result |
|---|---|
| Change one byte in the body | Verification fails |
| Reformat signed JSON | Verification fails unless the documented scheme canonicalizes it |
| Omit or corrupt the signature | Request rejected before work creation |
| Submit one event 20 times concurrently | Exactly one durable job |
| Fail the database commit | No success acknowledgment |
| Lose the response after commit | Retry creates no additional job |
| Replay a stale signed timestamp | Rejected when supported freshness policy applies |
| Restart workers during processing | Work resumes without an unprotected repeated effect |
| Rotate the signing secret | Documented overlap works without bypassing deduplication |
During rotation, follow only the provider’s documented verification procedure. Bound any previous-key acceptance period, and stop accepting a compromised key according to your incident policy.
Monitor invalid-signature volume, duplicate frequency, commit failures, queue age, and worker failures. More duplicates don’t necessarily mean an attack. Your own slow or failing endpoint may be causing retries.
Frequently Asked Questions
Authenticity, freshness, duplicate suppression, and action authorization solve different problems. None replaces another. The distinctions matter most when a verified email can trigger expensive model calls or irreversible tool operations. Keep the acceptance path small, and be explicit about what it guarantees.
Does HTTPS prevent webhook replay attacks?
HTTPS protects requests in transit and authenticates your server to the sender. It does not stop someone from replaying a request captured elsewhere, such as in an exposed request log. Use signature verification, durable deduplication, and authenticated timestamp validation where supported.
Can I use the email Message-ID for deduplication?
Not as the primary webhook replay key. Use the provider’s verified event ID, scoped to its account or source. Email Message-ID values identify messages, not deliveries or event types. Email senders can supply those values themselves.
Should duplicate webhook requests receive an error?
Usually, no. Once durable storage confirms that you accepted an event, return success without creating another job. An error can trigger more retries. Keep a confirmed duplicate distinct from a database failure where you don’t know whether acceptance succeeded.
Can I rely on a timestamp without storing event IDs?
No. An attacker can replay a valid request repeatedly within the allowed timestamp window. Signed timestamps bound age; event IDs suppress repeated acceptance. An unauthenticated timestamp cannot establish freshness at all.
Does a valid signature mean the agent can trust the email?
No. It authenticates the webhook delivery, not the sender’s instructions. Treat email content as untrusted, enforce tenant boundaries, and require application authorization before sensitive actions. Prompt-injection labels and classification can inform handling, but neither grants permission.
Related guides: Securing an Agent Email Inbox Against Spoofing, Transactional Email APIs With Inbound Parsing: Dev Guide, Inbound Email Webhooks for AI Agents: Parse & Act and Transactional Email APIs With Inbound Parsing (2026).
Explore the product: Mails.ai vs Resend, Inbound email parsing, Sender reputation, All features and Pricing.
