Skip to content

Email infrastructure

EmailMate Docs

Rails for agents and humans who build email products. REST for apps, MCP for agents, dashboard for operators. Same domains, keys, logs, and reputation. SES under the hood.

Base URL

https://www.emailmate.dev/v1

Auth

Bearer em_…

MCP

https://mcp.emailmate.dev/mcp

OpenAPI · Scalar

/api-reference
https://www.emailmate.dev/v1/openapi.json

Platform

Three doors on one send stack. Use cases (newsletters, ship notes, outreach) are things you build — not separate products.

DoorWhoSurface
RESTApps and CI/v1/emails · /inboxes · /domains · /api-keys · /webhooks
MCPAgentsmcp.emailmate.dev — same keys and From rules
DashboardHumans/dashboard — logs, inbox, DNS, suppressions
Deliverability law: root domain = OTP / product mail. mail. subdomain = marketing, newsletter, outreach. Never mix.

Quickstart

Verify a domain → mint a domain-scoped key → send. Target under 15 minutes.

1. Install the SDK

bash
bun add emailmate
# npm install emailmate

2. Send (Resend-shaped)

typescript
import EmailMate from "emailmate";

const em = new EmailMate(process.env.EMAILMATE_API_KEY!);

const { id } = await em.emails.send({
  from: "Acme <hello@acme.com>",
  to: "user@example.com",
  subject: "Welcome",
  html: "<p>You're in.</p>",
  text: "You're in.",
});

3. curl

bash
curl https://www.emailmate.dev/v1/emails \
  -H "Authorization: Bearer em_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@acme.com>",
    "to": "user@example.com",
    "subject": "Welcome",
    "html": "<p>Hi</p>",
    "text": "Hi"
  }'

4. Coming from Resend?

typescript
// Before
import { Resend } from "resend";

// After — drop-in class export
import { Resend } from "emailmate";

const resend = new Resend(process.env.EMAILMATE_API_KEY!);
await resend.emails.send({ from, to, subject, html });

Authentication

Every REST call needs a Bearer key. Keys start with em_ (legacy re_ / ml_ still resolve). Prefer domain-scoped keys — From ownership is fail-closed.

http
Authorization: Bearer em_xxxxxxxxxxxx
Content-Type: application/json
FieldMeaning
permissionfull_access (manage) or sending_access (send only)
domainIdWhen set, From must match that verified domain
tokenHashOnly form stored server-side; plaintext shown once
Never put keys in browsers, mobile apps, or public repos. Rotate from the dashboard if leaked.

Sending rules

Production lessons, enforced so Gmail stays green.

RuleWhy
Always set fromExplicit From every send — don't rely on app defaults for marketing
Split domainsroot = transactional · mail.* = marketing / newsletter / outreach
bucket: "marketing"List-Unsubscribe + bucket suppressions. Never on OTP / magic links
html + textAlways include both for deliverability
reply_to matches channelMarketing Reply-To on mail.*; transactional on root
Verified domain onlyDomain-scoped key → that domain; unscoped → owned verified domain
typescript
// Transactional (OTP, receipts)
await em.emails.send({
  from: "Acme <hello@acme.com>",
  to, subject, html, text,
  // omit bucket
});

// Marketing one-shot via API
await em.emails.send({
  from: "Acme <jimmy@mail.acme.com>",
  to, subject, html, text,
  bucket: "marketing",
  reply_to: "jimmy@mail.acme.com",
});

MCP for agents

Agent-native surface mirrors REST. Default URL is rails only (send, domains, logs, keys). Inbox tools need https://mcp.emailmate.dev/mcp?sets=inbound. OAuth consent at https://www.emailmate.dev/oauth/mcp, or Bearer key on Streamable HTTP.

bash
claude mcp add --transport http emailmate https://mcp.emailmate.dev/mcp
json
// .cursor/mcp.json
{
  "mcpServers": {
    "emailmate": {
      "type": "http",
      "url": "https://mcp.emailmate.dev/mcp"
    }
  }
}
DomainTools
Sendingem_send, em_send_batch, em_send_template, em_get_status
Domains / keysem_domain_*, em_domain_enable_inbound, em_key_*
Templates / logsem_template_*, em_log_*
Broadcastsem_broadcast_*, em_audience_*, em_contact_*, em_segment_*
Newslettersem_newsletter_*, em_issue_*
Outreachem_outreach_*
Webhooks / suppress / reputationem_webhook_*, em_suppress_*, em_reputation_*
Inbox (opt-in `?sets=inbound`)em_inbox_list_inboxes, em_inbox_create, em_inbox_send, em_inbox_reply, em_inbox_forward, em_inbox_*draft*, em_inbox_*list*

