HTTP API
POST https://api.mailway.net/v1/send # productionPOST https://api.mailway.dev/v1/send # sandbox — stored, never auto-forwardedThe machine-readable contract is published at
mailway.net/openapi.yaml (OpenAPI 3.1) —
every shape on this page, code-verified by the API’s own test suite so it
can’t drift. Generate a typed client from it, import it into Postman, or
hand it to a coding agent.
Authentication
Section titled “Authentication”Authorization: Bearer mw_live_… # production keys — accepted only on api.mailway.netAuthorization: Bearer mw_test_… # test keys — drive the sandboxKeys are created per-project in the console (the project’s Access section) and shown once — the key is the project selection, so there’s no project field in the request body. Full details (host binding, storage, rotation): Authentication & API keys.
Send a message
Section titled “Send a message”curl https://api.mailway.net/v1/send \ -H "Authorization: Bearer $MAILWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": { "email": "billing@yourapp.com", "name": "YourApp" }, "to": "customer@gmail.com", "subject": "Invoice #2042", "html": "<p>Your invoice #2042 is ready.</p>", "text": "Your invoice #2042 is ready." }'const res = await fetch('https://api.mailway.net/v1/send', { method: 'POST', headers: { Authorization: `Bearer ${process.env.MAILWAY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from: { email: 'billing@yourapp.com', name: 'YourApp' }, to: 'customer@gmail.com', subject: 'Invoice #2042', html: '<p>Your invoice #2042 is ready.</p>', text: 'Your invoice #2042 is ready.', }),});const mail = await res.json(); // { id: "m.01k8w9…", status: "queued", … }$response = Http::withToken(env('MAILWAY_API_KEY')) ->post('https://api.mailway.net/v1/send', [ 'from' => ['email' => 'billing@yourapp.com', 'name' => 'YourApp'], 'to' => 'customer@gmail.com', 'subject' => 'Invoice #2042', 'html' => '<p>Your invoice #2042 is ready.</p>', 'text' => 'Your invoice #2042 is ready.', ]);
$mailId = $response->json('id'); // "m.01k8w9…"Response — 202 Accepted
Section titled “Response — 202 Accepted”{ "id": "m.01k8w9abcdefghjkmnpqrstvwx", "status": "queued", "scheduled_at": null, "paused": false, "category": null, "project_id": "p.01k8w9…", "routing": { "provider": null, "strategy": null }}id is the mail ID, the permanent identifier that threads through the
console, webhook events, and share links. Webhooks send the same value as
data.mail.uid, and project_id as data.mail.project.uid. status is
queued, scheduled (when scheduled_at is in the future), paused, or
captured (a sandbox send — stored, never auto-forwarded). queued and
scheduled are the pending state in the console
and on webhooks; paused and captured are states of their own.
Request fields
Section titled “Request fields”| Field | Type | Notes |
|---|---|---|
from |
address | Required. "a@b.com" or { "email": …, "name": … } (a display name goes in name; the "Name <a@b.com>" string form isn’t accepted, so use { "email", "name" }). |
to, cc, bcc |
address | address[] | to is required; cc and bcc are optional. At most 50 combined across the three; each entry takes any address form. |
reply_to |
address | address[] | Optional. |
subject |
string | Required. ≤ 998 characters (the RFC 5322 line limit). |
html, text |
string | At least one, ≤ 2 MB each. Send both when you can — some inboxes still want the text part. |
headers |
object | Custom headers (e.g. List-Unsubscribe). A name is printable ASCII with no space or colon, and can’t start with a dot; anything else is a 422 (headers:invalid). Transport-critical headers and the X-Mailway-* prefix are reserved and rejected. Providers can limit them: MailerSend accepts custom headers over its API only on a Professional plan or higher, and some providers change or drop specific headers (What providers change). |
attachments |
object[] | ≤ 20, each { "filename", "content" (base64), "content_type", "content_id"?, "disposition"? } — 10 MB decoded each, 25 MB combined. content_id + "disposition": "inline" for inline images, referenced from the HTML as cid:<content_id>. A bare id such as logo is sent as logo@mailway (a Content-ID needs an @), and the HTML’s cid:logo references are pointed at it. Brevo’s and Nuntly’s APIs can’t embed images: through them an inline image arrives as a regular attachment. |
tags |
string[] | Optional. ≤ 10 labels for groups of mails (e.g. password-reset), each ≤ 64 chars of [A-Za-z0-9_-]. They become the project’s tags: you can filter mail by them in the console, a rule can match them (tag_present), and every webhook event lists them in data.mail.tags. Case doesn’t matter within a project: Invoice and invoice are one tag, and webhook events show the spelling used first. Once a project has 1,000 distinct tags, a send with a new one is rejected (tags:limit_reached). Reusing a tag always works, and so does a rule’s tag action. |
metadata |
object | Optional. Your own values for this one mail, such as an order or user ID: ≤ 10 keys, keys ≤ 40 chars, string values ≤ 500 chars. Key order isn’t kept. Every webhook event for the mail carries them in data.mail.metadata, so you can match an event to your records without a lookup. Mailway deletes them with the mail’s content when its retention period ends (webhook deliveries already made keep their copy for 30 days). |
category |
string | One per mail — the reporting bucket your per-category stats group by (e.g. "Password reset"). ≤ 64 chars, letters/digits/space/_-./:, case-sensitive, echoed in the response. A project holds at most 100 distinct categories: reusing one is always free, a 101st new name is rejected (category:limit_reached). Use category for reporting, tags for groups of mails, and metadata to match events to your own records. |
scheduled_at |
ISO 8601 | Defer the send — up to 72 hours ahead (Pro and up; on other plans, 402 scheduling_unavailable). |
paused |
boolean | Accept + store the message but hold it until released in the console. |
routing.provider |
string | string[] | Pin the send to a provider (or an ordered preference list) attached to the project, by family — amazon-ses, sendgrid, mailgun, mandrill, postmark, brevo, gmail, mailtrap, smtp, resend, mailjet, mailersend, nuntly, smtp2go. Mailway still picks the account’s transport. |
routing.strategy |
string | Override the project’s provider-selection strategy for this send: fallback, round-robin, random, or free-first. |
provider_options |
object | Provider-specific extras, allowlist-validated — keyed by provider family — { "amazon-ses": { "configuration_set": … } } (SES), { "postmark": { "message_stream": … } } (Postmark) — and applied whichever transport the send takes, except Postmark’s stream on a send through Postmark’s API: that doesn’t take effect yet, and the mail goes to Postmark’s default transactional stream. Unknown options are rejected, never silently dropped. |
The full cap sheet — including rate limits and plan quotas — is on one page: Limits & quotas.
Idempotency
Section titled “Idempotency”Pass an Idempotency-Key header (12–40 chars of [A-Za-z0-9_-]) to make
retries safe:
Idempotency-Key: invoice-2042-receiptA repeated key returns the original message’s 202 instead of sending
again. Keys are unique per project and never expire: a key is used once, for
the life of the project (see Data retention).
POST /v1/send/batchSend up to 100 messages in one request — the body is
{ "messages": [ … ] }, each element using the exact /v1/send message
schema. Elements are independent sends:
one failing validation doesn’t sink the rest, which is why the response is
207 Multi-Status — data is an array in your input order, each
element either the normal send object ({ "id": "m.…", "status": … }) or
an error envelope for that message. Check per element; don’t assume
all-or-nothing. Idempotency-Key is not honored on batch, and each
accepted element counts against your quotas.
Sandbox
Section titled “Sandbox”Point the same code at https://api.mailway.dev with a mw_test_… key:
messages are validated, stored, and visible in the console with a sandbox
badge — but never forwarded automatically. The payload shape is identical,
so promoting to production is a hostname + key swap — and if a specific
capture turns out to be the real thing, you can
release it from the console
instead of re-running the send.
Errors
Section titled “Errors”| Status | Meaning |
|---|---|
401 |
Missing or invalid API key, or a key used on the wrong host (mw_live_ on api.mailway.dev, mw_test_ on api.mailway.net). |
402 |
A plan limit — the monthly message quota or data volume, or scheduled_at on a plan without scheduling. The body’s error.code says which. |
422 |
Validation failed — the body lists field-level errors, e.g. routing_provider_not_configured or an invalid Idempotency-Key. |
429 |
Rate limited — back off and retry. |
The full code catalog with handling advice is on the Errors page; when a send doesn’t behave the way you expect, troubleshooting maps the symptom to a fix.
Cookie consent
We may run ads on Google and Reddit. If we do, knowing whether they work needs your OK for one cookie each from them, used only to tie a signup back to the ad that brought it. Analytics here is cookieless either way, and declining changes nothing else.
Change your mind any time with "Cookie choices" in the footer. Details in the privacy policy.