Skip to content

DOCUMENTATION · API v1 · MIT LICENSED

Docs

Fifteen minutes end to end, or jump straight to your endpoint. One base URL for everything: https://api.mailysend.com/v1

Introduction

MailySend is a Resend-compatible email platform that runs on Cloudflare Workers. One REST API covers transactional sends, marketing broadcasts, automations, inbound mail and analytics — and if you already call Resend, the request bodies, status values and webhook names here are the ones you know. Everything is MIT licensed, so you can read the source, fork it, or deploy the platform into your own Cloudflare account.

BASE URL
api.mailysend.com/v1
AUTH
Bearer ms_live_…
FORMAT
JSON · UTF-8

Quickstart

Three minutes from zero to a delivered email.

1 · Install the SDK
$ npm install mailysend
2 · Send
import { MailySend } from 'mailysend';
const ms = new MailySend(process.env.MAILYSEND_API_KEY);

const { data, error } = await ms.emails.send({
  from: 'MailySend <[email protected]>',
  to: ['[email protected]'],
  subject: 'Hello from the edge',
  html: '<p>It works.</p>',
  tags: [{ name: 'category', value: 'welcome' }],
});
3 · Watch it land
$ npx mailysend tail
12:04:03 em_7Kq2xR accepted [email protected]
12:04:04 em_7Kq2xR delivered gmail-smtp-in · 41ms

Domains & DNS

Add a domain, then publish three records. If the domain is on Cloudflare DNS we write them for you and verify in seconds; anywhere else, copy them into your provider and hit verify.

DNS records required for a sending domain
TYPENAMEVALUE
TXTsend.yourdomain.comv=spf1 include:spf.mailysend.com ~all
TXTms1._domainkeyp=MIGfMA0GCSq… (2048-bit DKIM)
TXT_dmarcv=DMARC1; p=none; rua=mailto:dmarc@…

Every domain gets its own DKIM key pair, a click-tracking subdomain you control, and an optional custom return-path. DMARC reports are parsed for you — see DMARC analytics.

Authentication

Keys are scoped by permission, domain and environment. A leaked send-only key can’t read your logs or export contacts.

curl https://api.mailysend.com/v1/api-keys \
  -H "Authorization: Bearer $MAILYSEND_API_KEY" \
  -d '{ "name": "prod worker", "permission": "sending_access",
        "domain_id": "dom_9f2" }'

Emails API

POST/v1/emailsSend one email
POST/v1/emails/batchUp to 100 per call
GET/v1/emails/:idStatus, events, full payload
PATCH/v1/emails/:idReschedule a queued email
POST/v1/emails/:id/cancelCancel before send
REQUEST BODY
from required — verified sender, optionally with display name.
to, cc, bcc — up to 50 recipients per message.
subject required
html, text, react, template_id — pick one; we generate the missing plain-text part.
reply_to, headers, attachments, tags
schedule_at — ISO 8601 or natural language ("in 1 hour").

Batch & scheduling

Batch sends fan out through Cloudflare Queues, so one slow recipient domain never blocks the rest. Scheduled mail is held in a Durable Object alarm — cancel or reschedule any time before it fires.

await ms.emails.batch([
  { from: f, to: '[email protected]', subject: 'Receipt', template_id: 'tpl_r1' },
  { from: f, to: '[email protected]', subject: 'Receipt', template_id: 'tpl_r1' },
], { schedule_at: '2026-09-10T09:00:00Z' });

Attachments

Pass base64 content, or a URL we fetch at send time. Files land in R2 in your own bucket when self-hosting.

attachments: [
  { filename: 'invoice.pdf', path: 'https://cdn.acme.dev/inv/4821.pdf' },
  { filename: 'terms.txt', content: base64, content_type: 'text/plain' },
]

Idempotency & tags

Send Idempotency-Key and a retry inside 24 hours returns the original email instead of a duplicate. Tags are indexed, so every chart and log filter can slice by them.