Core

Transactional send

Send, batch, domains, keys, templates, webhooks, suppressions. This is the product.

Send email

Programmatic transactional mail — OTP, receipts, product notifications. Response shape is Resend-compatible: { "id": "…" }.

POST/v1/emails

Send one email

Body

ParameterTypeDescription
fromrequiredstringSender. "Name <user@domain.com>" or bare email
torequiredstring | string[]Recipient(s). Max 50 per request
subjectrequiredstringSubject line (or from template)
htmlstringHTML body
textstringPlain text body
reply_tostring | string[]Reply-To header
ccstring | string[]CC recipients
bccstring | string[]BCC recipients
tags{ name, value }[]Metadata for logs / filters
headersRecord<string, string>Custom headers
attachmentsAttachment[]filename + content (+ content_type)
bucketstringSet "marketing" for promo path + List-Unsubscribe
templatestringTemplate slug — fills subject/html from registry
dataobjectVariables when using template
json
{
  "from": "Acme <hello@acme.com>",
  "to": ["user@example.com"],
  "subject": "Your code is 482910",
  "html": "<p>482910</p>",
  "text": "482910",
  "tags": [{ "name": "category", "value": "otp" }]
}
GET/v1/emails

List delivery logs

GET/v1/emails/:id

Retrieve one email / log

Batch send

Send up to 100 messages in one request (Resend batch shape).

POST/v1/emails/batch

Array of email objects · max 100

json
[
  { "from": "Acme <hello@acme.com>", "to": "a@x.com", "subject": "Hi", "html": "<p>A</p>" },
  { "from": "Acme <hello@acme.com>", "to": "b@x.com", "subject": "Hi", "html": "<p>B</p>" }
]

Templates

CRUD templates in the API, or use registry templates from emailmate/templates (React Email). Send with template + data on POST /emails.

GET/v1/templates

List templates

POST/v1/templates

Create template

GET/v1/templates/:id

Get template

PATCH/v1/templates/:id

Update template

DELETE/v1/templates/:id

Delete template

json
// POST /v1/emails with template
{
  "from": "Acme <hello@acme.com>",
  "to": "user@example.com",
  "template": "welcome",
  "data": { "name": "Ada" }
}

Domains

Add domain → publish SPF, DKIM, MAIL FROM → verify. Status until verified. Add DMARC (p=none OK to start) before scale.

POST/v1/domains

Create · returns DNS records

ParameterTypeDescription
namerequiredstringe.g. acme.com or mail.acme.com
GET/v1/domains

List domains

GET/v1/domains/:id

Status + DNS

POST/v1/domains/:id/verify

Re-check SES

DELETE/v1/domains/:id

Remove domain

Desk Health checklist covers Gmail sender guidelines (SPF + DKIM + DMARC, spam rate, one-click unsub on marketing).

Bring your own SES

EmailMate is a SES plugin. Cloud uses our AWS account. BYOK uses yours. Same REST, MCP, and desk. Resend does not let you plug in keys.

Dashboard → Settings → SES. Paste an IAM user access key. New domains verify on your account. Sends skip our tenant isolation — reputation stays on your AWS bill.

IAM

json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "ses:SendEmail",
      "ses:SendRawEmail",
      "ses:GetAccount",
      "ses:CreateEmailIdentity",
      "ses:DeleteEmailIdentity",
      "ses:GetEmailIdentity",
      "ses:PutEmailIdentityMailFromAttributes"
    ],
    "Resource": "*"
  }]
}

Self-host later

The same plugin reads AWS_SES_* env vars. Connecting keys in the desk is the cloud form of that plugin.

API keys

Create in dashboard or API. Domain-scoped keys can only send From that domain.

GET/v1/api-keys

List keys

POST/v1/api-keys

Create key (token once)

