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:

TokenLooks likeUse
API keyre_live_…Server-to-server. Sending mail, reading logs, managing domains and suppressions.
Session tokenJWTThe 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.

CodeHTTPMeaning
INVALID_API_KEY401Key is unknown or revoked.
INVALID_CREDENTIALS401Email or password is wrong.
UNAUTHORIZED401Missing or malformed Authorization header.
FORBIDDEN403Authenticated, but not allowed to do this.
ACCOUNT_SUSPENDED403Sending is blocked pending review.
DOMAIN_NOT_FOUND404No such domain on this account.
DOMAIN_NOT_VERIFIED403Domain exists but has not passed DNS verification.
DOMAIN_ALREADY_EXISTS409Already added to this account.
INVALID_DOMAIN422Not a domain you can send from.
INVALID_FROM_ADDRESS422from is not a usable address.
INVALID_RECIPIENT422A recipient address was rejected.
SUPPRESSED_RECIPIENT403One or more recipients are suppressed.
TOO_MANY_RECIPIENTS422Over the per-message recipient cap.
EMAIL_TOO_LARGE413Body exceeds the size limit.
EMAIL_NOT_FOUND404No such message on this account.
EMAIL_QUEUE_FAILED503Accepted but could not be queued. Safe to retry.
RATE_LIMIT_EXCEEDED429Too many HTTP requests.
SENDING_LIMIT_EXCEEDED429Hourly or daily message allowance spent.
VALIDATION_ERROR422Request body failed validation; see details.
INTERNAL_ERROR500Unexpected. 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!"
}
FieldTypeRequiredNotes
fromstringyesuser@domain or Name <user@domain>. The domain must be verified on your account.
tostring or string[]yesUp to EMAIL_MAX_RECIPIENTS across to + cc + bcc (default 50).
subjectstringyesNo line breaks.
htmlstringone ofHTML body.
textstringone ofPlain-text body. Send both for best deliverability.
replyTostring or string[]no
ccstring or string[]no
bccstring or string[]noNever appears in the message headers.
headersobjectnoCustom X-… headers. Reserved headers (To, Bcc, Message-ID, DKIM-Signature, …) are rejected.
tagsobjectnoString values. Shown in the dashboard, useful for filtering.
metadataobjectnoArbitrary JSON, stored and returned. Never sent to the recipient.
scheduledAtstringnoRFC 3339. Omit to send now.
attachmentsobject[]noUp 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 wantUse
A PDF, invoice, ticket, or photo the recipient downloadsAn attachment without cid
An image that appears inside the messageAn attachment with cid, referenced as <img src="cid:…">
An image hosted on your own siteA 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" }
  ]
}
FieldTypeRequiredNotes
filenamestringyesShown to the recipient. Path separators (/, \) and NUL are rejected.
contentstringyesBase64 of the raw bytes. No data: prefix.
contentTypestringnoe.g. application/pdf. Defaults to application/octet-stream.
cidstringnoMakes 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:

WhereHowRenders when images are blocked?
In the body, per messageAttachment with a cid, referenced <img src="cid:logo">Yes — it is part of the message
In the footer, every messageA hosted URL. Write {{logo_url}} to use your uploaded logoNo
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 listNamed addresses
Receivingevery address at the inbound hostonly those, everything else 550
Sendingany local partonly 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/inboundList. ?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}/rawOriginal 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_…
QueryNotes
statusOne of the lifecycle statuses below.
domainIdRestrict to one sending domain.
recipientExact address match.
limit1–100, default 25.
cursornextCursor from the previous page.
{ "data": [ … ], "hasMore": true, "nextCursor": "email_…" }

Email lifecycle

StatusMeaning
queuedAccepted and waiting for a worker.
processingA worker is handing it to the MTA.
sentThe MTA accepted it. Not yet proof of delivery.
deliveredThe receiving server accepted it (dsn=2.x.x).
deferredTemporary failure; the MTA will retry.
bouncedPermanently rejected by the recipient's server.
complainedRecipient marked it as spam.
failedCould 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": ["…"]
}
codeMeaning
MISSING_RECORDNothing published at that name.
MULTIPLE_SPF_RECORDSMore than one SPF record — merge them.
INCLUDE_MISSINGSPF exists but does not authorise us.
KEY_MISMATCHDKIM record exists but the key is not the one we issued.
INVALID_RECORDPresent but malformed.
LOOKUP_FAILEDDNS 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);
  }
});