Send API
POST https://api.mailway.net/v1/send # productionPOST https://api.mailway.dev/v1/send # sandbox — stored, never auto-forwardedAuthentication
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), or paused.
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[] | At least one recipient across the three, at most 50 combined. 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 an array where each
element uses the exact /v1/send 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.