DELETE/v1/api-keys/:id

Revoke key

Webhooks

Customer endpoints for delivery events — Resend-style CRUD. Secret whsec_… returned once on create. SES system ingestion is separate: POST /v1/webhooks/ses (no Bearer).

POST/v1/webhooks

Create endpoint

json
{
  "name": "Prod",
  "url": "https://app.example.com/hooks/email",
  "events": [
    "email.delivered",
    "email.bounced",
    "email.complained"
  ]
}
GET/v1/webhooks

List endpoints

GET/v1/webhooks/:id

Get endpoint

PATCH/v1/webhooks/:id

Update endpoint

DELETE/v1/webhooks/:id

Delete endpoint

Events

EventWhen
email.sentAccepted into queue / SES
email.deliveredMailbox accepted
email.delivery_delayedTemporary deferral
email.openedOpen pixel (when tracked)
email.clickedLink click (when tracked)
email.bouncedHard / soft bounce
email.complainedSpam complaint
email.receivedInbound mail threaded in Inbox
contact.createdAudience contact added
contact.unsubscribedUnsub / preference

Inbox

Named mailboxes on your verified domain. Enable receive on the domain page (Cloudflare Email Routing catch-all). Create agent@your-domain — or omit the username for a random local-part. Unknown addresses land in catch-all (receive-only). Reply and send go out via SES. MCP inbox tools are opt-in: https://mcp.emailmate.dev/mcp?sets=inbound.

POST/v1/domains/:id/inbound

Enable Cloudflare Email Routing catch-all

GET/v1/inboxes

List inboxes

POST/v1/inboxes

Create named inbox (username optional)

GET/v1/inboxes/:inboxId/threads

List threads

POST/v1/inboxes/:inboxId/messages

Send from this mailbox

POST/v1/inboxes/:inboxId/messages/:id/reply

Reply (reply_all optional)

POST/v1/inboxes/:inboxId/messages/:id/forward

Forward

GET/v1/inboxes/:inboxId/drafts

List drafts

POST/v1/inboxes/:inboxId/drafts/:id/send

Send a draft

GET/v1/inboxes/:inboxId/lists

Allow / block senders

GET/v1/inbox/threads

Legacy: list threads across inboxes

Suppressions

Hard bounces and complaints auto-suppress. Marketing respects bucket prefs. Never re-mail suppressed addresses.

GET/v1/suppression

List suppressions

POST/v1/suppression

Add suppression

DELETE/v1/suppression/:id

Remove (careful)

POST/v1/suppressions/sync

Partner / host preference sync

GDPR

Export and erase paths for compliance workflows.

POST/v1/gdpr/access

Data export for a subject

POST/v1/gdpr/erasure

Erase logs for a subject

Lists

Campaigns

Send one HTML to a list. Always from a marketing subdomain. List-Unsubscribe is automatic on broadcasts. Not a Mailchimp replacement — list-send on the same rails as OTP.

Mailchimp conceptEmailMate
Audience / listPOST /v1/audiences
SubscribersPOST /v1/audiences/:id/contacts
SegmentsPOST /v1/segments (filter AST)
CampaignPOST /v1/broadcasts + /send
TagsContact tags + email tags
UnsubscribeOne-click + /v1/unsubscribe/:token

Audiences

Named lists for campaigns and newsletters.

POST/v1/audiences

Create audience

ParameterTypeDescription
namerequiredstringList name
GET/v1/audiences

List audiences

GET/v1/audiences/:id

Get audience

DELETE/v1/audiences/:id

Delete audience

Contacts

Nested under audiences (Resend / Mailchimp-style). Supports first/last name and unsubscribed flag. Import merges on duplicate email where enabled.

POST/v1/audiences/:audience_id/contacts

Add contact

ParameterTypeDescription
emailrequiredstringContact email
first_namestringFirst name
last_namestringLast name
unsubscribedbooleanDefault false
json
{
  "email": "ada@example.com",
  "first_name": "Ada",
  "last_name": "Lovelace"
}
GET/v1/audiences/:audience_id/contacts

List contacts

GET/v1/audiences/:audience_id/contacts/:id

Get contact

PATCH/v1/audiences/:audience_id/contacts/:id

Update contact

DELETE/v1/audiences/:audience_id/contacts/:id

