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 — your own deployment, plus /v1. The samples below write it as https://your-deployment/v1; there is no hosted MailySend API to point at instead.

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
your-deployment/v1
AUTH
Bearer ms_live_…
FORMAT
JSON · UTF-8

Quickstart

Five steps from an empty Cloudflare account to a delivered email, and not one of them needs a terminal. The first is a button.

1 · Deploy it to your Cloudflare account

One click takes about a minute end to end — most of it DNS propagation, which is out of anyone’s hands. Cloudflare forks the repo and the build creates everything the Worker binds to — the twelve queues, the D1 database, the R2 bucket and the KV namespaces — then hands you back a URL. That URL is your API base URL for every step below.

2 · Claim it before anyone else does

Open the URL Cloudflare gave you. A fresh deployment lands on /setup, where you register a passkey and save ten recovery codes — that is the whole sign-up, and there is no password to leak.

3 · Add your sending domain

Domains → Add. On Cloudflare DNS the records are published and verified in seconds; anywhere else, copy the three below into your provider and hit verify. Until a domain verifies, nothing can leave.

The exact records are in Domains & DNS.

4 · Send

Create a key under Settings → API keys, then call your own deployment. No SDK required — this is the whole API.

curl https://your-deployment/v1/emails \
  -H "Authorization: Bearer $MAILYSEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "MailySend <hello@yourdomain.com>",
    "to": ["user@example.com"],
    "subject": "Hello from the edge",
    "html": "<p>It works.</p>"
  }'

Already calling Resend? Point RESEND_BASE_URL at https://your-deployment/v1 and the official resend client sends here instead — see Migrate. For Next.js, Rails, Django, Python, Go and the rest, the recipe for your stack is in Sending from your framework.

5 · Watch it land

Open Logs in the dashboard you just deployed. Every message is there with its status — accepted, delivered, bounced — and the detail view carries the provider's own response, so a rejection tells you what the receiving server actually said.

Prefer to watch from a shell? npx mailysend tail streams the same events — see the CLI.

If the build command is ever wrong

The queues, database, bucket and namespaces are created by pnpm run build:cf, so if Cloudflare infers a different build command the deploy fails on a binding it cannot resolve. Under Workers → your Worker → Settings → Builds, the two values are pnpm run build:cf and npx wrangler deploy -c apps/app/.output-cf/server/wrangler.json. The -c matters — the config wrangler deploys from is the one Vite generates beside the bundle. If the deploy fails →

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.mx.cloudflare.net ~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://your-deployment/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").

Sending from your framework

Whatever you are already writing, the integration is a library you already have. The base URL is your deployment and the password is your API key; everything else below belongs to somebody else’s documentation.

JavaScript & TypeScript

The SDK takes a key and a base URL, or reads MAILYSEND_API_KEY and MAILYSEND_BASE_URL from the environment. Pass a react-email component as `react` and it is rendered where your code runs — React never reaches the wire.

npm i mailysend
// app/actions.ts — npm i mailysend @react-email/components
'use server'

import { MailySend } from 'mailysend'
import { Welcome } from '@/emails/welcome'

// MAILYSEND_API_KEY and MAILYSEND_BASE_URL=https://your-deployment/v1
const ms = new MailySend()

export async function signUp(email: string) {
  const { id } = await ms.emails.send({
    from: 'Acme <hello@yourdomain.com>',
    to: [email],
    subject: 'Welcome to Acme',
    react: Welcome({ email }),
  })
  return id
}

The official `resend` client resolves its host as `process.env.RESEND_BASE_URL || "https://api.resend.com"`, so every recipe Resend publishes works here with that one variable changed. If you would rather change the import than the environment, `mailysend/compat` exports a `Resend` class with the same `{ data, error }` return shape.

Python, Go, Ruby, PHP, Java, .NET

There is no first-party client for these, and there does not need to be: your deployment serves its own OpenAPI document at /v1/openapi.json, so a generated client can never drift from the API it was generated against. For one endpoint, the HTTP call is shorter than the client.

POST /v1/emails
npx @openapitools/openapi-generator-cli generate \
  -i https://your-deployment/v1/openapi.json \
  -g python \
  -o ./mailysend-client

# -g python | go | ruby | php | java | csharp | rust | elixir | …
# The document comes from your own deployment, so the generated
# client matches the version you are actually running.

