Webhooks

The platform tells apps what happens to their mail by POSTing signed JSON to HTTPS endpoints: delivery events for messages an app sent, and every message received by a mailbox. Deliveries are stored in PostgreSQL, retried with backoff, dead-lettered after the 8th failure and can be replayed.

The platform tells apps what happens to their mail by POSTing signed JSON to HTTPS endpoints: delivery events for messages an app sent, and every message received by a mailbox. Deliveries are stored in PostgreSQL, retried with backoff, dead-lettered after the 8th failure and can be replayed.

Targets

Target Receives Signed with Format
App webhook (app.webhookUrl) The events the app subscribed to, for messages sent with the app's API keys; inbound for mailboxes the app owns The app's secret The app's webhookFormat
Mailbox inbound webhook (mailbox.inboundWebhookUrl) inbound for that mailbox The owning app's secret The owning app's webhookFormat

Messages sent from the web inbox or the Send a test email page do not belong to an app, so their events are not sent to any app webhook (they still appear in the message log). To test an app's webhook end to end from the web app, use Send a test as this app on the app's page (POST /api/v1/apps/{id}/test-message, REST API): that message belongs to the app, so its events reach the app's webhook. The platform's own emails (invitations, password resets, alerts) are not messages of any tenant at all: they never appear in the message log and produce no events or webhooks.

Configuring an app

In the UI: Integrations → (app) → Webhook. Through the API: POST /api/v1/apps or PATCH /api/v1/apps/{id} (REST API).

Field Values
webhookUrl HTTPS URL, no credentials in it (see URL rules). Not set: no app webhook; PATCH with "webhookUrl": null removes it.
webhookFormat NATIVE (default), POSTMARK (Postmark formats), SENDGRID (SendGrid format) or MAILGUN (Mailgun format).
webhookEvents Any of delivered, bounced, complained, opened, clicked, inbound, delivery_delayed, unsubscribed, conversation, accepted, dropped, canceled. Default for a new app: all but accepted, dropped and canceled; a SENDGRID or MAILGUN app gets accepted and dropped too, and an app asks for canceled. Apps created before an event existed keep their list: add it to receive it. An app in the POSTMARK format is never sent accepted or dropped, which Postmark has no record for.

The signing secret (whsec_ + 32 characters) is created with the app. Read it with GET /api/v1/apps/{id}/webhook-secret (UI: Reveal secret) and replace it with POST /api/v1/apps/{id}/webhook-secret/rotate. Rotation takes effect immediately, including for retries of older deliveries, so update the receiver first (or accept both secrets for a while). An app in the SENDGRID format also has a key pair for SendGrid's signed Event Webhook, created when it takes the format; its public key is webhookVerificationKey on the app, and rotating the secret renews it too (signature). An app in the MAILGUN format signs as Mailgun does, with the secret itself as the signing key (signature).

Changing the URL takes effect for every delivery still to be sent: each attempt (including retries and replays) goes to the app's current webhook URL, and a delivery whose URL was removed is dead-lettered unsent (The webhook URL was removed). Changing the format only affects new deliveries: each delivery keeps the format and, except for inbound, the payload it was created with.

Mailbox inbound webhooks

A mailbox can POST every received message to its own URL (Addresses → (address) → Inbound webhook URL, or inboundWebhookUrl on POST/PATCH /api/v1/mailboxes). Such a mailbox must have an owning app (appId): the app's secret signs the requests and the app's format shapes them. Saving a webhook URL without an app fails with 400 VALIDATION_FAILED on appId.

  • The mailbox webhook is independent of the app's webhookEvents: it always receives inbound.
  • If the owning app subscribes to inbound and has a webhook URL, it receives the message too. When the app delivery was queued and its URL is the same as the mailbox URL, only one delivery is made.
  • Messages whose virus verdict is FAIL are stored in the mailbox but never sent to any webhook.
  • Mailbox deliveries go to the mailbox's current inbound webhook URL, as app deliveries go to the app's.

Request

POST /hooks/mail HTTP/1.1
Content-Type: application/json
User-Agent: mailroomly-webhooks/1
X-Messaging-Event: delivered
X-Messaging-Delivery-Id: 01926a3f-5e6f-7a8b-9c0d-1e2f3a4b5c6d
X-Messaging-Signature: t=1727432133,v1=5f0c3e0b8d2a…
Header Meaning
X-Messaging-Event Event type.
X-Messaging-Delivery-Id The delivery row; stays the same across retries and replays.
X-Messaging-Signature Timestamp and HMAC signature, see Signature.

