Documentation
Waymail is an email API on Amazon SES. It sends transactional and marketing email from your own domains, records what happened to every message, and keeps an EU workspace's data in the EU. It sends; it does not receive mail.
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 re-checks every ten minutes, or immediately when you press Check DNS.
- 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 whole API, 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.
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.
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.
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 the whole platform behind it — sending, management and analytics — that runs the same code as the API, under the same suppression, quota and region rules, with every change in the same audit log. An agent can set a workspace up from nothing, send, run a broadcast, and explain to a customer 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 tools cover the whole API, apart from deciding who may reach a workspace: members and invitations stay
in the console, with a person. The jobs agents do most have a tool each, the changes to one kind of record
share a tool, and everything else the API can do is reached through api_search,
api_get and api_execute. That keeps the list short enough for an agent to
choose from well. Every tool also 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 (Clerk), 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 | Everything else the REST API does: automations, contact properties, projects, retention, the audit log. Search for the operation, then read or change through it, as the API itself would. |
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