Idempotency is worth one extra header in any language: send `Idempotency-Key` and a retry inside 24 hours returns the original email instead of a second copy.

Rails, Django, Laravel, WordPress

These frameworks already have a mailer, and it already speaks SMTP. Point it at the relay with the API key as the password — no gem, no package, no plugin. Sends arrive in the same logs, analytics and webhooks as API sends.

smtp.yourdomain.com:587
# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address:              'smtp.yourdomain.com',
  port:                 587,
  user_name:            'mailysend',
  password:             ENV.fetch('MAILYSEND_API_KEY'),
  authentication:       :plain,
  enable_starttls_auto: true,
}
config.action_mailer.default_options = {
  from: 'Acme <hello@yourdomain.com>',
}

Two things to know before you pick this door. Workers cannot accept inbound TCP, so the relay is an OCI container you run yourself rather than part of the Worker deploy. And SMTP is a one-way conversation: the relay reports acceptance, but opens, clicks and scheduling need the API.

Longer walkthroughs: Send your first email, Migrate from Resend and Choose a sending transport.

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: 'a@example.com', subject: 'Receipt', template_id: 'tpl_r1' },
  { from: f, to: 'b@example.com', 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 (react-email, MJML, Handlebars)

Write react-email components — the same @react-email/components you already have — and pass one as react. The SDK renders it where your code runs and posts the HTML; React never reaches the wire, and there is nothing to push or store.

// emails/LoginCode.tsx
import { Html, Text, Button } from '@react-email/components';

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

// anywhere — npm i mailysend @react-email/components
await ms.emails.send({ from, to, subject: 'Your code', react: LoginCode({ code }) });

@react-email/render is an optional peer dependency, imported only if you pass react — so the SDK still installs with no dependencies for everyone else. The official resend client does the same thing, which is why a component written for Resend renders here unchanged.

Or store them server-side

Push a template instead and it is versioned, previewable and sent by template_id, so marketing can fix a typo without a deploy and roll back instantly. .mjml and .hbs files push the same way.

$ npx mailysend templates push # versioned, instant rollback

Worth knowing what that path is and is not: templates push does not run react-email, it compiles react-email-shaped JSX into a data-only AST — fifteen component names, plain HTML tags, {expr}, {cond && …}, {items.map(…)} and the f.* filters. Anything outside that is refused at push time with a diagnostic pointing at the line. It is a subset, deliberately: nothing is ever evaluated when the mail renders, which is why a stored template cannot become code execution.

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: 'news@yourdomain.com',
  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 });

Throttles, warm-up and what a million-recipient send actually does: Broadcasts at scale →

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' }] } },
  ],
});

One workflow instance per contact, or one per cohort — the choice and its cost: Instance vs cohort →

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 (ticket+8f2@yourdomain.com) and HMAC-signed reply headers keep replies routed to the right object.

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

MX records, routing rules, mailboxes and threading, end to end: Receive email →

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
});

Which one, and when to move: Choose a sending transport. Migrating off Resend? Keep provider: 'resend' on day one, move traffic percentage by percentage, then flip to Cloudflare when the charts look boring — Migrate from Resend →

SMTP relay

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

Rails, Django, Laravel, WordPress, Jira, anything legacy — their own mailer configuration is the whole integration, and it is written out per framework in Sending from your framework. 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 & OpenAPI

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. The per-stack recipes are in Sending from your framework.

  • 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

The CLI, if you prefer a terminal

Nothing above this line needs it. npm i -g mailysend, or run it with npx — the same package as the SDK, so installing one gets you both. It exists for people who would rather stay in a shell, for CI, and for getting back into a deployment you cannot sign in to.

$ npx mailysend login --url https://mail.acme.dev
$ npx mailysend send --from hi@acme.dev --to me@acme.dev --template tpl_welcome
$ npx mailysend tail --status bounced
$ npx mailysend domains verify acme.dev

Two jobs are worth knowing it exists for even if you never use the rest. npx mailysend claim takes ownership of a deployment, or recovers one you are locked out of — the break-glass path when passkeys and recovery codes are both gone. And npx mailysend provision creates the queues for a deploy driven by wrangler or CI rather than by the button, which does it during the build. The wrangler path →

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://your-deployment/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.