Any 2xx response is success. Everything else — other status codes, timeouts, connection errors and redirects (never followed) — is a failed attempt. An attempt must complete within 10 s in total (messaging.webhooks.timeout: connecting, the response headers and the response body); only the first 2000 bytes of the response body are read (the delivery log keeps the first 500 characters) and the connection is then closed. Answer quickly and process asynchronously.

Deliveries are sent concurrently — at most 16 at a time per instance and 4 per tenant (messaging.webhooks.concurrency, messaging.webhooks.per-tenant-concurrency), claimed round-robin across tenants — so a slow endpoint delays only its own deliveries. Rendering inbound payloads (above all the Postmark format, which carries every attachment in base64) is bounded per instance by a memory budget (messaging.webhooks.render-budget, 128 MB; a delivery reserves about 6× the raw message size for the SendGrid and Mailgun formats, 4× for the Postmark format and 2× for the native one, at most the whole budget, and one that needs all of it runs alone): a delivery that finds no room within 30 s goes back to the queue for 30 s without counting an attempt.

Native format

{
  "id": "01926a3f-0c1d-7e2f-8a3b-4c5d6e7f8a9b",
  "type": "delivered",
  "channel": "email",
  "occurredAt": "2026-09-27T10:15:33.120Z",
  "messageId": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "metadata": { "invoiceId": "123" },
  "tags": ["invoice"],
  "data": { "recipient": "joao@exemplo.pt", "smtpResponse": "250 2.0.0 OK" }
}
Field Meaning
id Event id (unique per event; the same for the app and mailbox deliveries of one inbound message). Use it to de-duplicate.
type Event type (below).
channel email.
occurredAt When it happened (provider timestamp), ISO-8601 UTC.
messageId Platform message id — the id returned by the send call (MessageID for Postmark sends).
metadata, tags As sent with the message ({} and [] for inbound).
data Event-specific fields; always includes recipient when there is one.

Events

