Inbound email webhooks
Receive an HTTPS event whenever an inbound email webhook route accepts a message.
Overview
Create an Inbound email webhook route in the console. The platform sends an authenticated JSON POST to the route URL. A DMARC report route is a different, platform-managed inbound route and does not call your webhook.
Store the signing secret when it is shown. Your endpoint must verify every request before parsing or acting on the message.
Payload
The request body is UTF-8 JSON. Optional email header fields are omitted when unavailable. Treat unknown fields as additive and ignore them.
{
"message_id": "01JEXAMPLEMESSAGE",
"event_type": "inbound.message.received",
"route_id": "01JEXAMPLEROUTE",
"route_address": "support@example.com",
"envelope_sender": "sender@example.net",
"envelope_recipient": "support@example.com",
"rfc_message_id": "<message@example.net>",
"subject": "Hello",
"from": "Sender <sender@example.net>",
"raw_size_bytes": 25,
"raw_sha256": "877918b87a7881b96b2cc0825bc5fda6b4beea4efbc9e1f27775c3dfab733f5c",
"raw": {
"mode": "inline",
"content_type": "message/rfc822",
"base64": "U3ViamVjdDogSGVsbG8NCg0KSGVsbG8NCg=="
}
}JSON Schema is the machine-readable contract for version 1.
Request headers
Content-Type: application/jsonEmaia-Webhook-Id: stable message identifier across delivery retries.Emaia-Webhook-Timestamp: Unix timestamp in seconds used by the signature.Emaia-Webhook-Signature:v1=followed by a lowercase hexadecimal HMAC-SHA256 digest.Emaia-Webhook-Version: 1: payload contract version.
Verify signatures
Compute HMAC-SHA256 with the route signing secret over timestamp + "." + raw_request_body. Use the exact bytes received, reject timestamps more than five minutes in the past or future, and compare signatures in constant time. Do not re-serialize JSON before verification.
Use the published signature test vector to validate your implementation before accepting live events.
Node.js / TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
const timestamp = req.header("Emaia-Webhook-Timestamp");
const supplied = req.header("Emaia-Webhook-Signature") ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("stale");
const expected = "v1=" + createHmac("sha256", secret)
.update(timestamp + ".")
.update(req.body) // Buffer containing the unchanged request bytes
.digest("hex");
if (expected.length !== supplied.length ||
!timingSafeEqual(Buffer.from(expected), Buffer.from(supplied))) {
throw new Error("invalid signature");
}Python
import hashlib, hmac, time
body = request.get_data(cache=True)
timestamp = request.headers["Emaia-Webhook-Timestamp"]
if abs(time.time() - int(timestamp)) > 300:
abort(401)
expected = "v1=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, request.headers["Emaia-Webhook-Signature"]):
abort(401)PHP
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_EMAIA_WEBHOOK_TIMESTAMP'] ?? '';
$supplied = $_SERVER['HTTP_EMAIA_WEBHOOK_SIGNATURE'] ?? '';
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(401); exit;
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($expected, $supplied)) {
http_response_code(401); exit;
}Go
body, err := io.ReadAll(r.Body)
if err != nil { http.Error(w, "bad request", 400); return }
timestamp := r.Header.Get("Emaia-Webhook-Timestamp")
unix, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || time.Since(time.Unix(unix, 0)).Abs() > 5*time.Minute {
http.Error(w, "stale request", 401); return
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(timestamp + "."))
mac.Write(body)
expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(r.Header.Get("Emaia-Webhook-Signature"))) {
http.Error(w, "invalid signature", 401); return
}Raw message modes
inline includes the complete RFC 822 message in raw.base64. Base64-decode it and verify its SHA-256 digest against raw_sha256.
The current inline limit is 1 MiB. Messages larger than that use fetch. The raw object contains mode, content_type, and url. Send Authorization: Bearer <API token> to that URL. The API token must belong to the route organization and must be kept server-side.
{
"message_id": "01JEXAMPLEMESSAGE",
"event_type": "inbound.message.received",
"route_id": "01JEXAMPLEROUTE",
"route_address": "support@example.com",
"envelope_sender": "sender@example.net",
"envelope_recipient": "support@example.com",
"subject": "Large attachment",
"from": "Sender <sender@example.net>",
"raw_size_bytes": 2400000,
"raw_sha256": "2a8f1b0a35d00c9d7765a6d6ce7ab78e1fbd950a19a0e12905f2662353f65c9d",
"raw": {
"mode": "fetch",
"content_type": "message/rfc822",
"url": "${API_BASE_URL}/api/v1/inbound-messages/01JEXAMPLEMESSAGE/raw"
}
}curl --fail --location \
--header "Authorization: Bearer $EMAIA_API_TOKEN" \
--header "Accept: message/rfc822" \
"$RAW_MESSAGE_URL" --output message.emlA successful fetch returns Content-Type: message/rfc822. A 404 means the message is not available in the authenticated organization (or never existed). A 410 means the message record exists but its raw content is no longer available.
Raw content is retained for the route retention period configured in the console, then removed. Return a successful webhook response based on durable receipt of the event; accepting the webhook does not guarantee that a later raw-message fetch will still succeed.
Responses
Return any 2xx response after durable processing or durable queueing. Return 406 Not Acceptable or 410 Gone only when delivery should stop permanently. Network errors and all other non-2xx responses are retryable until the attempt limit. Response bodies are diagnostic only and are not part of the success contract.
Retries and delivery semantics
Delivery is at least once. Your endpoint can receive the same message more than once, including after a timeout where your response was not observed. Deduplicate on Emaia-Webhook-Id or message_id; both remain stable across retries for a received message.
Retries are bounded and delayed between attempts. Timing and attempt limits may change, so do not depend on a particular retry schedule. Make processing idempotent and return success only after durable processing or queueing.
Secret lifecycle
The signing secret is displayed only after route creation or regeneration. Store it immediately in a secret manager; it cannot be retrieved later.
Regeneration changes the secret used by newly claimed deliveries immediately. A delivery already claimed or in flight may still be signed with the previous secret. Keep accepting the previous secret briefly until pre-rotation deliveries drain, then remove it and send a test event with the new secret.
Versioning
Version 1 is identified by Emaia-Webhook-Version: 1 and the v1= signature prefix. New optional fields may be added without a version change. A breaking payload or signing change will use a new contract version and migration guidance.
Test events
Use Send test webhook on the Inbound routes page. The test follows the same URL validation, signing, timeout, and response handling path as production and uses event type inbound.message.test. Its raw message is synthetic and always inline. Test deliveries do not represent received customer email.



