Getting Started
Subscribe to signed Email Testing events, verify deliveries, and replay failed delivery records.
Email Testing webhooks notify your application about captured messages, analysis, previews, and actions. A webhook endpoint has a public URL, a set of event subscriptions, an active status, and a 64-character lowercase-hex secret. The secret is returned when the endpoint is created or rotated and is also included by the list endpoint; store it securely.
Set Up a Webhook
In the dashboard, open Email Testing → Webhooks, or use Create Webhook. Choose the events to subscribe to, then copy the signing secret and store it securely for signature verification.
Envelope
Every event is a JSON envelope:
{
"id": "evt_550e8400e29b41d4a716446655440000",
"type": "message.received",
"account_id": "12345",
"created_at": "2025-01-01T12:00:00Z",
"data": {
"message_id": "d5949378-3069-4d8f-a8f3-6e5edf5d6060",
"mailbox_id": "35a3cb89-ea59-4ea3-8edc-a5c5f7bf0bda"
}
}The data object never contains raw MIME, HTML bodies, attachment bytes, or API keys. Event data contains identifiers, statuses, action outcomes, and links as described below.
Events
| Event | Sent when |
|---|---|
message.received | A message is captured by the SMTP sink, MX sink, or an .eml upload. |
message.expired | Retention is about to delete a captured message. |
mailbox.expired | An inbox reaches its expiry and is deleted. |
analysis.completed | Analysis finishes, including after it is reprocessed. |
analysis.failed | Analysis cannot finish. |
preview.ready | Preview rendering finishes and at least one preview is ready. |
preview.failed | Preview rendering still fails after its retries. |
action.completed | A simulated action or a Release succeeds. |
action.failed | A simulated action fails. |
message.received
Fires once when a message is captured by the SMTP sink, the MX sink, or an .eml upload.
| Data field | Type | Description |
|---|---|---|
message_id | string | Captured message ID. |
sink | string | Ingestion source: smtp, mx, or upload. |
mailbox_id | string | Inbox ID that received the message. |
credential_id | string | SMTP sink credential ID for smtp; an empty string for mx or upload. |
recipients | string[] | Envelope recipients. This is always an array. |
from | string | Sender address. |
subject | string | Message subject. |
size_bytes | integer | Size of the full raw message. |
{
"id": "evt_550e8400e29b41d4a716446655440000",
"type": "message.received",
"account_id": "12345",
"created_at": "2025-01-01T12:00:00Z",
"data": {
"message_id": "d5949378-3069-4d8f-a8f3-6e5edf5d6060",
"sink": "smtp",
"mailbox_id": "35a3cb89-ea59-4ea3-8edc-a5c5f7bf0bda",
"credential_id": "0b6f3c2e-7a41-4c1d-9e58-2f6d8a90c3b1",
"recipients": ["[email protected]"],
"from": "[email protected]",
"subject": "Verify account",
"size_bytes": 148213
}
}message.expired
Fires just before retention deletes a captured message.
| Data field | Type | Description |
|---|---|---|
message_id | string | ID of the message being deleted. |
{
"id": "evt_650e8400e29b41d4a716446655440001",
"type": "message.expired",
"account_id": "12345",
"created_at": "2025-01-02T12:00:00Z",
"data": {
"message_id": "d5949378-3069-4d8f-a8f3-6e5edf5d6060"
}
}mailbox.expired
Fires when an inbox reaches its expiry, such as an ephemeral inbox, and is deleted.
| Data field | Type | Description |
|---|---|---|
mailbox_id | string | ID of the inbox being deleted. |
address | string | Expired inbox address. |
{
"id": "evt_750e8400e29b41d4a716446655440002",
"type": "mailbox.expired",
"account_id": "12345",
"created_at": "2025-01-03T12:00:00Z",
"data": {
"mailbox_id": "35a3cb89-ea59-4ea3-8edc-a5c5f7bf0bda",
"address": "[email protected]"
}
}analysis.completed
Fires when analysis finishes, including after Reprocess Analysis. Use Get Analysis to retrieve the results.
| Data field | Type | Description |
|---|---|---|
message_id | string | Analyzed message ID. |
analysis_id | string | Analysis ID, formatted as an_ followed by a UUID. |
status | string | complete, or partial when some checks could not run. |
{
"id": "evt_850e8400e29b41d4a716446655440003",
"type": "analysis.completed",
"account_id": "12345",
"created_at": "2025-01-04T12:00:00Z",
"data": {
"message_id": "d5949378-3069-4d8f-a8f3-6e5edf5d6060",
"analysis_id": "an_7c0e2f4a-91b3-4d55-8a3e-5b2c1d9e0f17",
"status": "complete"
}
}analysis.failed
Fires when analysis cannot finish after its retries.
| Data field | Type | Description |
|---|---|---|
message_id | string | Message whose analysis failed. |
error | string | analysis failed after retries or message cannot be loaded. |
{
"id": "evt_950e8400e29b41d4a716446655440004",
"type": "analysis.failed",
"account_id": "12345",
"created_at": "2025-01-05T12:00:00Z",
"data": {
"message_id": "d5949378-3069-4d8f-a8f3-6e5edf5d6060",
"error": "analysis failed after retries"
}
}preview.ready
Fires when preview rendering finishes and at least one preview rendered. Use List Previews to retrieve them.
| Data field | Type | Description |
|---|---|---|
message_id | string | Message whose previews were rendered. |
ready | integer | Number of previews rendered. |
failed | integer | Number of previews that failed to render. |
{
"id": "evt_a50e8400e29b41d4a716446655440005",
"type": "preview.ready",
"account_id": "12345",
"created_at": "2025-01-06T12:00:00Z",
"data": {
"message_id": "d5949378-3069-4d8f-a8f3-6e5edf5d6060",
"ready": 2,
"failed": 0
}
}preview.failed
Fires when preview rendering still fails after its retries.
| Data field | Type | Description |
|---|---|---|
message_id | string | Message whose previews could not be rendered. |
error | string | Always preview rendering failed after retries. |
{
"id": "evt_b50e8400e29b41d4a716446655440006",
"type": "preview.failed",
"account_id": "12345",
"created_at": "2025-01-07T12:00:00Z",
"data": {
"message_id": "d5949378-3069-4d8f-a8f3-6e5edf5d6060",
"error": "preview rendering failed after retries"
}
}action.completed
Fires when a simulated action or a Release succeeds.
| Data field | Type | Description |
|---|---|---|
action_id | string | Action ID. |
action_type | string | open, click, unsubscribe, reply, arf, ooo, or release. |
message_id | string | Message the action was performed on. |
links | object[] | Click results, included only for click when results are present. Results follow the links' document order. |
The links array contains:
| Link field | Type | Description |
|---|---|---|
id | string | Link ID, such as lnk_3f9a1c2b7d4e. |
index | integer | Link's position in the message document. |
url | string | Original link URL. |
outcome | string | fetched when a response arrived, regardless of status code; otherwise blocked or failed. |
status_code | integer | HTTP status code; not included when there was no response. |
final_url | string | URL after redirects; not included when empty. |
error | string | Fetch error; not included when empty. |
duration_ms | integer | Time spent processing the link. |
Click action:
{
"id": "evt_c50e8400e29b41d4a716446655440007",
"type": "action.completed",
"account_id": "12345",
"created_at": "2025-01-08T12:00:00Z",
"data": {
"action_id": "14b0f9d2-9df7-4e74-9b2d-dc27e5b2c123",
"action_type": "click",
"message_id": "d5949378-3069-4d8f-a8f3-6e5edf5d6060",
"links": [
{
"id": "lnk_3f9a1c2b7d4e",
"index": 0,
"url": "https://maileroo.com/verify",
"outcome": "fetched",
"status_code": 200,
"final_url": "https://maileroo.com/verify?source=email",
"duration_ms": 184
},
{
"id": "lnk_a16b8c2d4e70",
"index": 1,
"url": "https://links.maileroo.com/confirm",
"outcome": "failed",
"error": "not fetched: dial tcp: connection refused",
"duration_ms": 12
}
]
}
}Release action:
{
"id": "evt_d50e8400e29b41d4a716446655440008",
"type": "action.completed",
"account_id": "12345",
"created_at": "2025-01-08T12:05:00Z",
"data": {
"action_id": "63a7b9c1-41d2-4f8a-9c36-05e7b1a2d489",
"action_type": "release",
"message_id": "d5949378-3069-4d8f-a8f3-6e5edf5d6060"
}
}action.failed
Fires when a simulated action fails. Release only sends action.completed.
| Data field | Type | Description |
|---|---|---|
action_id | string | Action ID. |
action_type | string | open, click, unsubscribe, reply, arf, or ooo. |
message_id | string | Message the action was attempted on. |
links | object[] | Click results, included only for click when results are present. |
error | string | Reason the action failed. |
{
"id": "evt_e50e8400e29b41d4a716446655440009",
"type": "action.failed",
"account_id": "12345",
"created_at": "2025-01-09T12:00:00Z",
"data": {
"action_id": "72c9d863-8b4a-4f15-9a32-6d1e0f7b4c21",
"action_type": "unsubscribe",
"message_id": "d5949378-3069-4d8f-a8f3-6e5edf5d6060",
"error": "The List-Unsubscribe link must use HTTPS."
}
}Delivery is at least once. Deduplicate by the envelope id. message.received, message.expired, mailbox.expired, analysis.failed, and preview.failed use deterministic event IDs; the delivery ID is also stable per endpoint for these events. analysis.completed, preview.ready, and action.* receive a fresh ID for each emission. Delivery records are retained for 24 hours. List Deliveries returns the newest 100 records and is not cursor-paged. Replay Delivery queues a delivery again.
Failed deliveries have up to five total attempts. Retries are scheduled after approximately 1 minute, 5 minutes, 15 minutes, and 1 hour, with up to 10% positive jitter. analysis.failed and preview.failed are emitted after the final processing retry; intermediate retries do not emit failure events.
Request headers and signatures
Each delivery is an HTTP POST with:
Content-Type: application/json
Maileroo-Signature: t=1700000000,v1=...
User-Agent: maileroo-email-testing/1.0No event ID or delivery ID is sent in a separate header; read those values from the JSON envelope and delivery history. The signature is:
Maileroo-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">The signing key is the secret string as-is, encoded as UTF-8 bytes. The 64 lowercase hexadecimal characters are not decoded before use. Verify the exact raw request body, compare the HMAC in constant time, and reject timestamps outside your replay window before processing the event.
Node.js
import crypto from "node:crypto";
export function verifyMaileroo(signature, secret, rawBody, now = Date.now()) {
const parts = Object.fromEntries(signature.split(",").map((part) => part.split("=")));
if (!parts.t || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
const age = Math.abs(Math.floor(now / 1000) - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return false;
const expected = crypto
.createHmac("sha256", Buffer.from(secret, "utf8"))
.update(Buffer.concat([Buffer.from(`${parts.t}.`, "utf8"), rawBody]))
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Pass the untouched request body as a Buffer to preserve its exact bytes.
PHP
function verifyMaileroo(string $signature, string $secret, string $rawBody): bool {
$parts = [];
foreach (explode(',', $signature) as $item) {
[$key, $value] = explode('=', $item, 2);
$parts[$key] = $value;
}
if (!isset($parts['t'], $parts['v1']) || !preg_match('/^[0-9a-f]{64}$/', $parts['v1'])) return false;
if (abs(time() - (int) $parts['t']) > 300) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}Python
import hashlib
import hmac
import time
def verify_maileroo(signature, secret, raw_body):
fields = dict(part.split("=", 1) for part in signature.split(","))
if len(fields.get("v1", "")) != 64:
return False
if abs(int(time.time()) - int(fields["t"])) > 300:
return False
expected = hmac.new(
secret.encode("utf-8"),
fields["t"].encode("ascii") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, fields["v1"])Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func verifyMaileroo(header, secret string, rawBody []byte, now time.Time) bool {
fields := map[string]string{}
for _, part := range strings.Split(header, ",") {
pair := strings.SplitN(part, "=", 2)
if len(pair) == 2 { fields[pair[0]] = pair[1] }
}
ts, err := strconv.ParseInt(fields["t"], 10, 64)
age := now.Sub(time.Unix(ts, 0))
if age < 0 { age = -age }
if err != nil || age > 5*time.Minute || len(fields["v1"]) != 64 { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fields["t"] + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(fields["v1"]))
}Keep the raw bytes unchanged when verifying. Rotate a secret with Rotate Secret; the response contains the new secret, and future deliveries use it.