Verify webhook signatures
Authenticate the exact request bytes before processing an event.
Mailactor signs each webhook using HMAC-SHA256 and the signing secret returned when the endpoint was created. This authenticates the webhook transport, not the identity or intent of the person who sent the email.
Webhook headers
| Header | Meaning |
|---|---|
x-mailactor-event | message.received |
x-mailactor-event-id | Stable source event ID. |
x-mailactor-delivery-id | Stable delivery ID for this endpoint. |
x-mailactor-timestamp | Unix timestamp in seconds. |
x-mailactor-signature | v1= followed by the lowercase hexadecimal HMAC digest. |
Compute the HMAC over these bytes in order:
timestamp + "." + eventId + "." + exactRawBodyUse the complete signing-secret string as the HMAC key. Do not decode its prefix or reserialize parsed JSON. Even harmless whitespace changes alter the signature.
Use the Node.js verifier
Download the verifier. It uses Node's built-in crypto module, checks timestamp freshness, performs a constant-time signature comparison, and validates event identity before returning the parsed event.
import { verifyMailactorWebhook } from './verify-webhook.mjs';
// request is a Web Request. Read bytes before any JSON middleware.
const rawBody = new Uint8Array(await request.arrayBuffer());
const verified = verifyMailactorWebhook({
rawBody,
headers: request.headers,
secret: process.env.MAILACTOR_WEBHOOK_SECRET,
});
if (!verified) {
return new Response('Invalid webhook', { status: 401 });
}
// Persist using a unique constraint on endpoint identity + verified.event.id.
// Store verified.deliveryId too. A duplicate should be acknowledged without
// executing the same work twice. Return 503 if durable storage is unavailable.
await persistEventOnce(verified);
return new Response(null, { status: 204 });persistEventOnce is your application-owned durable queue/database operation. The example is a handler fragment; connect it to your web framework, enforce a request-size limit before buffering the body, and implement persistence before using it in production. An in-memory Set will not survive restarts.
Reject stale requests and duplicates
Reject timestamps more than five minutes in the past or future relative to your verifier's clock. Keep the host clock synchronized.
The body and event ID are signed; the delivery-ID header is not independently covered by the HMAC. Record the delivery ID, and use the signed event ID with your endpoint identity as the durable deduplication key so changing a header cannot cause a repeated action.
A valid duplicate must not produce another reply or another business action. Persist before acknowledging, then process asynchronously. Use the same logical send idempotency key if your event worker retries an automatic reply.
Test your receiver
Download a signed fixture. It contains a fake secret, exact raw body, matching headers, and a fixed verification time. The headers for that body are:
content-type: application/json
x-mailactor-event: message.received
x-mailactor-event-id: evt_0123456789abcdef01234567
x-mailactor-delivery-id: whd_0123456789abcdef01234567
x-mailactor-timestamp: 1788868860
x-mailactor-signature: v1=9e34323b31594245ed9541aca26f1e48e79fbff68299b54d1560548cf34dc8efRun this beside the downloaded fixture and verifier. Only this fixture test overrides the clock; production uses the current time and the endpoint's real secret.
import { readFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
import { verifyMailactorWebhook } from './verify-webhook.mjs';
const fixture = JSON.parse(await readFile('./webhook-fixture.json', 'utf8'));
const verified = verifyMailactorWebhook({
rawBody: new TextEncoder().encode(fixture.rawBody),
headers: new Headers(fixture.headers),
secret: fixture.secret,
now: fixture.now,
});
assert.equal(verified?.event.id, fixture.headers['x-mailactor-event-id']);Verify four separate cases: one valid event is accepted, a modified body is rejected, a stale timestamp is rejected, and a valid replay is acknowledged without repeating work. Keep polling as a reconciliation path after receiver outages.