-H "Idempotency-Key: order-4821-receipt"

tags: [
  { name: 'category', value: 'receipt' },
  { name: 'plan',     value: 'pro' },
]

Templates (JSX, MJML, Handlebars)

Store templates server-side, version every change, and send by template_id — so marketing can fix a typo without a deploy. Or render React locally and send HTML. Both paths preview against 40 clients.

// templates/LoginCode.jsx
import { Html, Text, Button } from '@mailysend/jsx';

export default ({ code }) => (
  <Html>
    <Text>Your code is {code}</Text>
    <Button href="https://acme.dev/verify">Verify</Button>
  </Html>
);

$ npx mailysend templates push # versioned, instant rollback

Audiences & contacts

Contacts live in D1 with arbitrary custom fields. Segments are saved SQL-ish filters that stay live — plan = 'pro' AND last_open < 30d.

POST/v1/audiences
POST/v1/audiences/:id/contacts
GET/v1/audiences/:id/segments/:sid/count
DEL/v1/contacts/:id· GDPR erase, cascades everywhere

Broadcasts

Create in the API or the visual editor — either one is editable in both places. A broadcast’s progress lives in a Durable Object, so you get live counts, pause/resume, throttle control and per-link click maps.

const b = await ms.broadcasts.create({
  audience_id: 'aud_2Kx',
  from: '[email protected]',
  subject: 'September changelog',
  html: '<p>Hi {{first_name}} …</p>',
  ab_test: { subject_b: 'What shipped in September', split: 0.2 },
});
await ms.broadcasts.send(b.id, { throttle_per_minute: 5000 });

AutomationsRUNS IN YOUR ACCOUNT

Drip sequences, welcome flows and win-backs run on Cloudflare Workflows: durable steps, waits measured in days, branching on your own events. No external orchestration, no cron soup — and the workflow executes in your account, against your data.

await ms.automations.create({
  name: 'Onboarding',
  trigger: { event: 'user.signed_up' },
  steps: [
    { send: 'tpl_welcome' },
    { wait: '2 days' },
    { branch: { if: 'contact.projects == 0',
                then: [{ send: 'tpl_nudge' }] } },
  ],
});

Inbound email

Point a catch-all rule at MailySend and inbound mail arrives as parsed JSON: headers, text, HTML, attachments in R2, spam score, and the thread it belongs to. Sub-addressing ([email protected]) and HMAC-signed reply headers keep replies routed to the right object.

{
  "type": "email.received",
  "thread_id": "thr_5Nq",
  "from": "[email protected]",
  "spam_score": 0.02,
  "attachments": [{ "r2_key": "inb/5Nq/photo.png" }]
}

Webhooks

Signed with an HMAC timestamp, retried with exponential backoff for 24 hours, and replayable from the dashboard. Every attempt keeps its response code and body so you can debug your own endpoint.

  • email.sent
  • email.delivered
  • email.delivery_delayed
  • email.opened
  • email.clicked
  • email.bounced
  • email.complained
  • email.received
  • contact.unsubscribed
  • broadcast.finished

Suppressions

Hard bounces and complaints are suppressed automatically in KV, per workspace, within milliseconds — and a suppressed send returns a clear 422 suppressed_recipient instead of silently vanishing. Import your existing list on day one so you never re-mail a dead address.

Analytics API

Query the same Analytics Engine data the dashboard charts, grouped by tag, template, domain, recipient provider or country — and export raw events to R2 or your warehouse.

GET /v1/analytics/deliverability
  ?group_by=recipient_provider&tag=receipt&range=30d

{ "gmail.com":   { delivered: 0.997, inbox_rate: 0.981, opens: 0.62 },
  "outlook.com": { delivered: 0.991, inbox_rate: 0.943, opens: 0.48 } }

Every inbox_rate carries a source field — seed, postmaster, snds or estimate — because an SMTP 250 means accepted, not inboxed. See what the analytics look like →

Providers: Cloudflare, Amazon SES, Resend

