Reference
API reference
Base URL: https://mail-api.scanmypass.com
All requests and responses are JSON. All timestamps are RFC 3339 in UTC.
Quickstart
Four steps from nothing to a delivered message.
1. Add your sending domain — dashboard → Domains → Add domain. You get four DNS records; publish them at your registrar.
2. Verify — click Verify DNS. SPF and DKIM must both pass before the domain can send. DMARC and the bounce CNAME are recommended, not blocking.
3. Create an API key — dashboard → API keys → Create. It is shown once and stored only as a SHA-256 hash, so copy it immediately. Keep it in an environment variable, never in client-side code or a git repo.
export MAIL_API_KEY='re_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
4. Send:
curl -X POST https://mail-api.scanmypass.com/v1/emails \
-H "Authorization: Bearer $MAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": ["you@gmail.com"],
"subject": "First message",
"html": "<h1>It works</h1>",
"text": "It works"
}'
{ "id": "email_9f3k2m1x8b7c4d5e6a0z", "status": "queued", "createdAt": "…" }
That 202 means queued, not delivered. Track the outcome with
GET /v1/emails/{id}, the dashboard's Emails page, or a
webhook.
To attach a PDF or embed an image, see Attachments.
Authentication
Every endpoint except /health requires a bearer token.
Authorization: Bearer re_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
There are two kinds of token:
| Token | Looks like | Use |
|---|---|---|
| API key | re_live_… | Server-to-server. Sending mail, reading logs, managing domains and suppressions. |
| Session token | JWT | The dashboard. Also required for creating or revoking API keys. |
An API key cannot create another API key. If a key leaks, revoking it is enough to contain the damage — it could not have minted others.
Keys are stored as SHA-256 hashes. The secret is shown once at creation and cannot be recovered.
Errors
Every failure has the same shape:
{
"error": {
"code": "DOMAIN_NOT_VERIFIED",
"message": "example.com has not passed DNS verification. SPF and DKIM must both verify before it can send.",
"details": {}
}
}
Switch on code; message is written for humans and may change.
| Code | HTTP | Meaning |
|---|---|---|
INVALID_API_KEY | 401 | Key is unknown or revoked. |
INVALID_CREDENTIALS | 401 | Email or password is wrong. |
UNAUTHORIZED | 401 | Missing or malformed Authorization header. |
FORBIDDEN | 403 | Authenticated, but not allowed to do this. |
ACCOUNT_SUSPENDED | 403 | Sending is blocked pending review. |
DOMAIN_NOT_FOUND | 404 | No such domain on this account. |
DOMAIN_NOT_VERIFIED | 403 | Domain exists but has not passed DNS verification. |
DOMAIN_ALREADY_EXISTS | 409 | Already added to this account. |
INVALID_DOMAIN | 422 | Not a domain you can send from. |
INVALID_FROM_ADDRESS | 422 | from is not a usable address. |
INVALID_RECIPIENT | 422 | A recipient address was rejected. |
SUPPRESSED_RECIPIENT | 403 | One or more recipients are suppressed. |
TOO_MANY_RECIPIENTS | 422 | Over the per-message recipient cap. |
EMAIL_TOO_LARGE | 413 | Body exceeds the size limit. |
EMAIL_NOT_FOUND | 404 | No such message on this account. |
EMAIL_QUEUE_FAILED | 503 | Accepted but could not be queued. Safe to retry. |
RATE_LIMIT_EXCEEDED | 429 | Too many HTTP requests. |
SENDING_LIMIT_EXCEEDED | 429 | Hourly or daily message allowance spent. |
VALIDATION_ERROR | 422 | Request body failed validation; see details. |
INTERNAL_ERROR | 500 | Unexpected. Logged on our side. |
Rate limits
Two independent layers:
HTTP requests — per IP, default 600/minute. Credential endpoints
(/v1/auth/login, /v1/auth/register) allow 10 attempts per 15 minutes per IP.
Exceeding either returns RATE_LIMIT_EXCEEDED.
Messages — counted per recipient, not per API call. A message to four
people costs four. Enforced against the user, the sending domain and the API key
simultaneously; the first to run out rejects the request with
SENDING_LIMIT_EXCEEDED.
Defaults are RATE_LIMIT_EMAILS_PER_HOUR=100 and
RATE_LIMIT_EMAILS_PER_DAY=1000. New accounts start lower
(NEW_ACCOUNT_EMAILS_PER_HOUR=20) for the first 24 hours.
A rejected request consumes no quota.
Send an email
POST /v1/emails
Returns 202 Accepted as soon as the message is durably queued. The API never
waits for SMTP delivery — delivery to a remote mail server can take seconds or
minutes, and that must not be your request's latency. Watch the status with
GET /v1/emails/{id} or a webhook.
Request
{
"from": "hello@example.com",
"to": ["user@gmail.com"],
"subject": "Welcome",
"html": "<h1>Welcome!</h1>",
"text": "Welcome!"
}
| Field | Type | Required | Notes |
|---|---|---|---|
from | string | yes | user@domain or Name <user@domain>. The domain must be verified on your account. |
to | string or string[] | yes | Up to EMAIL_MAX_RECIPIENTS across to + cc + bcc (default 50). |
subject | string | yes | No line breaks. |
html | string | one of | HTML body. |
text | string | one of | Plain-text body. Send both for best deliverability. |
replyTo | string or string[] | no | |
cc | string or string[] | no | |
bcc | string or string[] | no | Never appears in the message headers. |
headers | object | no | Custom X-… headers. Reserved headers (To, Bcc, Message-ID, DKIM-Signature, …) are rejected. |
tags | object | no | String values. Shown in the dashboard, useful for filtering. |
metadata | object | no | Arbitrary JSON, stored and returned. Never sent to the recipient. |
scheduledAt | string | no | RFC 3339. Omit to send now. |
attachments | object[] | no | Up to 10 files. See Attachments. |
Response
{
"id": "email_9f3k2m1x8b7c4d5e6a0z",
"status": "queued",
"createdAt": "2026-08-16T12:00:00.000Z"
}
What happens before you get that ID
validate body → authenticate → resolve sending domain → check it is verified
→ screen recipients → check suppression list → check size → consume quota
→ write the email record → enqueue → respond
Anything that fails returns an error and consumes nothing.
Attachments
Attach up to 10 files per message by passing base64-encoded bytes in
attachments. There are two distinct things you might want, and they are not the
same mechanism:
| You want | Use |
|---|---|
| A PDF, invoice, ticket, or photo the recipient downloads | An attachment without cid |
| An image that appears inside the message | An attachment with cid, referenced as <img src="cid:…"> |
| An image hosted on your own site | A plain <img src="https://…"> in your HTML — no attachment at all |
{
"from": "billing@example.com",
"to": ["customer@gmail.com"],
"subject": "Your invoice",
"html": "<p><img src=\"cid:logo\" width=\"140\" alt=\"Acme\"></p><p>Invoice attached.</p>",
"attachments": [
{ "filename": "invoice.pdf", "content": "JVBERi0xLjQK…", "contentType": "application/pdf" },
{ "filename": "logo.png", "content": "iVBORw0KGgo…", "contentType": "image/png", "cid": "logo" }
]
}
| Field | Type | Required | Notes |
|---|---|---|---|
filename | string | yes | Shown to the recipient. Path separators (/, \) and NUL are rejected. |
content | string | yes | Base64 of the raw bytes. No data: prefix. |
contentType | string | no | e.g. application/pdf. Defaults to application/octet-stream. |
cid | string | no | Makes the part an inline image referenced by <img src="cid:VALUE">. Must be unique within the message. |
Limits
- 10 attachments per message.
- Total size — bodies plus decoded attachment bytes — must stay under
EMAIL_MAX_SIZE_BYTES(default 10 MB). Base64 inflates the JSON you send by about a third, but the limit is checked against the real bytes. - Receivers apply their own, often smaller, caps: Gmail rejects above 25 MB and many corporate gateways stop at 10 MB. Large files belong behind a link.
There is no url option, on purpose
Some APIs let you pass a URL and fetch it server-side. This one does not: fetching a caller-supplied URL from inside our network is server-side request forgery — it would reach the cloud metadata endpoint, Postgres and Redis. Read the file in your own application and send the bytes.
Examples
cURL — base64 the file first:
curl -X POST https://mail-api.scanmypass.com/v1/emails \
-H "Authorization: Bearer $MAIL_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg pdf "$(base64 -w0 invoice.pdf)" \
--arg png "$(base64 -w0 logo.png)" \
'{
from: "billing@example.com",
to: ["customer@gmail.com"],
subject: "Your invoice",
html: "<p><img src=\"cid:logo\" width=\"140\" alt=\"Acme\"></p><p>Invoice attached.</p>",
attachments: [
{filename: "invoice.pdf", content: $pdf, contentType: "application/pdf"},
{filename: "logo.png", content: $png, contentType: "image/png", cid: "logo"}
]
}')"
Node.js (SDK):
import { readFileSync } from "node:fs";
import { MailClient } from "@yourservice/node";
const mail = new MailClient({
apiKey: process.env.MAIL_API_KEY,
baseUrl: "https://mail-api.scanmypass.com",
});
await mail.emails.send({
from: "billing@example.com",
to: "customer@gmail.com",
subject: "Your invoice",
html: `<p><img src="cid:logo" width="140" alt="Acme"></p><p>Invoice attached.</p>`,
attachments: [
{
filename: "invoice.pdf",
content: readFileSync("./invoice.pdf").toString("base64"),
contentType: "application/pdf",
},
{
filename: "logo.png",
content: readFileSync("./logo.png").toString("base64"),
contentType: "image/png",
cid: "logo",
},
],
});
Python:
import base64, os, requests
def attach(path, content_type, cid=None):
with open(path, "rb") as fh:
part = {
"filename": os.path.basename(path),
"content": base64.b64encode(fh.read()).decode(),
"contentType": content_type,
}
if cid:
part["cid"] = cid
return part
requests.post(
"https://mail-api.scanmypass.com/v1/emails",
headers={"Authorization": f"Bearer {os.environ['MAIL_API_KEY']}"},
json={
"from": "billing@example.com",
"to": ["customer@gmail.com"],
"subject": "Your invoice",
"html": '<p><img src="cid:logo" width="140" alt="Acme"></p><p>Invoice attached.</p>',
"attachments": [
attach("invoice.pdf", "application/pdf"),
attach("logo.png", "image/png", cid="logo"),
],
},
timeout=30,
).raise_for_status()
PHP:
<?php
function attach(string $path, string $type, ?string $cid = null): array {
$part = [
'filename' => basename($path),
'content' => base64_encode(file_get_contents($path)),
'contentType' => $type,
];
if ($cid !== null) $part['cid'] = $cid;
return $part;
}
$payload = [
'from' => 'billing@example.com',
'to' => ['customer@gmail.com'],
'subject' => 'Your invoice',
'html' => '<p><img src="cid:logo" width="140" alt="Acme"></p><p>Invoice attached.</p>',
'attachments' => [
attach('invoice.pdf', 'application/pdf'),
attach('logo.png', 'image/png', 'logo'),
],
];
$ch = curl_init('https://mail-api.scanmypass.com/v1/emails');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('MAIL_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
echo curl_exec($ch);
Putting an image in an email
Three mechanisms, and the right one depends on where the image goes:
| Where | How | Renders when images are blocked? |
|---|---|---|
| In the body, per message | Attachment with a cid, referenced <img src="cid:logo"> | Yes — it is part of the message |
| In the footer, every message | A hosted URL. Write {{logo_url}} to use your uploaded logo | No |
| Anywhere, hosted by you | <img src="https://yoursite.com/logo.png"> | No |
Prefer cid: for anything per-message. Most clients block remote images until
the reader clicks "display images", so a hotlinked logo shows nothing on first
open. An embedded part renders immediately.
A footer image has to be a URL. The footer is injected into every message, so
there is no per-message attachment to point a cid: at. The platform already hosts
your uploaded logo, so a footer can reference it with a token:
<img src="{{logo_url}}" width="120" alt="Acme" style="display:block;border:0">
That resolves to https://mail-api.scanmypass.com/public/branding/<your id>/logo
at send time, and to nothing at all when no logo is uploaded — so the footer
degrades to no image rather than a broken one.
Always set width and alt. Blocked images show the alt text, and Outlook
guesses badly without an explicit width. style="display:block;border:0" avoids
the gap and border some clients add.
What the recipient's client receives
The parts are assembled so that inline images render and downloads still appear:
multipart/mixed
├── multipart/related
│ ├── text/html ← your html
│ └── image/png Content-ID: <logo> ← inline, referenced by cid:logo
└── application/pdf ← Content-Disposition: attachment
Two things that catch people out
Most clients block remote images by default. An <img src="https://…"> shows
nothing until the reader clicks "display images". An inline cid: image is part of
the message and usually renders straight away — that is the reason to prefer it for
a logo.
Always set alt and width on images in email. Blocked images show the alt
text, and Outlook needs an explicit width or it guesses badly.
Receiving mail
Off by default. Switch it on per domain and mail addressed to that domain arrives
in the Inbox and fires an email.received webhook.
PATCH /v1/domains/{id}
{ "receiveEnabled": true, "inboundLabel": "inbox" }
You then publish one record:
inbox.example.com. 3600 IN MX 10 mail.scanmypass.com.
Every address at that host works — anything@inbox.example.com — there are no
mailboxes to create.
Why the default is a subdomain
inboundLabel defaults to inbox, not the apex, and that is deliberate. An MX
record at the apex replaces wherever the domain currently receives mail. If you
have a mailbox on Google Workspace, Microsoft 365 or your host, pointing the apex
here diverts it and that mail stops arriving. A subdomain leaves your existing
mail completely untouched.
"inboundLabel": "@" is accepted if you genuinely want the apex. The dashboard
spells out the consequence before you do.
Creating addresses
By default a verified domain sends from any local part and receives at every address. Naming the addresses that exist narrows both:
PATCH /v1/domains/{id}
{ "addresses": ["info", "support"], "restrictSenders": true }
| Empty list | Named addresses | |
|---|---|---|
| Receiving | every address at the inbound host | only those, everything else 550 |
| Sending | any local part | only those, when restrictSenders is set |
restrictSenders is a separate switch and off by default, because turning it on
changes what an existing integration may send as — anything mailing from
no-reply@ starts getting 422 INVALID_FROM_ADDRESS. With it on, a leaked API key
cannot send as an address you never created.
{
"error": {
"code": "INVALID_FROM_ADDRESS",
"message": "ceo@example.com is not an address on example.com. Allowed: info@example.com, support@example.com."
}
}
Local parts only — the domain half comes from the domain itself. Changes reach the MTA within a few seconds.
Return-path hosts stay catch-all regardless, because a bounce address is
bounce+<message id>@… and the local part differs every time.
Endpoints
GET /v1/inbound | List. ?unread=true, ?search=, ?domainId=, ?cursor=, ?limit= |
GET /v1/inbound/{id} | Full message. Opening it marks it read. |
PATCH /v1/inbound/{id} | { "read": false } to mark unread again |
DELETE /v1/inbound/{id} | Deletes the message and its attachments |
GET /v1/inbound/{id}/attachments/{attachmentId} | The file itself |
GET /v1/inbound/{id}/raw | Original bytes, as message/rfc822 |
Authentication results are reported, not enforced
Each message carries spfResult, dkimResult, dmarcResult and an
authenticated flag that is true only when all three pass.
Mail that fails is still delivered to you. Refusing it at the SMTP layer would
drop legitimate mail from badly configured senders, so the verdict is recorded and
the judgement left to you. null means the check did not run — not that it
failed.
Treat an unauthenticated sender as unverified. Anyone can put any address in a
From header; only a passing DMARC check makes it meaningful.
Handling the HTML safely
html is returned exactly as it arrived, script tags and all — it is a stranger's
markup, and sanitising it server-side would be a lie about what you received. The
dashboard renders it in an iframe with sandbox="" and srcDoc, so nothing can
execute and it has no access to the page's origin. Do the same, or show text.
Attachments
Up to 25 per message are stored. Filenames are stripped of path separators before
storage, and downloads are served with Content-Disposition: attachment and
nosniff — never render an inbound attachment inline in your own origin.
Retrieve an email
GET /v1/emails/{id}
Returns the message with its recipients and full event timeline:
{
"id": "email_9f3k2m1x8b7c4d5e6a0z",
"status": "delivered",
"subject": "Welcome",
"fromAddress": "hello@example.com",
"smtpResponse": "250 2.0.0 Ok: queued as 4A2B3C1D",
"attempts": 1,
"createdAt": "2026-08-16T12:00:00.000Z",
"sentAt": "2026-08-16T12:00:01.400Z",
"deliveredAt": "2026-08-16T12:00:03.900Z",
"recipients": [
{ "email": "user@gmail.com", "type": "to", "status": "delivered", "smtpResponse": "250 2.0.0 OK" }
],
"events": [
{ "type": "queued", "message": "Accepted by the API", "createdAt": "…" },
{ "type": "processing", "message": "Attempt 1", "createdAt": "…" },
{ "type": "sent", "message": "250 2.0.0 Ok: queued as 4A2B3C1D", "createdAt": "…" },
{ "type": "delivered", "message": "250 2.0.0 OK", "createdAt": "…" }
]
}
List emails
GET /v1/emails?status=bounced&limit=25&cursor=email_…
| Query | Notes |
|---|---|
status | One of the lifecycle statuses below. |
domainId | Restrict to one sending domain. |
recipient | Exact address match. |
limit | 1–100, default 25. |
cursor | nextCursor from the previous page. |
{ "data": [ … ], "hasMore": true, "nextCursor": "email_…" }
Email lifecycle
| Status | Meaning |
|---|---|
queued | Accepted and waiting for a worker. |
processing | A worker is handing it to the MTA. |
sent | The MTA accepted it. Not yet proof of delivery. |
delivered | The receiving server accepted it (dsn=2.x.x). |
deferred | Temporary failure; the MTA will retry. |
bounced | Permanently rejected by the recipient's server. |
complained | Recipient marked it as spam. |
failed | Could not be sent at all, or retries were exhausted. |
queued → processing → sent → delivered
└──→ bounced / complained
processing ──→ deferred ──→ sent (retry succeeded)
└──→ failed (retries exhausted)
A message with several recipients reports the worst outcome: one bounce
among four deliveries shows as bounced, because that is the number your
reputation is judged on. Per-recipient detail is in the recipients array.
Domains
Add a domain
POST /v1/domains
{ "domain": "example.com" }
Generates a dedicated 2048-bit DKIM key pair and returns the DNS records to publish. The private key is encrypted at rest and never appears in any response.
{
"id": "dom_a1b2c3…",
"domain": "example.com",
"status": "PENDING",
"records": [
{
"purpose": "spf",
"type": "TXT",
"host": "@",
"name": "example.com",
"value": "v=spf1 include:mail.yourservice.com ~all",
"ttl": 3600,
"required": true,
"description": "…"
},
{
"purpose": "dkim",
"type": "TXT",
"host": "selector1._domainkey",
"name": "selector1._domainkey.example.com",
"value": "v=DKIM1; k=rsa; p=MIIBIjANBgkq…",
"required": true
},
{ "purpose": "dmarc", "type": "TXT", "host": "_dmarc", "value": "v=DMARC1; p=none;", "required": false },
{ "purpose": "return_path", "type": "CNAME", "host": "bounce", "value": "bounce.yourservice.com", "required": false }
]
}
Verify DNS
POST /v1/domains/{domainId}/verify
{
"verified": true,
"domain": "example.com",
"spf": { "verified": true },
"dkim": { "verified": true },
"dmarc": { "verified": true },
"returnPath": { "verified": false },
"required": { "spf": true, "dkim": true, "dmarc": false, "returnPath": false }
}
SPF and DKIM are required. DMARC and the bounce CNAME are checked and reported but do not block sending — see dns.md for why.
When something is wrong you get the diagnosis, not just a false:
{
"verified": false,
"spf": {
"verified": false,
"code": "MULTIPLE_SPF_RECORDS",
"message": "Multiple SPF records detected. A domain may publish only one — receivers treat two as a permanent error. You must merge them into a single SPF record that includes `include:mail.yourservice.com`.",
"found": ["v=spf1 include:_spf.google.com ~all", "v=spf1 include:mail.yourservice.com ~all"],
"expected": "v=spf1 include:mail.yourservice.com ~all"
},
"nextSteps": ["…"]
}
code | Meaning |
|---|---|
MISSING_RECORD | Nothing published at that name. |
MULTIPLE_SPF_RECORDS | More than one SPF record — merge them. |
INCLUDE_MISSING | SPF exists but does not authorise us. |
KEY_MISMATCH | DKIM record exists but the key is not the one we issued. |
INVALID_RECORD | Present but malformed. |
LOOKUP_FAILED | DNS query failed or timed out. Usually transient. |
Verified domains are re-checked every few hours. If a required record disappears, the domain loses verified status and sending stops.
Other domain endpoints
GET /v1/domains
GET /v1/domains/{domainId}
DELETE /v1/domains/{domainId} # 409 if the domain has sending history
API keys
Requires a dashboard session token.
POST /v1/api-keys
{ "name": "Production backend", "mode": "live" }
{
"id": "key_…",
"name": "Production backend",
"keyPrefix": "re_live_a1b2",
"token": "re_live_a1b2c3d4e5f6…",
"warning": "Store this key now. It is hashed on our side and cannot be shown again."
}
GET /v1/api-keys
DELETE /v1/api-keys/{keyId} # revoke; effective immediately
Suppressions
Addresses on this list are rejected before a message is queued. Hard bounces and spam complaints are added automatically.
GET /v1/suppressions?email=…&type=hard_bounce&limit=25
POST /v1/suppressions { "email": "user@example.com", "type": "manual", "reason": "Requested removal" }
DELETE /v1/suppressions/{id}
Types: hard_bounce, soft_bounce, complaint, manual, unsubscribe.
Suppression lists are per-account. One customer's bounce never blocks another's.
Webhooks
POST /v1/webhooks
{
"url": "https://example.com/webhooks/email",
"events": ["email.sent", "email.delivered", "email.bounced", "email.failed", "email.complained"]
}
The response contains a secret shown once.
Payload
{
"event": "email.delivered",
"emailId": "email_9f3k2m1x8b7c4d5e6a0z",
"timestamp": "2026-08-16T12:00:00Z",
"recipient": "user@example.com"
}
email.bounced and email.failed also carry reason and statusCode.
Verifying the signature
Every request carries:
X-Mail-Signature: t=1755345600,v1=6f2a…
X-Mail-Event: email.delivered
X-Mail-Delivery-Id: 0f1e…
Compute HMAC-SHA256(secret, "<t>.<raw body>") and compare in constant time.
Use the raw body — re-serialising parsed JSON changes the bytes.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const timestamp = Number(parts.t);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; // replay window
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
The official SDK exports verifyWebhook() which does exactly this.
Delivery guarantees
At-least-once. Respond 2xx within 10 seconds; anything else is retried with
exponential backoff up to WEBHOOK_MAX_ATTEMPTS (default 6). An endpoint that
fails 20 times in a row is disabled automatically. Deduplicate on
X-Mail-Delivery-Id.
GET /v1/webhooks
GET /v1/webhooks/{id} # includes the last 25 delivery attempts
PATCH /v1/webhooks/{id}
DELETE /v1/webhooks/{id}
Statistics
GET /v1/stats/overview?days=30
{
"totals": { "sent": 12483, "delivered": 11920, "bounced": 382, "failed": 181, "complained": 0, "queued": 0 },
"rates": { "delivery": 95.49, "bounce": 3.06, "complaint": 0 },
"daily": [{ "day": "2026-08-01", "status": "delivered", "count": 412 }],
"usage": { "hour": { "used": 12, "limit": 100 }, "day": { "used": 340, "limit": 1000 } }
}
Rates are calculated over messages that reached a terminal state, so a queue backlog does not distort them.
GET /v1/stats/health-signals
Returns anything wrong with your sending: elevated bounce rate, complaint rate, failure rate, or an unusual volume spike.
Account
POST /v1/auth/register { "email": "…", "password": "…", "name": "…" }
POST /v1/auth/login { "email": "…", "password": "…" }
GET /v1/auth/me
POST /v1/auth/change-password { "currentPassword": "…", "newPassword": "…" }
GET /v1/settings
PATCH /v1/settings/limits { "emailsPerHour": 50 }
You can lower your own sending limits — a useful blast-radius control for a leaked key. Raising them above the platform default is an operator action.
Health
GET /health # liveness: is the process up? No dependencies checked.
GET /ready # readiness: database, Redis and MTA reachable?
/ready returns 503 when Postgres or Redis is unreachable, and
{"status":"degraded"} with 200 when only the MTA is down — the API can still
accept and queue mail.
Examples
cURL
curl -X POST https://api.yourservice.com/v1/emails \
-H "Authorization: Bearer $MAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@example.com",
"to": ["user@gmail.com"],
"subject": "Welcome",
"html": "<h1>Welcome!</h1>",
"text": "Welcome!"
}'
JavaScript (fetch)
const response = await fetch("https://api.yourservice.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "hello@example.com",
to: ["user@gmail.com"],
subject: "Hello",
html: "<h1>Hello World</h1>",
}),
});
if (!response.ok) {
const { error } = await response.json();
throw new Error(`${error.code}: ${error.message}`);
}
const { id } = await response.json();
Node.js (official SDK)
import { MailClient } from "@yourservice/node";
const mail = new MailClient({
apiKey: process.env.MAIL_API_KEY,
baseUrl: "https://api.yourservice.com",
});
const { id } = await mail.emails.send({
from: "hello@example.com",
to: "customer@gmail.com",
subject: "Welcome",
html: "<h1>Welcome!</h1>",
});
const email = await mail.emails.get(id);
console.log(email.status);
Python
import os
import requests
response = requests.post(
"https://api.yourservice.com/v1/emails",
headers={
"Authorization": f"Bearer {os.environ['MAIL_API_KEY']}",
"Content-Type": "application/json",
},
json={
"from": "hello@example.com",
"to": ["user@gmail.com"],
"subject": "Welcome",
"html": "<h1>Welcome!</h1>",
},
timeout=30,
)
if not response.ok:
error = response.json()["error"]
raise RuntimeError(f"{error['code']}: {error['message']}")
print(response.json()["id"])
PHP
<?php
$payload = json_encode([
'from' => 'hello@example.com',
'to' => ['user@gmail.com'],
'subject' => 'Welcome',
'html' => '<h1>Welcome!</h1>',
]);
$ch = curl_init('https://api.yourservice.com/v1/emails');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('MAIL_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $payload,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
$error = json_decode($response, true)['error'];
throw new RuntimeException("{$error['code']}: {$error['message']}");
}
echo json_decode($response, true)['id'];
Handling a webhook (Express)
import express from "express";
import { verifyWebhook } from "@yourservice/node";
const app = express();
// The raw body is required — parsed JSON re-serialises differently.
app.post("/webhooks/email", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyWebhook({
payload: req.body.toString("utf8"),
signature: req.get("X-Mail-Signature"),
secret: process.env.MAIL_WEBHOOK_SECRET,
});
} catch {
return res.status(400).send("bad signature");
}
// Acknowledge fast, then do the work out of band.
res.sendStatus(200);
if (event.event === "email.bounced") {
markAddressUndeliverable(event.recipient);
}
});