Remove contact

Segments

Mailchimp-style filters over an audience. AST: { op: "and"|"or", clauses: [{ field, cmp, value }] }. Broadcasts may target a segment_id when supported on the desk.

GET/v1/segments?audience_id=

List segments for an audience

POST/v1/segments

Create segment

json
{
  "name": "VIPs",
  "audience_id": "aud_…",
  "filter": {
    "op": "and",
    "clauses": [{ "field": "tag", "cmp": "eq", "value": "vip" }]
  }
}
FieldUse
tagContact tag match
sourceImport / form source
statusSubscription status
unsubscribedBoolean
email / firstNameIdentity
hasEmailHas address
cf:<key>Custom field

Campaigns (broadcasts)

Create a draft → send now or schedule. Sends use the marketing bucket + List-Unsubscribe. From domain must be verified.

POST/v1/broadcasts

Create draft campaign

ParameterTypeDescription
namerequiredstringInternal campaign name
subjectrequiredstringEmail subject
audience_idrequiredstringTarget list
fromrequiredstring"Name <jimmy@mail.acme.com>"
htmlstringHTML body
preview_textstringInbox preview
reply_tostringReply-To
template_idstringOptional template
json
{
  "name": "Launch",
  "subject": "We're live",
  "audience_id": "aud_…",
  "from": "Jimmy <jimmy@mail.acme.com>",
  "html": "<p>Ship day.</p>",
  "preview_text": "Ship day",
  "reply_to": "jimmy@mail.acme.com"
}
GET/v1/broadcasts

List campaigns (?status=)

GET/v1/broadcasts/:id

Get campaign

PATCH/v1/broadcasts/:id

Update draft

POST/v1/broadcasts/:id/send

Send or schedule

json
// optional body
{ "scheduled_at": "2026-08-01T15:00:00.000Z" }
POST/v1/broadcasts/:id/cancel

Cancel scheduled send

typescript
const b = await em.broadcasts.create({
  name: "Launch",
  audience_id: "aud_…",
  from: "Jimmy <jimmy@mail.acme.com>",
  subject: "We're live",
  html: "<p>Ship day.</p>",
});
await em.broadcasts.send(b.id);

Use case

Newsletter APIs

Publications and issues exist so hosts can build a newsletter. EmailMate is the rails, not Beehiiv.

Beehiiv conceptEmailMate
PublicationPOST /v1/newsletters
Post / issuePOST /v1/newsletters/:id/issues
Send issuePOST /v1/issues/:id/send
Subscribe pagehttps://www.emailmate.dev/s/{slug}
Double opt-indouble_opt_in on publication
Archivearchive_public + /s/{slug}/archive

Publications

POST/v1/newsletters

Create publication

ParameterTypeDescription
namerequiredstringPublication name
from_namerequiredstringFrom display name
from_emailrequiredstringVerified From address (prefer mail.*)
slugstringPublic URL slug
descriptionstringAbout blurb
reply_tostringReply-To
audience_idstringBind to existing list (optional)
double_opt_inbooleanConfirm email before subscribe
public_subscribebooleanEnable /s/{slug} page
json
{
  "name": "Weekly Ship",
  "from_name": "Acme",
  "from_email": "jimmy@mail.acme.com",
  "slug": "weekly-ship",
  "public_subscribe": true,
  "double_opt_in": false
}
GET/v1/newsletters

List publications

GET/v1/newsletters/:id

Get publication

PATCH/v1/newsletters/:id

Update publication

Also supports archive_public for public issue archives.

Issues

Draft issues → send to the publication audience. Status: draft · scheduled · sending · sent · cancelled.

POST/v1/newsletters/:id/issues

Create issue

ParameterTypeDescription
titlerequiredstringInternal / archive title
subjectstringEmail subject
preview_textstringInbox preview
htmlstringHTML body
textstringPlain text
GET/v1/newsletters/:id/issues

List issues

GET/v1/issues/:id

Get issue

PATCH/v1/issues/:id

Update draft issue

POST/v1/issues/:id/send

Send issue to list

