Skip to content

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

  1. Sign in at console.waymail.app and create a workspace.
  2. 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.
  3. Create an API key under API keys. It is shown once; store it as a secret.
  4. 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>",
});

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:

fromSender, e.g. Acme <hello@acme.com>, on a verified domain.
to, cc, bccAn address or a list of addresses.
reply_toAn address or a list.
subjectRequired unless a template supplies one.
html, textAt least one of these, or a template.
message_classtransactional (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.
headersCustom headers. Headers Waymail manages, such as Message-ID, are refused.
tagsKey/value pairs you can filter and search on later.
attachmentsUp to 20 per message.
scheduled_atAn ISO 8601 time to send at instead of now, up to 89 days ahead.
topic_idFor 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

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": "…" } }

Limits

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.

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:

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