One call to send. One honest word back.
Send email, run campaigns, and receive replies through one API. Try a sandbox request below, then follow delivery events on your webhook.
import { RootMail } from "@rootmail/node";
const mail = new RootMail({ apiKey: process.env.ROOTMAIL_API_KEY! });
const msg = await mail.send({
to: "delivered@test.rootmail.dev",
subject: "Your booking is confirmed",
html: "<p>See you Friday.</p>",
idempotencyKey: "demo-8f21c4",
});import os, requests
r = requests.post(
"https://api.rootmail.io/v1/messages",
headers={"Authorization": f"Bearer {os.environ['ROOTMAIL_API_KEY']}"},
json={
"to": "delivered@test.rootmail.dev",
"subject": "Your booking is confirmed",
"html": "<p>See you Friday.</p>",
"idempotency_key": "demo-8f21c4",
},
)body, _ := json.Marshal(map[string]any{
"to": "delivered@test.rootmail.dev",
"subject": "Your booking is confirmed",
"html": "<p>See you Friday.</p>",
"idempotency_key": "demo-8f21c4",
})
req, _ := http.NewRequest("POST",
"https://api.rootmail.io/v1/messages", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("ROOTMAIL_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)curl -X POST https://api.rootmail.io/v1/messages \
-H "Authorization: Bearer $ROOTMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "delivered@test.rootmail.dev",
"subject": "Your booking is confirmed",
"html": "<p>See you Friday.</p>",
"idempotency_key": "demo-8f21c4"
}'{
"id": "msg_8haaujzmx7g3pv5kjlsoon5e",
"object": "message",
"status": "queued",
"to": "delivered@test.rootmail.dev",
"subject": "Your booking is confirmed",
"sandbox": true
}queued is the only status we can honestly return in a request. Everything after it arrives on your webhook, and we draw the difference.
We record what we did, not only what happened.
One append-only trail. Your customers' throttles and DNS drift arrive on the same webhook as your deliveries.
- 11:20:55dkim_rotation_startedsunsetvillas.com · selector rootmail-202608 published
- 11:02:18tenant_dns_driftednorthlakegym.com · DKIM stopped resolving · 6h grace
- 10:31:09tenant_throttledharbourclinic.com · 60/hour · complaints 0.31% · throttle at 0.30%
- 10:12:44suppressedmsg_iq3q… · added to the suppression list
- 10:12:44bouncedmsg_iq3q… · 550 5.1.1 mailbox unavailable
- 10:01:30tenant_warnedharbourclinic.com · complaints 0.12% · warn at 0.10%
- 09:42:10clickedmsg_8haa… · redirect recorded
- 09:41:55openedmsg_8haa… · tracking pixel · undercounts blocked images
- 09:14:07deliveredmsg_8haa… · guest@test.rootmail.dev
- 09:14:03sentmsg_8haa… · provider accepted
- 09:14:02sendingmsg_8haa… · provider ses
- 09:14:02queuedmsg_8haa… · api_key
The hollow node is an open — a pixel fired, and mail clients prefetch images.
Rootmail-Signature · HMAC-SHA256 of timestamp + raw body
The same key twice sends once.
One key. Two requests. One message — and a response header that names the replay.
{
"id": "msg_8haaujzmx7g3pv5kjlsoon5e",
"status": "queued"
}{
"id": "msg_8haaujzmx7g3pv5kjlsoon5e",
"status": "queued"
}sandbox · real API · rate limited · recipients forced to the mailbox simulator
One integration. A branch for every customer you send for.
Their domain, their DKIM key, their reputation — hanging off the one API key you already integrated.
your platform
harbourbookings.com
one API key · one webhook · one integration
01you call· one request
const tenant = await mail.subTenants.create({
name: "Sunset Villas",
sendingDomain: "sunsetvillas.com",
externalId: "customer_8821",
});
// tenant.dns_records → the table beside this one.
await mail.subTenants.verify(tenant.id);02they publish· paste into your own onboarding UI
03and then they are their own sender· sunsetvillas.com · its own key, its own score
- From
- bookings@sunsetvillas.com
- DKIM
- rootmail._domainkey.sunsetvillas.com
- Sending
- sending · within limits
these two clients are invented · the record shapes, thresholds and throttle rate are the real ones
Every branch is scored on its own trailing 7 days. Cross a line — Cliffside is over the complaint threshold — and a 15-minute sweep throttles that branch, sixty sends an hour, re-queued rather than dropped, while the rest of your platform keeps sending at full rate.
Sign it, verify it, then break it.
const proof = await mail.messages.proof(msg.id);
// Anyone can check it — no key, no account.
await fetch("https://api.rootmail.io/v1/proof/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(proof), // { bundle, signature }
});{
"object": "proof_verification",
"valid": true
}{
"message_id": "msg_8haaujzmx7g3pv5kjlsoon5e",
"content_hash": "7242947b3a9e5bebd1e9f0a7…",
"subject": "Your booking is confirmed",
"to": "guest@test.rootmail.dev",
"from": "bookings@sunsetvillas.com",
"status": "delivered",
"workspace_id": "ws_7q2ktdmzr4x9c1n6vbhsyaef",
"created_at": "2026-08-26T09:14:02.184Z",
"audit": [
{ "event": "queued",
"occurred_at": "2026-08-26T09:14:02.184Z", "actor": "api_key" },
{ "event": "sent",
"occurred_at": "2026-08-26T09:14:03.902Z", "actor": "worker" },
{ "event": "delivered",
"occurred_at": "2026-08-26T09:14:07.451Z", "actor": "provider" }
],
"issued_at": "2026-08-26T09:52:41.006Z"
}
POST /v1/proof/verify takes a bundle and a signature, needs no key, and answers someone who does not trust us.
Everything the dashboard does, the API does.
12 of them · every one documented
3,000sends a month, free
500 a day, no card. Sandbox sends never count.
npm i @rootmail/node