type When data fields
delivered Delivered to the recipient's server (one per recipient) recipient, smtpResponse (the server's reply; only its status code when the reply quotes the sending service)
bounced Hard or soft bounce recipient, bounceType (Permanent, Transient, Undetermined), bounceSubtype (Suppressed, InvalidAddress or a general SMTP kind such as General, NoEmail, MailboxFull), diagnostic (the recipient server's reply, or Mailroomly's words when the sending service refused it), suppressed (true when this bounce suppressed the address: a hard bounce or a validation refusal, tenant-wide, or the soft bounce that reached the threshold, on the stream), validationRejected (true when validation refused the address before sending — bounceSubtype InvalidAddress: nothing was sent and the address joins the organization's Blocked list, reason INVALID_ADDRESS; unblock it if it works)
complained Recipient marked it as spam (the address is suppressed on the stream) recipient, feedbackType (e.g. abuse) when reported
opened The recipient's mail program loaded the tiny invisible image (messages that track opens; an estimate) recipient, userAgent, firstOpen
clicked Tracked link clicked recipient, link, userAgent
delivery_delayed Not delivered yet; delivery keeps being tried recipient, delayType, diagnostic
unsubscribed Recipient used the one-click header or the hosted page (the address is suppressed on the stream) recipient
inbound A mailbox received a message see below
conversation A person sent or discarded the app's draft (or it was superseded), or a conversation was handed back to the agents see below
accepted A send was accepted and queued (once per message; not when every recipient was left out) recipients: the addresses it goes to
dropped A recipient was left out of a send: blocked, or (from a domain sent through the platform) its domain takes no mail or is a disposable-address service in marketing; also a recipient blocked while the message waited recipient, reason (suppressed or invalid)
canceled An admin canceled the message while it waited in the send queue (Canceling what is waiting): it is never sent. Only for apps that list it in webhookEvents; always in the native format recipient for a broadcast (one per recipient not sent yet); none for a transactional message

Fields without a value are omitted from data for outbound events.

bounced

{
  "id": "01926a3f-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "type": "bounced",
  "channel": "email",
  "occurredAt": "2026-09-27T10:15:34.004Z",
  "messageId": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "metadata": { "invoiceId": "123" },
  "tags": ["invoice"],
  "data": {
    "recipient": "nao-existe@exemplo.pt",
    "bounceType": "Permanent",
    "bounceSubtype": "General",
    "diagnostic": "smtp; 550 5.1.1 user unknown",
    "suppressed": true
  }
}

complained

{
  "id": "01926a41-2c3d-7e4f-9a50-1b2c3d4e5f60",
  "type": "complained",
  "channel": "email",
  "occurredAt": "2026-09-27T11:02:10.000Z",
  "messageId": "01926a40-aa11-7b22-8c33-d44e55f66a77",
  "metadata": {},
  "tags": ["newsletter"],
  "data": { "recipient": "maria@exemplo.pt", "feedbackType": "abuse" }
}

opened

{
  "id": "01926a42-3d4e-7f5a-8b6c-7d8e9f0a1b2c",
  "type": "opened",
  "channel": "email",
  "occurredAt": "2026-09-27T11:00:42.000Z",
  "messageId": "01926a40-aa11-7b22-8c33-d44e55f66a77",
  "metadata": {},
  "tags": ["newsletter"],
  "data": { "recipient": "maria@exemplo.pt", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "firstOpen": true }
}

clicked

{
  "id": "01926a43-4e5f-7a6b-8c7d-8e9f0a1b2c3d",
  "type": "clicked",
  "channel": "email",
  "occurredAt": "2026-09-27T11:01:05.000Z",
  "messageId": "01926a40-aa11-7b22-8c33-d44e55f66a77",
  "metadata": {},
  "tags": ["newsletter"],
  "data": { "recipient": "maria@exemplo.pt", "link": "https://cliente.pt/servicos", "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)" }
}

delivery_delayed

{
  "id": "01926a40-0b1c-7d2e-8f30-4a5b6c7d8e9f",
  "type": "delivery_delayed",
  "channel": "email",
  "occurredAt": "2026-09-27T10:20:01.000Z",
  "messageId": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "metadata": { "invoiceId": "123" },
  "tags": ["invoice"],
  "data": { "recipient": "joao@exemplo.pt", "delayType": "TransientCommunicationFailure", "diagnostic": "smtp; 421 4.4.2 connection timed out" }
}

unsubscribed

{
  "id": "01926a44-5f6a-7b7c-8d8e-9f0a1b2c3d4e",
  "type": "unsubscribed",
  "channel": "email",
  "occurredAt": "2026-09-27T11:03:00.000Z",
  "messageId": "01926a40-aa11-7b22-8c33-d44e55f66a77",
  "metadata": {},
  "tags": ["newsletter"],
  "data": { "recipient": "maria@exemplo.pt" }
}

canceled

Always in the native format, also for apps set to POSTMARK, SENDGRID or MAILGUN (they have no such event).

{
  "id": "01926a52-1b2c-7d3e-8f40-5a6b7c8d9e0f",
  "type": "canceled",
  "channel": "email",
  "occurredAt": "2026-10-20T14:02:00.000Z",
  "messageId": "01926a50-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
  "metadata": { "invoiceId": "123" },
  "tags": ["invoice"],
  "data": {}
}

inbound

messageId is the id of the received message in the platform; data carries the parsed message. Attachments are links to /a/{token} on the platform, signed and valid for 7 days (no authentication needed to download).

Message bodies are never stored in PostgreSQL, so an inbound delivery only stores a reference to the message ({ "reference": "inbound-message", "id", "type", "messageId", "recipient", "occurredAt", "format" }) and the payload below (or the Postmark inbound payload, attachments in base64, SendGrid's Inbound Parse or a Mailgun route's forward) is rendered from the stored message — its row and its bodies and attachments in storage — each time the delivery is sent, including retries and replays. The delivery log (GET /api/v1/webhook-deliveries/{id}, Integrations → Webhook deliveries → Details) shows that reference. If the message was deleted in the meantime (retention), the delivery is dead-lettered (The message no longer exists).

{
  "id": "01926a45-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
  "type": "inbound",
  "channel": "email",
  "occurredAt": "2026-09-27T09:58:12Z",
  "messageId": "01926a45-6a00-7c11-8d22-e33f44a55b66",
  "metadata": {},
  "tags": [],
  "data": {
    "recipient": "suporte@mail.cliente.pt",
    "mailbox": "suporte@mail.cliente.pt",
    "threadId": "01926a45-69ff-7abc-8def-0123456789ab",
    "from": "joao.pereira@exemplo.pt",
    "fromName": "João Pereira",
    "to": ["suporte@mail.cliente.pt"],
    "cc": [],
    "replyTo": [],
    "originalRecipient": "suporte@mail.cliente.pt",
    "subject": "Problema com a fatura",
    "text": "Olá, a fatura deste mês tem um valor diferente do habitual. Podem verificar?",
    "html": "<p>Olá, a fatura deste mês tem um valor diferente do habitual. Podem verificar?</p>",
    "messageIdHeader": "<CAF3x9Q@mail.exemplo.pt>",
    "inReplyTo": null,
    "references": [],
    "date": "2026-09-27T09:58:12Z",
    "spamVerdict": "PASS",
    "virusVerdict": "PASS",
    "attachments": [
      {
        "filename": "fatura.pdf",
        "contentType": "application/pdf",
        "sizeBytes": 48213,
        "contentId": null,
        "inline": false,
        "url": "https://messaging.example.com/a/YXR0YWNobWVudHwxNzI4MDM2…"
      }
    ],
    "headers": [ { "name": "X-Mailer", "value": "Example Mail 1.0" } ]
  }
}

recipient and originalRecipient are the address the message was received for (for a catch-all mailbox, the address the sender used). spamVerdict and virusVerdict come from the inbound scan (PASS, FAIL, GRAY, PROCESSING_FAILED).

conversation

What people did with an agent's conversation, so the agent can react without polling. Always in the native format, also for apps set to POSTMARK, SENDGRID or MAILGUN (they have no such event). data.change is:

  • draft_sent: a person sent the app's draft (edited says whether they changed it first); messageId is the message that went out, which then has its own delivery events.
  • draft_discarded: a person discarded the app's draft (messageId is null).
  • draft_superseded: the app's pending draft was replaced, by a newer draft or by mail sent in the conversation (a reply, a compose or the send API threading into it). A draft deleted by retention (older than the organization's retention) sends no event.
  • handed_back: a person handed the conversation back to the agents; sent to the app that handed it over, the app that last sent in the conversation and the address's owning app (each once).
  • claim_ended: a person ended the app's claim on the conversation, so the agent should stop working on it; reason is taken_over (a person took it over, also by discarding a draft with "I'll reply"), assigned (it was given to a teammate) or person_replied (a person's reply answered it). Sent to the app whose key claimed it. A claim that ran out, the agent's own reply, draft, hand-over or release sends nothing.
{
  "id": "0199…", "type": "conversation", "channel": "email", "occurredAt": "2026-10-11T10:00:00Z",
  "messageId": "0199…", "metadata": {}, "tags": [],
  "data": { "change": "draft_sent", "threadId": "0199…", "mailboxId": "0199…", "draftId": "0199…", "edited": false }
}