MailySend separates the API you code against from the wire that carries the mail. Cloudflare Email Service is the default. Point a domain at Amazon SES for the cheapest bulk rate, or keep sending through Resend while you migrate — same SDK, same logs, same webhooks.

Sending providers and their rates
PROVIDERRATEBEST FORSETUP
Cloudflare$0.35 / 1kDefault · lowest latencyZero
Amazon SES$0.10 / 1kMillions/month, cost-firstIAM key
Resendyour planZero-risk migration windowre_ key
await ms.domains.update('dom_9f2', {
  provider: 'ses',            // 'cloudflare' | 'ses' | 'resend'
  credentials_secret: 'SES_KEY',
  failover: ['cloudflare'],  // auto-retry elsewhere on 5xx
});

Migrating off Resend? Keep provider: 'resend' on day one, move traffic percentage by percentage, then flip to Cloudflare when the charts look boring. Migration guide →

SMTP relay

host ····· smtp.mailysend.com
port ····· 587 (STARTTLS) · 465 (TLS) · 2587
user ····· mailysend
pass ····· your API key

Rails, Django, Laravel, WordPress, Jira, anything legacy. SMTP sends appear in the same logs, analytics and webhooks as API sends.

Self-hosting the relay
Workers cannot accept inbound TCP, so smtp.<domain>:587 is not part of the Worker deploy — it ships as an OCI container image you run wherever you run containers, pointed at your MailySend API key. If you would rather not operate one, configure your app against Cloudflare’s own relay at smtp.mx.cloudflare.net:465 instead. The trade-off is worth stating plainly: those sends bypass MailySend entirely, so they will not appear in your logs, analytics or webhooks.

SDKs & CLI

One first-party SDK — Node and TypeScript, MIT, with a Resend-compatible shim — and the OpenAPI document every other language generates from, served by your own deployment at /v1/openapi.json so a generated client can never drift from the API it was generated against.

  • Node.js / TypeScriptSHIPPED
    npm i mailysend
  • Python
    openapi-generator-cli · python
  • Go
    openapi-generator-cli · go
  • Ruby
    openapi-generator-cli · ruby
  • PHP (+ Laravel)
    openapi-generator-cli · php
  • Java / Kotlin
    openapi-generator-cli · java
  • .NET / C#
    openapi-generator-cli · csharp
  • Rust
    openapi-generator-cli · rust
  • Elixir
    openapi-generator-cli · elixir
$ npx mailysend login
$ npx mailysend send --to [email protected] --template tpl_welcome
$ npx mailysend tail --tag receipt --status bounced
$ npx mailysend domains verify acme.dev
$ npx mailysend deploy --domain acme.dev # self-host

MCP & agents

Your workspace exposes an MCP endpoint at /mcp with nine tools: search threads, read a message, reply, send, look up delivery status, list domains, read analytics, manage contacts. The two that send mail never send on their first call — they return a confirmation bound to that exact message, which a person approves at /app/approvals. There is no MCP method that approves one, so an agent holding a valid key still cannot approve its own send.

{
  "mcpServers": {
    "mailysend": {
      "url": "https://api.mailysend.com/mcp",
      "headers": { "Authorization": "Bearer ms_live_…" }
    }
  }
}

Errors & rate limits

Errors are typed, human-readable, and always name the fix. Rate limits are a fixed window per workspace, not per key: 600 sends a minute on /v1/emails and 1,000 requests a minute everywhere else. Inbound is never limited.

Error codes and their remedies
CODETYPEWHAT TO DO
401invalid_api_keyCheck the key’s environment
403domain_not_verifiedPublish the DKIM record, then verify
422suppressed_recipientAddress hard-bounced before — see logs
429rate_limitedHonour Retry-After; SDKs do it for you

Ready to send?

Deploy the platform into your own Cloudflare account in about a minute end to end — most of it DNS propagation, which is out of anyone’s hands. MIT licensed, no vendor bill.