typescript
const nl = await em.newsletters.create({
  name: "Weekly Ship",
  from_name: "Acme",
  from_email: "jimmy@mail.acme.com",
  public_subscribe: true,
});
const issue = await em.newsletters.createIssue(nl.id, {
  title: "Issue 12",
  subject: "What shipped this week",
  html: "<p>…</p>",
});
await em.newsletters.sendIssue(issue.id);

Subscribe pages

When public_subscribe is true, the publication is live at:

Public subscribe

https://www.emailmate.dev/s/{slug}

Archive (when enabled)

https://www.emailmate.dev/s/{slug}/archive

No embed iframe required for MVP — share the URL. Double opt-in sends a confirm message when enabled.

Use case

Ship notes

Product updates are a campaign, not a product. Same POST /v1/broadcasts path, marketing domain, one-click unsub.

Use case

Outreach

Cold send with daily caps on the marketing domain. You build the product; we provide the rails.

Cold campaigns

Daily caps protect reputation. Marketing domain only. Start queues min(list, daily_cap) via the marketing path. Cap clamped 1–500 (default 50).

POST/v1/outreach

Create campaign

json
{
  "name": "Builder intros",
  "subject": "Quick question",
  "audience_id": "aud_…",
  "from_name": "Jimmy",
  "from_email": "jimmy@mail.acme.com",
  "daily_cap": 50,
  "html": "<p>…</p>",
  "notes": "opt-in list only"
}
GET/v1/outreach

List campaigns

GET/v1/outreach/:id

Get campaign

PATCH/v1/outreach/:id

Update campaign

POST/v1/outreach/:id/start

Start / resume

POST/v1/outreach/:id/pause

Pause

Never cold-mail from your OTP root domain. Caps + split domains + suppressions are non-negotiable.

SDK

Official TypeScript client. Resources: emails · domains · inboxes · apiKeys · templates · webhooks · audiences · contacts · segments · broadcasts · newsletters · outreach · suppressions · gdpr.

typescript
import EmailMate from "emailmate";

const em = new EmailMate(process.env.EMAILMATE_API_KEY!);

// Transactions
await em.emails.send({ from, to, subject, html, text });

// Inbox (named mailbox on a verified domain)
const box = await em.inboxes.create({ domain: "acme.com", username: "agent" });
await em.inboxes.messages.send(box.id, { to, subject, text });

// Segments
await em.segments.create({
  name: "VIPs", audience_id, filter: { op: "and", clauses: [{ field: "tag", cmp: "eq", value: "vip" }] },
});

// Marketing
const b = await em.broadcasts.create({
  audience_id, from: "Name <mail@domain.com>", subject, html,
});
await em.broadcasts.send(b.id);

// Newsletter
const nl = await em.newsletters.create({
  name: "Weekly", from_name: "Acme", from_email: "jimmy@mail.acme.com",
});
const issue = await em.newsletters.createIssue(nl.id, { title: "Ship notes", html });
await em.newsletters.sendIssue(issue.id);

// Outreach
const o = await em.outreach.create({
  name: "Cold A", subject, audience_id,
  from_name: "Jimmy", from_email: "jimmy@mail.acme.com", daily_cap: 40,
});
await em.outreach.start(o.id);

Rate limits

Per API key, by plan. Exceeded → 429 with Retry-After. Agent keys on Free are stricter.

PlanDefaultAgent key
Free2/s · 100/h · 500/d1/s · 50/h · 200/d
Pro10/s · 1k/h · 20k/d5/s · 500/h · 5k/d
Scale20/s · 5k/h · 50k/d10/s · 2k/h · 20k/d

Errors

JSON error bodies. Honor status codes for retries.

StatusMeaning
400Bad request
401Missing or invalid API key
403Not allowed (domain, key scope, or account paused)
404Resource not found
422Validation error
429Rate limited — use Retry-After
503Temporarily unavailable — retry shortly
500Server error — exponential backoff
json
{
  "statusCode": 422,
  "message": "Domain not verified",
  "name": "validation_error"
}
Do not retry on 2xx. Retry 429 / 5xx with backoff. Never log full API keys.

OpenAPI

Machine-readable contract for codegen, agents, and Scalar UI.

JSON spec

https://www.emailmate.dev/v1/openapi.json

SystemPath
Health (no auth)GET /v1/health
API root / tool mapGET /v1/
One-click unsubPOST /v1/unsubscribe/:token