A drop-in compatible API on infrastructure you control. If you've used a modern transactional email SDK, you already know UserMails — change one base URL and you're sending. Not a developer? See the user guide →
Every send goes through one endpoint — https://api.usermails.com/emails — on a mail server we operate. No third-party relay in the path.
Create a project in the Dashboard and generate a key. Keys are project-scoped and shown once — store it as an environment variable, never in client-side code. Live keys are prefixed um_live_; sandbox keys um_test_.
# keep your key in the environment, not in source export USERMAILS_API_KEY="um_live_xxxxxxxxxxxxxxxxxxxxxxxx"
Verify your sending domain first (DKIM/SPF/DMARC) so mail lands in the inbox. Adding a domain in the Dashboard generates the records and re-checks them for you.
A single authenticated POST with a Resend-shaped payload. The response returns the message id you'll use to track delivery.
curl -X POST https://api.usermails.com/emails \
-H "Authorization: Bearer um_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <onboarding@yourdomain.com>",
"to": ["dev@example.com"],
"subject": "Welcome to Acme",
"html": "<p>Hello from <strong>usermails</strong>.</p>"
}'// 200 OK
{ "id": "4ef9a3b1-8c2d-4a77-9f0e-1b2c3d4e5f60" }The compatibility layer keeps resend-node's method names, parameters, and { data, error } envelope, so your call sites don't move — they just send through UserMails instead.
- import { Resend } from "resend";
+ import { Resend } from "usermails/compat";
const resend = new Resend(process.env.USERMAILS_API_KEY);
const { data, error } = await resend.emails.send({
from: "Acme <onboarding@yourdomain.com>",
to: "dev@example.com",
subject: "Welcome to Acme",
html: "<p>Hello from UserMails.</p>",
});Zero-dependency, typed, and adds verify(), inboxes.*, verifyWebhook(), and renderNotification(). Works in Node 18+, Next.js, and edge runtimes. The package is in this repo; it is not on npm yet — until it is, use the Resend drop-in in 3a or copy the SDK from source.
// after publish: npm install usermails
import { Usermails } from "usermails";
const um = new Usermails(process.env.USERMAILS_API_KEY);
await um.emails.send({
from: "Acme <hi@yourdomain.com>", to: "dev@example.com",
subject: "Welcome", html: "<p>Hello</p>",
});Catch invalid, dead-domain, disposable, role-based, blacklisted, and suppressed addresses up front — protect your sending reputation. Via the SDK or the API.
const { results, summary } = await um.verify([
"real@gmail.com", "admin@acme.com", "user@mailinator.com",
]);
// summary -> { deliverable, risky, undeliverable, unknown }curl -X POST https://api.usermails.com/verify \
-H "Authorization: Bearer um_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"emails":["a@x.com","b@y.com"]}'The UserMails MCP server gives Claude and other agents 22 tools: sending (send_email, send_batch, cancel_email), verification, domains, keys, log search — plus full inbox operation: create_inbox claims name@agents.usermails.com with zero DNS, list_messages / get_message read it, reply_to_message answers in-thread, and wait_for_reply blocks until a human answers. Check inboxes_domains first — it probes live MX readiness so you never hand out an address that can't receive.
// claude_desktop_config.json / .mcp.json
{ "mcpServers": { "usermails": {
"command": "npx", "args": ["-y", "@usermails/mcp"],
"env": { "USERMAILS_API_KEY": "um_live_xxx" }
} } }Point a raw agent at the machine surfaces instead: /llms.txt (index), /llms-full.txt (full context), and /openapi.json on the API (canonical contract for codegen). Same API key, same envelopes.
Fetch a single message by id to see its current status and full lifecycle, or list recent sends for a project-wide log.
curl https://api.usermails.com/emails/4ef9a3b1-8c2d-4a77-9f0e-1b2c3d4e5f60 \ -H "Authorization: Bearer um_live_xxxxxxxx"
{
"id": "4ef9a3b1-8c2d-4a77-9f0e-1b2c3d4e5f60",
"status": "delivered",
"to": ["dev@example.com"],
"subject": "Welcome to Acme",
"events": [
{ "type": "queued", "at": "2026-06-20T18:00:00Z" },
{ "type": "sent", "at": "2026-06-20T18:00:00Z" },
{ "type": "delivered", "at": "2026-06-20T18:00:02Z" }
]
}List the recent log — filter by status, domain, tag, recipient, or date:
curl "https://api.usermails.com/emails?status=bounced&limit=25" \ -H "Authorization: Bearer um_live_xxxxxxxx"
Newest first, limit 1–100 (default 50). A full page returns a next_before cursor — pass it back as ?before= until it comes back null. Date range via from_date / to_date (ISO-8601), subject search via q.
Retrying a request after a timeout? Send an Idempotency-Key header. Keys are unique per project: a repeated key returns the original result instead of sending twice. There is no expiry window — pick a stable key per logical action (order id, user-event id), not a random value per attempt.
curl -X POST https://api.usermails.com/emails \
-H "Authorization: Bearer um_live_xxxxxxxx" \
-H "Idempotency-Key: order-1042-receipt" \
-H "Content-Type: application/json" \
-d '{ "from": "...", "to": "...", "subject": "...", "html": "..." }'Use a key that's stable per logical action (an order id, a user-event id) — not a random value per attempt.
Register an endpoint in the Dashboard and subscribe to event types (email.sent, email.delivered, email.bounced, email.complained, email.opened, email.clicked, email.inbound). UserMails POSTs the payload with the event name in the usermails-event header and an HMAC signature in usermails-signature (format sha256=<hex>), retrying with backoff on failure. Verify the signature against your endpoint's signing secret before trusting the body.
The SDK ships verifyWebhook() — WebCrypto-based, constant-time, and it runs on Node 18+, edge runtimes, and browsers:
import { verifyWebhook, type WebhookEvent } from "usermails";
export async function POST(req: Request) {
const raw = await req.text(); // RAW body — do not parse first
const ok = await verifyWebhook({
payload: raw,
signature: req.headers.get("usermails-signature"),
secret: process.env.USERMAILS_WEBHOOK_SECRET,
});
if (!ok) return new Response("bad signature", { status: 400 });
const event = JSON.parse(raw) as WebhookEvent;
// event.type, event.data.email_id, event.data.to …
return new Response("ok");
}Or verify it yourself, in any language:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, signature, secret) {
const expected = "sha256=" + createHmac("sha256", secret)
.update(rawBody).digest("hex");
if (!signature || signature.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
// in your handler — verify against the RAW request body
const sig = req.headers["usermails-signature"];
if (!verify(rawBody, sig, process.env.WEBHOOK_SECRET)) {
return res.status(400).end();
}Hash the raw bytes of the request body — parsing and re-serializing JSON changes the bytes and breaks the signature. Every event is delivered at least once; key off data.email_id + type (or data.message_id for inbound) to stay idempotent.
Base URL https://api.usermails.com · authenticate with Authorization: Bearer um_live_… on every request.
| Method & path | Description | Notes |
|---|---|---|
| POST /emails | Send a single email (Resend-shaped payload). | Idempotency-Key scheduled_at |
| POST /emails/batch | Send multiple emails in one request. | Per-item results returned. |
| GET /emails/:id | Retrieve a message with its status and event timeline. | queued → sent → delivered / bounced … |
| POST /emails/:id/cancel | Pull back a scheduled or queued message before it sends. | Idempotent. 409 not_cancelable once sent. |
| GET /emails | List recent sends (the project log). | Filter by status, domain, tag, recipient, date. |
| POST /verify | Validate a list before you send. | deliverable / risky / undeliverable |
| GET /domains/health | Live MX/SPF/DKIM/DMARC report for a domain. | Query ?domain= |
| POST /inboxes | Create a mailbox. Omit domain for an instant shared-domain address. | Full-access key. See Inboxes. |
| GET /inboxes GET /inboxes/domains | List this project's inboxes, or the shared domains with live receive-readiness. | Do not hand out an address whose domain is not receiving. |
| GET|PATCH|DELETE /inboxes/:id | Get, rename, or delete an inbox. Messages cascade on delete. | PATCH/DELETE are full-access. |
| GET /inboxes/:id/messages POST /inboxes/:id/send POST …/messages/:mid/reply | Read, send from, and reply in an inbox. Threads at /inboxes/:id/threads. | Either key kind. Reading does not auto-mark seen. |
| GET /livez | Process liveness. Always 200 if the API can answer. | No auth. Alias of /health. |
| GET /readyz | Readiness: Postgres + Redis. 503 if we cannot serve. | No auth. Queue backup is degraded, still 200. |
| GET /version | Deploy identity: running revision. Compare commit against origin/main after a push. | No auth. commit: null means the box doesn't set GIT_COMMIT_SHA yet. |
Dashboard webmail stays at session-auth /app/mailboxes. Same Mailbox/Message store as /inboxes. Inbound webhooks: email.inbound.
POST /inboxes provisions an address. Omit domain for an instant inbox on agents.usermails.com (or pass inbox.usermails.com). No DNS of your own. Pass a verified sending domain of this project to place the inbox there — that domain's MX must already point at mail.usermails.com, on a subdomain if the apex still goes to Google Workspace.
Create / update / delete need a full-access key. Reading, sending, and replying work with either key kind. Plan caps apply org-wide (free 3 / pro 25 / scale 250).
# live receive-readiness per shared domain
curl https://api.usermails.com/inboxes/domains \
-H "Authorization: Bearer um_live_xxxxxxxx"
# claim an address (omit username → agent-<hex>)
curl -X POST https://api.usermails.com/inboxes \
-H "Authorization: Bearer um_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"username":"scout","display_name":"Scout"}'
# send FROM the inbox
curl -X POST https://api.usermails.com/inboxes/INBOX_ID/send \
-H "Authorization: Bearer um_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"to":"user@acme.com","subject":"Hello","text":"Hi from scout"}'import { Usermails } from "usermails";
const um = new Usermails(process.env.USERMAILS_API_KEY!);
const { data: domains } = await um.inboxes.domains();
const ready = domains.find((d) => d.receiving);
if (!ready) throw new Error("shared domain is not receiving yet");
const box = await um.inboxes.create({ username: "scout" });
await um.inboxes.send(box.id, { to: "user@acme.com", subject: "Hi", text: "Hello" });
const { data: messages } = await um.inboxes.messages.list(box.id);Listing is newest-first with a next_before cursor. q is full-text (stemmed, over subject, sender, and body) with substring fallback, so ?q=invoice finds “invoicing” and ?q=inv-2049 still resolves. Filter one free-form label with ?label=vip, and set labels with PATCH …/messages/:mid {"labels":["vip"]} (replaces the whole set — canonicalized server-side, [] clears).
curl "https://api.usermails.com/inboxes/INBOX_ID/messages?q=invoice&label=vip" \
-H "Authorization: Bearer [REDACTED]"
curl -X PATCH https://api.usermails.com/inboxes/INBOX_ID/messages/MID \
-H "Authorization: Bearer [REDACTED]" \
-H "Content-Type: application/json" \
-d '{"labels":["VIP"," receipts "]}' # stored as ["vip","receipts"]Human-in-the-loop sending: the agent stages a send with POST …/inboxes/:id/drafts (same fields as send, plus optional scheduled_at), which fans out email.draft_created — subscribe a human workflow to it. A human approves with POST …/drafts/:did/approve and the send executes through the normal pipeline (approval is single-flight: one approver wins, the rest get 409). POST …/drafts/:did/cancel withdraws it.
curl -X POST https://api.usermails.com/inboxes/INBOX_ID/drafts \
-H "Authorization: Bearer [REDACTED]" \
-H "Content-Type: application/json" \
-d '{"to":"user@acme.com","subject":"Refund approved","text":"..."}'
# → {"id":"...","status":"draft",...} + email.draft_created to your webhook
curl -X POST https://api.usermails.com/inboxes/INBOX_ID/drafts/DRAFT_ID/approve \
-H "Authorization: Bearer [REDACTED]"
# → {"id":"...","email_id":"...","message_id":"..."}Always read receiving from GET /inboxes/domains before handing an address out. A shared domain whose MX is not yet ours can send but cannot receive. MCP inbox tools and an npm publish are not live yet — copy the SDK from this repo or call the HTTP API.
Create addresses in the dashboard (Inbox). Mail is stored when the domain's MX points at mail.usermails.com. Do not change an apex MX that already goes to Google Workspace — use a subdomain. Setup: the user guide.
When a message is stored, subscribed endpoints receive email.inbound (same HMAC as the other events). Unknown recipients are dropped with no bounce. Over-quota returns 507 mailbox_full so the sending MTA defers.
{
"type": "email.inbound",
"created_at": "2026-08-21T15:00:00.000Z",
"data": {
"mailbox": "hello@yourdomain.com",
"to": ["hello@yourdomain.com"],
"from": "Ann <ann@client.com>",
"subject": "Re: Invoice",
"text": "Thanks — paid.",
"html": "<p>Thanks — paid.</p>",
"snippet": "Thanks — paid.",
"message_id": "<abc@client.com>",
"in_reply_to": "<orig@yourdomain.com>"
}
}Send-lifecycle events look like this instead:
{
"type": "email.delivered",
"created_at": "2026-08-21T15:00:02.000Z",
"data": {
"email_id": "4ef9a3b1-8c2d-4a77-9f0e-1b2c3d4e5f60",
"to": ["user@example.com"],
"from": "Acme <hello@yourdomain.com>",
"subject": "Welcome to Acme"
}
}Forwarding copies are sent from the mailbox address (DKIM-aligned). Destinations must click a confirm link first. This is not From-preserving alias forwarding — that breaks DMARC without ARC.
GET /livez (also /health) means the process is up. It stays 200 even if Postgres is down — putting dependencies on liveness turns a blip into a container kill loop.
GET /readyz means we can serve: Postgres SELECT 1 and Redis PING. Either failing → 503 and "status":"down". A backed-up send queue is "degraded" with HTTP 200 so monitors can warn without paging "down".
curl -s https://api.usermails.com/readyz
# { "status": "ok", "service": "usermails-api",
# "checks": { "postgres": { "ok": true, "ms": 4 },
# "redis": { "ok": true, "ms": 1 },
# "send_queue": { "ok": true, "waiting": 0, "failed": 0 } } }Component view: usermails.com/status. Better Stack paste list (HTTP, TCP :25, worker heartbeats): infra/observability.md in the repo.
Failures return JSON { statusCode, name, message }. The SDK throws UsermailsError with .status, .code, .message.
| HTTP | name | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid API key. |
| 402 | plan_limit_exceeded | Monthly send cap for the org's plan. |
| 403 | domain_not_verified | From domain is not a verified sending domain of this project. |
| 403 | email_unverified | Sending from usermails.com before the org owner verifies their email. |
| 403 | forbidden | Role cannot perform that dashboard write. |
| 404 | not_found | Unknown resource. |
| 409 | not_cancelable | Cancel after the message left queued/scheduled. |
| 422 | validation_error | Bad payload — including scheduled_at without an explicit offset, or a past time. |
| 422 | suppressed_recipient | Address is on the project suppression list. |
| 429 | rate_limit_exceeded | Too many requests on this key/IP. |
| 507 | mailbox_full | Hosted mailbox over quota — inbound defers, does not silently drop. |
scheduled_at must be ISO-8601 with an explicit Z or ±HH:MM offset, and strictly in the future. Natural language is rejected.
UserMails accepts Resend's payloads and responses, so your code barely moves. Swap the import — every emails.send() call, payload shape, and { data, error } response stays exactly as it is, now sending on infrastructure you control.
// before
import { Resend } from "resend";
// after — same methods, same payloads, same { data, error }
import { Resend } from "usermails/compat";
const resend = new Resend(process.env.USERMAILS_API_KEY);
await resend.emails.send({ from, to, subject, html });Bring your domains, broadcast templates, and contacts across with one read-only command:
npx usermails-migrate resend --from re_xxxxxxxx
Run both in parallel during cutover: keep Resend live while you verify your domain and watch sends land in the UserMails log, then flip 100%. No payload migration, no data backfill.
Spin up a project, verify a domain, and put a live transactional email through in minutes.