Send 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 minted per-project in the console 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": "Acme <no-reply@acme.com>", "to": "user@example.com", "subject": "Welcome to Acme", "html": "<p>Hello!</p>", "text": "Hello!" }'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: 'Acme <no-reply@acme.com>', to: 'user@example.com', subject: 'Welcome to Acme', html: '<p>Hello!</p>', text: 'Hello!', }),});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' => 'Acme <no-reply@acme.com>', 'to' => 'user@example.com', 'subject' => 'Welcome to Acme', 'html' => '<p>Hello!</p>', 'text' => 'Hello!', ]);
$mailId = $response->json('id'); // "m.01k8w9…"Response — 202 Accepted
Section titled “Response — 202 Accepted”{ "id": "m.01k8w9abcdefghjkmnpqrstvw", "status": "queued", "scheduled_at": null, "paused": false, "project_id": "p.01k8w9…", "routing": { "provider": null, "strategy": null }}id is the message’s permanent identifier — it threads through the console,
webhook events, and the public viewer. status is queued, scheduled
(when scheduled_at is in the future), paused, or captured (a sandbox
send — stored, never auto-forwarded).
Request fields
Section titled “Request fields”| Field | Type | Notes |
|---|---|---|
from |
address | Required. "a@b.com", "Name <a@b.com>", or { "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 | Optional. |
subject |
string | Required. ≤ 998 bytes (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). Transport-critical headers and the X-Mailway-* prefix are reserved and rejected. |
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. |
tags |
string[] | ≤ 10 labels of ≤ 64 chars, echoed on every webhook event for this mail. |
metadata |
object | ≤ 10 keys — your correlators (order IDs, user IDs), echoed on every webhook event. |
category |
string | One per message — 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 and tags for correlation — different jobs. |
scheduled_at |
ISO 8601 | Defer the send — up to 72 hours ahead. |
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, e.g. "amazon-ses-api". |
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 — e.g. { "configuration_set": … } (SES), { "message_stream": … } (Postmark). 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: order-84412-receiptA repeated key returns the original message’s 202 instead of sending
again. Keys are unique per project and bounded by your retention window, not
a 24-hour timer.
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 honoured 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 mw_live_… key used outside api.mailway.net. |
402 |
Plan quota exhausted — monthly message volume or throughput. 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 run ads. Knowing whether they work needs your OK for one cookie each from Google and Reddit, 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.