Documentation
Waymail is an email API on Amazon SES for transactional and marketing email, managed inboxes, and forwarding incoming mail to verified destinations.
Every request goes to your region's API: https://eu.api.waymail.app.
Quickstart
- Sign in at console.waymail.app and create a workspace.
- Add the domain you send from and publish the DNS records it lists. The DKIM records are required — sending from a domain is blocked until they verify. Waymail keeps checking on its own, and Check DNS checks straight away. If Amazon SES gives up on the records, Check DNS is what starts it looking again.
- Create an API key under API keys. It is shown once; store it as a secret.
- Send a message:
curl -X POST https://eu.api.waymail.app/emails \
-H "Authorization: Bearer $WAYMAIL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: welcome-user-42" \
-d '{
"from": "Acme <hello@acme.com>",
"to": "user@example.com",
"subject": "Welcome to Acme",
"html": "<p>Thanks for signing up.</p>"
}'
The response is immediate:
{ "id": "em_eu_01K4…", "status": "queued" }
queued means Waymail has durably accepted the message, not that it
has been delivered. Delivery, bounces and complaints arrive afterwards — on the message's timeline and through
webhooks.
TypeScript SDK
The waymail package covers the main email and workspace resources for Node.js 20 and later, as ESM or CommonJS, with types included.
npm install waymail
import { Waymail } from "waymail";
const waymail = new Waymail(process.env.WAYMAIL_API_KEY);
const { id } = await waymail.emails.send({
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Welcome to Acme",
html: "<p>Thanks for signing up.</p>",
});
- It reads the region from your key and calls that region's API directly.
-
Every send carries an
Idempotency-Key, so a retry after a lost response returns the original message rather than sending a second. Pass your own — an order id, say — to make a retry safe across restarts too:waymail.emails.send(email, { idempotencyKey }). -
It retries only what cannot happen twice: reads, updates and deletes after a timeout or a 5xx, sends through
their key, and anything after a 429 with a short
Retry-After. A long one — a daily quota, a plan's monthly allowance — fails at once. -
Errors are thrown as
WaymailApiError, with the status, the code, the request id and, on rate limits and quotas,retryAfterin seconds.
HTTP API contract
Download Waymail’s OpenAPI 3.1 contract for language-neutral API discovery and client generation. It covers all 152 customer operations offered by MCP, with scope requirements, owner-session boundaries, shared validation and every success response shape. Custom values stay inside typed envelopes; compatible added fields are allowed. Its coverage section records deliberate omissions such as operator actions, billing and capability links. Business rules remain enforced by the API.
Command line
The waymail npm package includes the customer CLI. Use Node.js 20 or later and keep WAYMAIL_API_KEY in your environment.
npm install --global waymail
waymail api forwarding
waymail request GET /emails --query limit=10
waymail request POST /emails --data @email.json --idempotency-key welcome-customer-123
waymail request GET /contacts --all
Requests return API JSON on stdout. Errors, request IDs and automatically generated sending keys go to stderr as JSON. --all streams cursor pages as newline-delimited JSON and stops at a bounded page count. Reuse one sending key after a timeout or process restart; queued means accepted, not delivered. Run waymail --help for stdin, context, conditional revisions and exit codes. The CLI uses public HTTP and your existing permissions; workspace membership administration requires a signed-in owner in the console or MCP.
Rust
Download the tested Rust client example. It uses reqwest and serde for sending, email paging, forwarding rules and inbox reads, with regional routing, structured errors and caller-persisted sending keys. It includes source, a locked dependency graph, a pinned toolchain and local HTTP tests. Adapt it in your project or use the OpenAPI contract for broader client generation. US availability remains disabled.
unzip waymail-rust-client.zip
cd waymail-client
cargo test --locked
cargo run --example send
The example lists recent messages by default. Sending requires the explicit sender, recipient and logical-operation key described in its README. Local tests use only a loopback server.
Authentication
Send your API key as a bearer token: Authorization: Bearer wm_eu_live_….
A key belongs to one project in one workspace, and its region is part of the key itself — which is how
clients reach the right regional API without configuration. Revoke a key from the console the moment it may
have leaked. Keys are for servers: never ship one to a browser.
Sending email
POST /emails accepts:
| from | Sender, e.g. Acme <hello@acme.com>, on a verified domain. |
| to, cc, bcc | An address or a list of addresses. |
| reply_to | An address or a list. |
| subject | Required unless a template supplies one. |
| html, text | At least one of these, or a template. |
| message_class | transactional (the default) or marketing. Marketing mail carries one-click unsubscribe and honours marketing suppressions. Its unsubscribe link belongs to one person, so a marketing message goes to exactly one recipient — send a batch to reach several. |
| headers | Custom headers. Headers Waymail manages, such as Message-ID, are refused. |
| tags | Key/value pairs you can filter and search on later. |
| attachments | Up to 20 per message. |
| scheduled_at | An ISO 8601 time to send at instead of now, up to 89 days ahead. |
| topic_id | For marketing mail: the subscription topic, so a recipient can leave just that topic. |
Retries are safe. Send an Idempotency-Key
header, and repeating the same request returns the original message instead of sending a second one.
Batches. POST /emails/batch takes an
array of up to 100 messages in one request, each one a separate message with its own id.
Reading messages back
GET /emails— recent messages, newest first, with cursor pagination.GET /emails/{id}— one message: recipients, status, and its rendered HTML and text for as long as your retention setting keeps them.GET /emails/{id}/events— its timeline: queued, sent, delivered, delayed, bounced, complained, opened, clicked.
Deleting messages
When someone asks you to erase their data (GDPR Article 17), two calls erase their mail. Both need a key
holding the email:delete scope. A full-access key has it; a key made for sending does not, so a
deploy key cannot erase the record of what it sent. In the console, workspace owners and admins can delete a
message from its page. A delete cannot be undone.
DELETE /emails/{id}— erases one message: its recipients, subject and events, and every stored copy of its content and attachments, straight away rather than when your retention period ends.DELETE /emails?recipient=alice@example.com— erases every message sent to that address, up to 100 a call. It answers with the ids itdeleted, any itskipped, and anext_cursor: repeat withcursorwhilehas_moreis true.GET /emails?recipient=lists the same messages first, if you want to see what will go. The SDK'semails.removeForRecipient(address)does the whole loop.
A message still queued or scheduled is cancelled first, so it never goes, and is not counted towards your
month. One being sent at that very moment is refused with not_deletable (409), or listed under
skipped: it takes seconds, so try again. Messages are found by their to addresses — one
that only copied the person (cc or bcc) is not found by their address, so delete it by
its id.
Three things are kept on purpose. Suppressions stay, because keeping the address is what makes sure an
unsubscribe, a bounce or a complaint goes on being honoured; remove one with
DELETE /suppressions/{email} only if the person asks to be mailed again. Your usage for the month
does not change, because a message that was sent was sent. And the audit log records each deletion by the
message's id, never by an address. A contact is separate: DELETE /contacts/{id} removes it.
Domains
POST /domains with {"name": "acme.com"}
returns the DNS records to publish. DKIM is required. A MAIL FROM subdomain (MX and SPF) and DMARC are
recommended — but publish only one DMARC record per domain: if you already have one, keep it.
POST /domains/{id}/verify re-checks DNS immediately.
Webhooks
POST /webhooks with a URL and the events you want. The response includes a
signing secret beginning whsec_, shown once. Events:
email.queued, email.scheduled,
email.sent, email.delivered,
email.delayed, email.bounced,
email.complained, email.rejected,
email.failed, email.cancelled,
email.opened, email.clicked,
contact.unsubscribed.
Opens and clicks come from Waymail's own tracking pixel and link redirect.
email.opened is sent each time the message is shown, at most once a
second, so one person can open a message many times; email.clicked
carries the URL they followed as link. Both carry the
user_agent that fetched them.
Verify every delivery. Each request carries
waymail-id, waymail-timestamp and
waymail-signature. The signature is v1,
followed by a base64 HMAC-SHA256 of {id}.{timestamp}.{raw body}, keyed with your
secret after its whsec_ prefix, base64url-decoded. While a secret is being
rotated the header holds more than one signature, separated by spaces; accept any that match. Refuse a timestamp
more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
// `body` must be the raw request body, exactly as received.
export function verifyWaymailWebhook(secret, headers, body) {
const id = headers["waymail-id"];
const timestamp = Number(headers["waymail-timestamp"]);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64url");
const expected = "v1," + createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");
return headers["waymail-signature"].split(" ").some((candidate) => {
const a = Buffer.from(candidate), b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
});
}
The SDK does the same in one call: verifyWebhookSignature({ secret, body, messageId, timestamp,
signature }) from waymail, with the three headers' values.
Failed deliveries are retried with backoff, and every attempt is recorded —
GET /webhooks/{id}/attempts lists them, and
POST /webhooks/{id}/replay re-sends a past event.
Bounces, complaints and unsubscribes
A hard bounce or a spam complaint adds the address to your workspace's suppression list automatically, and
suppressed addresses are dropped before a message is queued. If every recipient of a message is suppressed,
the send is refused with recipient_suppressed. Marketing mail carries RFC 8058
one-click unsubscribe and a visible unsubscribe link; unsubscribing from a topic leaves the recipient's other
topics alone. Manage the list with /suppressions or from the console.
Marketing mail also prints your organisation's postal address, which anti-spam law asks of commercial email. You
have usually given it already: the onboarding wizard asks for it, and a paid checkout copies the billing address
you entered on Stripe's page onto the workspace if it has none. Otherwise set it
once, in the console under Settings or with
PATCH /settings and
{"postal_address": "Example AS\nStorgata 1\n0155 Oslo\nNorway"} (up to six
lines). Waymail adds it under the unsubscribe link of every marketing message that does not already state it.
Until it is set, marketing sends and broadcasts are refused with a
validation_error naming
postal_address; transactional mail is never affected, and never carries it.
When someone unsubscribes through that link, a contact.unsubscribed webhook
tells your system, so it can mark them unsubscribed too rather than sync them back as subscribed. Its
data carries the address as email, the
message as email_id, and a scope:
marketing when they left all marketing mail, or
topic with the topic_id they left. It is
sent when the unsubscribe changes something, so pressing again sends nothing more, and never for an
unsubscribe you make through the API.
Errors
Errors are JSON, with a stable code and a request id to quote to support:
{ "error": { "code": "validation_error", "message": "`to`: …", "request_id": "…" } }
400— the request body is not valid JSON.401— missing or invalid API key.403— the key lacks the scope this request needs.404— no such resource in this workspace.409— a conflict, such as a name already taken.422— the request failed validation, or every recipient is suppressed.429— a sending limit was reached. Wait for the number of seconds inRetry-After.
Limits
- Up to 50 recipients per message across
to,ccandbcc. - Up to 100 messages per batch request.
- Up to 20 attachments per message.
- Each workspace has daily and per-second sending limits, shown under Settings in the console.
Coming from Resend
The official resend package works against Waymail unchanged for sending, reading
messages back, domains, API keys, contacts, audiences, suppressions and broadcasts. Point it at Waymail with two
environment variables:
RESEND_BASE_URL=https://eu.api.waymail.app
RESEND_API_KEY=wm_eu_live_…
Or swap the package: waymail includes a Resend-compatible client,
import { Resend } from "waymail", which finds your region from the key — no base URL to set — and
whose calls return { data, error } as Resend's do.
The differences that matter: a send returns queued rather than
sent, and webhooks are signed as described above rather than with Svix.
Agents: MCP
Waymail is as easy for agents as for developers. Each region runs a Model Context Protocol server with workspace operations behind it — sending, management and analytics — that use the same API rules for suppression, quotas and region, with every change in the same audit log. An admitted person can set up a workspace through an agent, send, run a broadcast and investigate why a message bounced.
Endpoint: https://eu.mcp.waymail.app/mcp, over Streamable HTTP. Every answer comes in its
response — there are no sessions and no stream to open. A person or a key sees only the tools their scopes allow:
a read-only key is never offered email_send.
Twenty-one workspace tools cover sending, setup and analysis; three account-entry tools help a signed-in person
inspect their account, create an admitted workspace or accept an invitation. The jobs agents do most have a tool
each; other workspace operations are found through api_search,
api_get and api_execute.
A signed-in owner can manage members and invitations through those tools; API keys cannot. Billing and platform
operations are separate. Every tool says whether it only reads, so Claude and ChatGPT can run lookups without
asking and ask before anything changes.
Connect with an API key
Claude Code, Cursor, the Claude API's MCP connector and your own agents send an API key as a bearer token. Give the agent a key with no more scopes than it needs.
claude mcp add --transport http waymail https://eu.mcp.waymail.app/mcp \
--header "Authorization: Bearer $WAYMAIL_API_KEY"
{
"mcpServers": {
"waymail": {
"type": "http",
"url": "https://eu.mcp.waymail.app/mcp",
"headers": { "Authorization": "Bearer wm_eu_live_…" }
}
}
}
Connect from Claude.ai or ChatGPT
Claude.ai's custom connectors and ChatGPT's connectors cannot hold an API key; they sign you in instead, over
OAuth 2.1. Add https://eu.mcp.waymail.app/mcp as a custom connector (in ChatGPT, turn on
developer mode in Settings first), and you are sent to your Waymail sign-in and back. You then act with the role
you have in the workspace — an admin can set things up, a viewer can only look — and everything you do is in the
workspace's audit log under your name.
-
If you belong to more than one workspace, connect to one by its own URL:
https://eu.mcp.waymail.app/w/<workspace id>/mcp. The workspace id is under Settings in the console, and the endpoint names the URLs when you connect without one. - The authorization server is Waymail's sign-in, powered by Better Auth, with PKCE and dynamic client registration; nothing to configure on your side.
- ChatGPT's
searchandfetchtools are there too, so it can find and read messages, contacts, domains and broadcasts in the shape it expects.
What an agent can do
| Tool | Scope | What it does |
|---|---|---|
| email_send, email_send_batch | email:send | Send one transactional message, or up to 100, now or scheduled. The same rules as the API: suppressions, quotas, and an idempotency key so a retry never sends twice. |
| email_cancel | email:send | Cancel a scheduled message before it goes out. |
| recipient_check, email_search, email_read | email:read | Why someone did not get an email: their suppressions, their messages, and one message's timeline, links (the verification link they never clicked), bodies, attachments or MIME source. |
| email_metrics | email:read | Sent, delivered, bounced, complained, opened and clicked, as counts and rates, by day, domain or broadcast. |
| workspace_status | domain:read | Whether mail can go out right now, and exactly what is missing: DNS records per domain, the account's state, the plan's usage. |
| domain_manage, api_key_manage, template_manage | domain:write | Add, verify, change or remove a domain; issue or revoke a key (never with more scopes than the caller holds); create, publish and preview templates. |
| webhook_manage | webhook:write | Register an endpoint, test it, replay an event, rotate its signing secret. |
| contact_manage, suppression_manage | contact:write, suppression:write | Contacts, audiences and topic subscriptions; stop mail to an address, or allow it again when the person asks. |
| broadcast_manage, broadcast_send | broadcast:write | Draft a broadcast and send yourself a test; sending it to the audience is a separate tool, so you can let an agent draft and still be asked before anything goes out. |
| search, fetch | any read scope | Find messages, contacts, domains and broadcasts by words, and read any record by its id, in the shape ChatGPT expects. |
| api_search, api_get, api_execute | the operation's own | Other permitted workspace operations: automations, contact properties, projects, retention, the audit log and owner-only team access. Search for the operation, then read or change through it under its API permissions. |
Connect only what the agent needs
/mcp offers every tool. For an agent with one job, add a narrower endpoint instead — its tools, and
nothing else, whatever the key or your role would allow:
/mcp/send— sending, scheduling, and finding out what happened to a message./mcp/audience— contacts, audiences, topics, suppressions, broadcasts and automations./mcp/setup— domains, API keys, webhooks, templates and settings./mcp/insights— read-only: delivery history, analytics and every record, and nothing that sends or changes anything.
A workspace URL takes the same suffix, e.g.
https://eu.mcp.waymail.app/w/<workspace id>/mcp/insights.
A tool that fails says so in its result, in the API's own words, so the agent can read it and adjust.
GET https://eu.mcp.waymail.app/tools, with the same credential, lists the tools it can reach
(?surface=send for a narrower endpoint) — the first thing to check when an agent cannot find one.
Regions
The EU region is live, in Frankfurt: eu.api.waymail.app. An EU workspace's
messages, contacts and events are stored and processed in the EU, and a workspace's region is fixed when it is
created. A US region is planned.
What stays in your region
- API, queues and workers
- eu-central-1
- Message bodies and attachments
- eu-central-1 · KMS
- Delivery history, contacts, suppressions
- eu-central-1
- Encryption keys, logs, backups
- eu-central-1
- Tracking links and unsubscribe pages
- eu.api.waymail.app