Read the conversation with GET /api/v1/threads/{threadId} or the MCP get_thread tool.

Signature

Every delivery carries:

X-Messaging-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)>

To verify:

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Split the header on ,, then each part on the first =; take t and v1.
  3. Reject when t is more than 5 minutes away from your clock (replay protection). The timestamp is set at each attempt, so retries and replays carry a fresh one.
  4. Compute HMAC-SHA256 with the whole secret string (including the whsec_ prefix, UTF-8) as key over t, a dot, and the raw body; hex-encode it (lowercase).
  5. Compare with v1 in constant time.

Node.js

import crypto from 'node:crypto'
import express from 'express'

const SECRET = process.env.MESSAGING_WEBHOOK_SECRET // whsec_…
const TOLERANCE_SECONDS = 300

export function verifySignature(header, rawBody, secret = SECRET) {
  if (!header) return false
  const parts = new Map(header.split(',').map((part) => {
    const index = part.indexOf('=')
    return [part.slice(0, index).trim(), part.slice(index + 1).trim()]
  }))
  const t = Number(parts.get('t'))
  const v1 = parts.get('v1')
  if (!Number.isInteger(t) || !v1) return false
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex')
  const a = Buffer.from(expected, 'utf8')
  const b = Buffer.from(v1, 'utf8')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

const app = express()
app.post('/hooks/mail', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifySignature(req.get('X-Messaging-Signature'), req.body)) return res.sendStatus(401)
  const event = JSON.parse(req.body.toString('utf8'))
  // de-duplicate on event.id, then queue the work
  res.sendStatus(204)
})

Java

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;

public final class MessagingWebhookSignature {

    private static final long TOLERANCE_SECONDS = 300;

    public static boolean verify(String secret, String header, byte[] rawBody) {
        if (header == null) {
            return false;
        }
        long timestamp = -1;
        String signature = null;
        for (String part : header.split(",")) {
            String[] kv = part.trim().split("=", 2);
            if (kv.length != 2) {
                continue;
            }
            if (kv[0].equals("t")) {
                try {
                    timestamp = Long.parseLong(kv[1]);
                } catch (NumberFormatException e) {
                    return false;
                }
            } else if (kv[0].equals("v1")) {
                signature = kv[1];
            }
        }
        if (timestamp < 0 || signature == null
                || Math.abs(Instant.now().getEpochSecond() - timestamp) > TOLERANCE_SECONDS) {
            return false;
        }
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
            String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
            return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
                    signature.getBytes(StandardCharsets.US_ASCII));
        } catch (java.security.GeneralSecurityException e) {
            throw new IllegalStateException(e);
        }
    }
}

