Mailactor
Webhooks

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

HeaderMeaning
x-mailactor-eventmessage.received
x-mailactor-event-idStable source event ID.
x-mailactor-delivery-idStable delivery ID for this endpoint.
x-mailactor-timestampUnix timestamp in seconds.
x-mailactor-signaturev1= followed by the lowercase hexadecimal HMAC digest.

Compute the HMAC over these bytes in order:

timestamp + "." + eventId + "." + exactRawBody

Use 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=9e34323b31594245ed9541aca26f1e48e79fbff68299b54d1560548cf34dc8ef

Run 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.

On this page