Skip to content

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

  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 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.
  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 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>",
});

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:

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

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.

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

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 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.

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:

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