Mailgun compatibility

Apps built against Mailgun's messages API can send through the platform by changing the API URL and the key. The platform accepts Mailgun's request on POST /v3/{domain}/messages and POST /v3/{domain}/messages.mime, answers as Mailgun does ({"id", "message"}, or {"message"} for an error), and can send webhooks shaped like Mailgun's signed webhooks and its routes' forwards. Everything else uses the native API (REST API, webhooks).

Apps built against Mailgun's messages API can send through the platform by changing the API URL and the key. The platform accepts Mailgun's request on POST /v3/{domain}/messages and POST /v3/{domain}/messages.mime, answers as Mailgun does ({"id", "message"}, or {"message"} for an error), and can send webhooks shaped like Mailgun's signed webhooks and its routes' forwards. Everything else uses the native API (REST API, webhooks).

Moving an app over, step by step: moving from Mailgun.

Endpoints and authentication

Method Path Body
POST /v3/{domain}/messages Mailgun's message parameters, as multipart/form-data (needed for files) or application/x-www-form-urlencoded
POST /v3/{domain}/messages.mime multipart/form-data with the message as built (message, a file) and its recipients (to)

The paths sit at the root of the platform host (https://<host>/v3/…), not under /api/v1, so the platform's host is the URL Mailgun's SDKs take as their API base. The domain in the path names the request, as Mailgun's does; what decides how the message goes is its From address, whose domain must be a verified domain of the key's tenant, as for any send. The answer's id is at the From address's domain.

The app's API key goes where Mailgun's key went, as HTTP Basic credentials with the user api:

POST /v3/mail.example.com/messages HTTP/1.1
Host: app.mailroomly.com
Content-Type: multipart/form-data; boundary=…
Authorization: Basic YXBpOm1wX2xpdmVf…

Authorization: Bearer mp_live_… works too. HTTP Basic is taken on these two paths only. The key must have the SEND permission, and its domain and stream restrictions apply. A personal token (mp_pat_…) works too, sending as its person within the token's scopes and mailboxes, with no app and so no webhook. Nothing else is accepted: a request without a key or a token, with a user other than api, or carrying a signed-in browser's session cookie, is answered 401 ({"message": "Invalid private key"}, with a WWW-Authenticate: Basic challenge) before anything else runs.

Mailgun's SDKs

Point the SDK at the platform's host and give it the app's key; the code that builds and sends the mail stays the same.

SDK Setting
Node (mailgun.js) new Mailgun(FormData).client({ username: 'api', key, url: 'https://app.mailroomly.com' })
PHP (mailgun/mailgun-php) Mailgun::create($key, 'https://app.mailroomly.com')
Python, Ruby, Go, Java and others The API base URL setting, https://app.mailroomly.com in place of https://api.mailgun.net (https://api.eu.mailgun.net in Mailgun's EU region); with plain HTTP, https://app.mailroomly.com/v3/{domain}/messages

Message parameters

Every parameter Mailgun documents is either honored or refused with a 400 naming it; none is silently ignored. An option that asks for nothing (o:dkim=yes, o:require-tls=no, o:skip-verification=no, o:tracking-pixel-location-top=no) is always accepted.

Parameter Supported Notes
from yes Required, one address (Name <address>). Its domain must be verified and within the key's scope.
to, cc, bcc yes Each given once or more, each a comma-separated list. At least one to. At most 50 recipients in a message.
subject yes Required.
text, html yes At least one.
attachment yes Files, with their filename and type. multipart/form-data only.
inline yes Files shown in the HTML: refer to each as <img src="cid:filename">, by its filename, as with Mailgun.
recipient-variables yes A JSON object by recipient address. The request is then a batch: one message per to recipient (at most 500), each seeing only its own address, with %recipient.name% replaced by that recipient's value (empty when it has none) in the subject, the text, the HTML and the v: variables. cc and bcc are refused in a batch, and so is a batch whose messages, once their variables are replaced, come to more than 50 MB.
o:tag yes Given once or more, each at most 100 characters. Stored as the message's tags and returned as tags in webhooks.
o:tracking, o:tracking-opens, o:tracking-clicks yes yes/no (true/false); o:tracking-clicks also takes htmlonly, which is what yes does here (links are tracked in the HTML only). The specific option wins over o:tracking.
o:testmode yes Runs the checks a real send makes before it keeps anything (the parameters, the addresses and whether the recipients can receive mail, the sender domain and stream, the key's scopes), then keeps and sends nothing: the answer is the one a send gets, with no message, no event, no webhook, nothing counted. The daily limits, and whether a resting organization may send, are not checked.
h: headers yes h:X-Name adds the header, with the restrictions of the native API (reserved headers are refused). h:Reply-To sets the reply-to address. Mailgun's own X-Mailgun-* headers are applied as the options they stand for and never sent on: h:X-Mailgun-Variables is a JSON object of variables, read as v: ones, h:X-Mailgun-Tag adds a tag, h:X-Mailgun-Drop-Message is o:testmode, h:X-Mailgun-Track, -Track-Clicks and -Track-Opens are the tracking options, and the headers for the options below are taken or refused as those options are (an o: option given too wins). h:X-Mailgun-Recipient-Variables and any other X-Mailgun- header are refused. A header or a variable given twice (as v: and in X-Mailgun-Variables too) is refused.
v: variables yes Stored as the message's metadata and returned as user-variables in webhooks.
o:dkim yes only Every message is signed for its domain.
o:require-tls, o:skip-verification, o:tracking-pixel-location-top no only Messages go over TLS whenever the receiving server offers it, certificates are always verified, and the open pixel goes at the end of the HTML.
template, t:*, amp-html no Refused: render the content in text and html before sending.
o:deliverytime, o:deliverytime-optimize-period, o:time-zone-localize no Refused: send the message when it should go.
o:sending-ip, o:sending-ip-pool no Refused: the platform chooses how each message leaves.
o:archive-to no Refused: every message is kept in the platform's message log.

messages.mime takes to (once or more, the recipients) and message, the MIME message; h:, v: and the options above are taken as on messages, and any other parameter is refused. The message is sent again as the platform builds every message: its From, Reply-To, Subject, text and HTML bodies, attachments (inline ones by their Content-ID), In-Reply-To, References and X- headers are kept; its X-Mailgun-* headers are applied as their h: fields are, and never sent on; one Mailroomly doesn't apply (X-Mailgun-Sid, say, on mail Mailgun received) is left out rather than refused. A recipient named in its To header shows there, one named in its Cc header shows there, any other is blind-copied; when none is named in either (a list address, say), they all show as the message's recipients.

A message is transactional; with a key restricted to broadcast it is broadcast (marketing), the recipient can unsubscribe, from all of the organization's marketing, and every copy carries one-click unsubscribe headers. On a plan with the Mailroomly footer (Free), a broadcast gets a small Sent with Mailroomly line at the end of its content.

In broadcast messages, %unsubscribe_url%, %tag_unsubscribe_url% and %mailing_list_unsubscribe_url% are replaced with the recipient's hosted unsubscribe link, as the platform's {{unsubscribe_url}} is; in transactional messages they are removed.

Responses

Success: 200 OK with the call's id, at the From address's domain:

{ "id": "<01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50@mail.example.com>", "message": "Queued. Thank you." }

A batch's messages (one per recipient with recipient-variables) are each one message in the platform (its own messageId, in the message log and the native API); they share the call's id, which Mailgun-format webhooks carry as message.headers.message-id. Blocked recipients are left out of a message that still has other recipients, and a message whose recipients are all blocked is accepted and sends nothing, as Mailgun drops them; with Mailgun's webhook format, each of them is a failed event.

o:testmode on: the same answer, with an id that matches no message, once every message passed the checks.

Errors: Mailgun's shape, {"message": "…"}. In a batch, the message names the first refused recipient (Recipient 2: …).

Situation HTTP
A parameter refused or malformed (above), a body that isn't a readable form (a URL-encoded value that isn't percent-encoded, such as a bare %recipient.name%; more than 1,000 fields), more than 500 recipients in a batch, a batch whose messages come to more than 50 MB of content and attachments (what they share counted once for each, as a batch of as many messages would carry it), any other validation error 400
No key, or an invalid, revoked or suspended-tenant key ("Invalid private key") 401
Sender domain unknown or not verified, key without SEND or outside its domain or stream scope, stream or sending paused, marketing that isn't allowed (from a starter address, without DMARC, outside the plan, before the terms are accepted), or a Free organization waking from resting 403
Request body over 15 MB: refused before it is read when its length is declared; a multipart body sent in chunks, with no length, is read up to 25 MB before it is refused 413
More than 100 requests per second for the key, the daily recipient limit, the key's own daily limit, or a Free plan's month of emails used up 429
Too many bodies at once (32 KB or more each), after waiting up to 10 seconds (Retry-After: 10); nothing is stored, send it again 429
An unexpected error 500

A batch is checked whole before any message is sent: one recipient that can't be sent to (an address, a recipient who can't receive mail, the sender domain, a key's scope, the size) refuses the call, and nothing is sent. What only sending finds can still stop part of a batch, above all a limit reached on the way (429): the answer then carries the call's id and a message saying how many were queued (1 of 2 messages were queued; the others were not sent: …); retry only the recipients whose messages are not in the message log.

Not supported

  • Mailgun's other APIs: domains, templates, mailing lists, suppressions (bounces, unsubscribes, complaints), events and logs, statistics, tags, routes, webhooks and IP management, validation, and the /v3/{domain}/messages/{key} storage. Use the native REST API, the web app and MCP instead: templates, contacts and newsletters live in Campaigns.
  • Scheduled sends, stored templates and AMP.
  • Mailgun's ids: the answered id is a UUID at the sending domain.

Mailgun webhook format

Set the app's webhook format to MAILGUN (Integrations → (app), or webhookFormat: "MAILGUN"). Events then arrive as Mailgun's webhooks, signed as Mailgun signs them, and mail received by the app's addresses arrives as a Mailgun route forwards it. Every request is also signed with X-Messaging-Signature (webhooks).

A new app in this format subscribes to accepted and dropped too (accepted, and failed for a dropped recipient, in Mailgun's words); an app moved to it keeps its list, so add them if the handler uses them.

Events

Each delivery is one event, as Mailgun posts them: a JSON object with the signature and the event data.

{
  "signature": {
    "timestamp": "1792491330",
    "token": "5f0c3e0b8d2a…",
    "signature": "b63c0701c4f4b614f272106a1b367c5c3369bcdca664ae73ebd52787e45eef07"
  },
  "event-data": {
    "id": "01926a3f-0c1d-7e2f-8a3b-4c5d6e7f8a9b",
    "event": "delivered",
    "timestamp": 1792491330.250000,
    "log-level": "info",
    "recipient": "joao@exemplo.pt",
    "recipient-domain": "exemplo.pt",
    "tags": ["invoice"],
    "user-variables": { "invoiceId": "123" },
    "message": { "headers": { "message-id": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50@mail.example.com", "from": "faturas@mail.example.com", "to": "joao@exemplo.pt", "subject": "A sua fatura" } },
    "flags": { "is-test-mode": false },
    "delivery-status": { "code": 250, "message": "250 2.0.0 OK", "description": "" }
  }
}
Platform event Mailgun event Fields
accepted accepted one per recipient; method http
dropped failed severity permanent, reason generic, delivery-status.code 605 and a description saying why (an address that is not valid, or one previously bounced, unsubscribed or complaining)
delivered delivered delivery-status: the recipient server's reply and its code
delivery_delayed failed severity temporary, reason generic, delivery-status with why, as the recipient server said it
bounced failed severity permanent and reason bounce, or severity temporary and reason generic; delivery-status.code is the reply's code, else 550 or 450; a recipient refused by validation before sending is permanent, generic, 605
opened opened client-info.user-agent
clicked clicked url, client-info.user-agent
complained complained
unsubscribed unsubscribed
conversation, canceled — the native format (webhooks)

Every event carries id (unique per event: use it to de-duplicate), event, timestamp (Unix seconds with their fraction), log-level (info, warn for a temporary failure or a complaint, error for a permanent failure), recipient, recipient-domain, tags, user-variables (the message's v: variables), message.headers (message-id, from, to, subject) and flags.is-test-mode (false: test mode sends no webhooks). message-id is the id the messages API answered, without its angle brackets; a message sent another way carries its own Message-ID.

Not sent: storage, envelope, geolocation, ip, campaigns, routes, batch, primary-dkim and the rest of flags.

Signature

Mailgun's webhook signature: in signature, a Unix timestamp, a random token (50 hex characters, new on every attempt) and signature, the HMAC-SHA256 in hex of the timestamp followed by the token, keyed with the app's webhook secret (whsec_…, the whole string). Put the secret where Mailgun's webhook signing key was: Mailgun's verification code works unchanged. Rotating the secret (POST /api/v1/apps/{id}/webhook-secret/rotate) applies at once, to retries too, so put the new one there right after rotating (the deliveries it refuses meanwhile are retried). Check the timestamp is recent, and keep the tokens you have seen, to refuse a replayed request.

As with Mailgun, a handler that answers 406 refuses the delivery for good: it is not retried and raises no alert, and it stays in the delivery log as Dead, where it can be replayed. Any other answer outside 2xx is retried.

Routes' forwards

Mail received by an address the app owns is posted as a Mailgun route's forward() posts it: multipart/form-data when it has files, application/x-www-form-urlencoded when it has none, with the signature as three more fields:

Field Value
recipient The address that received it.
sender The envelope sender (SMTP's MAIL FROM, from Return-Path): empty for a bounce, the From address only when there is no Return-Path.
from, subject The message's From header and decoded subject.
body-plain, stripped-text, body-html The text body, the text body without the quoted reply, and the HTML body, in UTF-8. body-plain and stripped-text are always there: made from the HTML when the message has no text part. body-html is there when the message has HTML.
message-headers JSON: every header as a [name, value] pair, in order.
attachment-count The number of files.
attachment-1 … attachment-N The files, with their filename and type.
content-id-map JSON: each inline part's <Content-ID> and its file field.
each header Its first value, as a field named like the header (To, From, Message-Id, In-Reply-To…), unless a field above has that exact name: From is posted next to from, as Mailgun posts it. A header named like a field Mailgun computes, in any case (Body-Html, Stripped-Text, attachment-…, X-Mailgun-…), is never made a field, so a sender can't forge one; it is still in message-headers.
timestamp, token, signature The signature, as for events.

Not sent: stripped-html, stripped-signature, X-Mailgun-Sflag and X-Mailgun-Sscore: the platform doesn't pass on verdicts it didn't make in Mailgun's terms. Received mail goes to the app's webhook URL, the same as its events: if the handlers sit at two URLs, give received mail its own app, or the address its own inbound webhook URL (webhooks).