In Spring MVC, take the body as @RequestBody byte[] body (or String and encode it back to UTF-8) and the header with @RequestHeader("X-Messaging-Signature").

Python

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_signature(secret: str, header: str | None, raw_body: bytes) -> bool:
    if not header:
        return False
    parts = {}
    for part in header.split(","):
        key, sep, value = part.strip().partition("=")
        if sep:
            parts[key] = value
    try:
        timestamp = int(parts.get("t", ""))
    except ValueError:
        return False
    signature = parts.get("v1")
    if signature is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

With Flask use request.get_data(); with FastAPI await request.body().

Retries and dead letter

Attempt Sent
1 As soon as the event is recorded (the dispatcher polls every 2 s)
2 1 minute after attempt 1 failed
3 5 minutes later
4 30 minutes later
5 2 hours later
6 6 hours later
7 12 hours later
8 24 hours later

After the 8th failed attempt (about 45 hours after the first) the delivery becomes DEAD and a WEBHOOK_DEAD_LETTER alert is raised for the app (and emailed to admins). Each attempt records the status code, the error, the first 500 characters of the response and the URL it went to (the delivery's url). An attempt is counted when it starts (so attempts includes one in progress): an attempt that never finishes because the instance stopped while sending it is retried after its 10-minute lease, and a delivery whose 8th attempt never finished becomes DEAD with The last attempt did not finish (…) and the same alert.

Deleting an app marks its pending deliveries DEAD (App deleted). A delivery is never sent unsigned: if its app no longer exists when it is due (for example on a replay), it is dead-lettered at once.

Deliveries may arrive more than once (for example after a replay) and are not ordered: de-duplicate on id and use occurredAt.

Replay

UI: Integrations → Webhook deliveries lists deliveries with filters (status, app, event). A delivery's details show the payload and the last response. Select deliveries and choose Replay N selected, or Replay all dead for this app.

API:

POST /api/v1/webhooks/replay
Content-Type: application/json

{ "ids": ["01926a3f-5e6f-7a8b-9c0d-1e2f3a4b5c6d"] }
Body Effect
{ "ids": [...] } Replays those deliveries, whatever their status.
{ "status": "DEAD", "appId": "…" } Replays every delivery of the app in that status.
{} Replays every DEAD delivery of the tenant.

Response: { "replayed": 3 }. Replayed deliveries go back to PENDING with the attempt counter reset, so they get the full retry schedule again. They are sent to the target's current URL with the payload stored in the delivery (inbound: rendered again from the message), signed with the app's current secret. List and inspect deliveries with GET /api/v1/webhook-deliveries and GET /api/v1/webhook-deliveries/{id} (REST API). These endpoints need the WEBHOOKS_MANAGE capability (tenant admins); API keys cannot use them.

URL rules

Webhook URLs are checked to keep deliveries away from internal networks. Internal addresses are: loopback (127/8, ::1), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16 including the cloud metadata address 169.254.169.254, fe80::/10), carrier-grade NAT (100.64/10), 0.0.0.0/8 and ::, IETF protocol assignments (192.0.0.0/24), benchmarking (198.18/15), documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32), multicast (224/4, ff00::/8), reserved and broadcast (240/4), IPv6 unique-local (fc00::/7) and site-local (fec0::/10), NAT64 (64:ff9b::/96, 64:ff9b:1::/48), discard-only (100::/64), and IPv4-mapped or IPv4-compatible IPv6 addresses of any of these.

When What is checked
When the URL is saved (webhookUrl, inboundWebhookUrl) The scheme is https; there are no credentials (https://user:pass@…); if the host is an IP literal (including […] IPv6 and forms such as 0x7f.1), it is not internal. No DNS lookup is made: a host name is accepted as long as the URL is well formed. A refused URL is 400 VALIDATION_FAILED.
Before every request (first attempt, retries, replays) The same scheme and credential rules on the target's current URL, then the host is resolved and every address it resolves to must be public.
During the request Redirects are never followed.

A target refused before the request is dead-lettered at once with Blocked target: … (and an alert); fix the URL and replay. A host that does not resolve (Blocked target: Host does not resolve) is a failed attempt that follows the retry schedule.

The HTTP client resolves the host again when it connects. It shares the JVM's DNS cache with the check (positive answers are kept for 30 s by default), so it connects to the checked addresses; the remaining DNS-rebinding window is the moment that cache entry expires between the check and the connection. Pinning the checked address is not possible with the JDK HTTP client without breaking TLS host name verification, so this residual risk is accepted.

The local and local-aws profiles relax these rules (http, private hosts and internal literals allowed) for development.