Mail API
Register sending domains, generate DKIM keys, send signed mail, and receive inbound mail as webhooks. Every endpoint returns JSON.
Overview
All paths are relative to your deployment. Requests and responses are JSON;
send Content-Type: application/json on anything with a body.
Base URL
http://localhost:3000 # default deployment
Getting to a first send takes four steps, in this order:
| Step | Endpoint | What it gives you |
|---|---|---|
| 1 | POST /v1/domains | A DKIM keypair and the DNS records to publish |
| 2 | — (publish DNS) | Done at your DNS provider, not here |
| 3 | POST …/verify | Confirmation that DNS matches the key held here |
| 4 | POST /v1/messages | A signed, delivered message |
Do not skip step 3. A signature receivers cannot verify reads
to them like a forgery attempt — worse than sending unsigned. DNS also takes a
few minutes to propagate, so not found immediately after publishing
usually means "wait", not "broken".
Authentication
Every authenticated request carries a bearer token:
Authorization: Bearer <key or token>
Three credential types, in decreasing order of privilege:
| Credential | Prefix | Scope | Use for |
|---|---|---|---|
| Admin key | mk_… | admin | Managing domains and credentials |
| Send key | sk_… | send | Applications that only send mail |
| Session token | — | admin | The browser portal; expires after 8 hours |
The ADMIN_API_KEY from the environment always works as an admin
credential — without it there would be no way to mint the first key on a fresh
install.
Give applications a send key, not an admin key. A leaked send key can deliver mail; a leaked admin key can read every DKIM private key you hold and mint further credentials.
Errors
Errors carry a stable machine-readable error code and a
human-readable message. Validation failures add an
issues array naming the offending fields.
{
"error": "invalid_request",
"message": "The message could not be accepted as written.",
"issues": [
{ "field": "(body)", "problem": "Provide text, html, or both — a message needs a body." }
]
}
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed or missing fields |
| 400 | unknown_sender_domain | The from domain is not registered |
| 401 | missing_credentials | No Authorization header |
| 401 | invalid_credentials | Key unrecognised, revoked, or token expired |
| 403 | insufficient_scope | Send key used on an admin endpoint |
| 404 | not_found | No such domain, key, or message |
| 409 | domain_exists | Domain already registered |
| 502 | delivery_failed | The mail server refused the message |
Auth endpoints
post/v1/auth/loginno auth
Exchanges the portal password for a session token valid for eight hours. Failed attempts are delayed by 750 ms to make brute force impractical.
Request
| Field | Description | |
|---|---|---|
| password | required | The value of PORTAL_PASSWORD, or the admin key if unset |
# request curl -X POST $BASE/v1/auth/login \ -H 'content-type: application/json' \ -d '{"password":"your-portal-password"}' # 200 { "token": "eyJzdWI…", "expires_at": "2026-08-24T06:12:00.000Z" }
get/v1/auth/meadmin
Confirms a token or key is still valid. Used by the portal to decide whether to show the login screen.
{ "authenticated": true, "name": "portal:portal", "scope": "admin" }
Domains
post/v1/domainsadmin
Registers a domain and generates a 2048-bit RSA DKIM keypair for it. The private key is stored here and never leaves the server; the response carries the public half as ready-to-publish DNS records.
Request
| Field | Description | |
|---|---|---|
| domain | required | Domain to send from, e.g. example.com |
| selector | optional | DKIM selector. Defaults to api |
| inbound_webhook_url | optional | Set now, or later via the webhook endpoint |
# request curl -X POST $BASE/v1/domains \ -H "Authorization: Bearer $ADMIN_KEY" \ -H 'content-type: application/json' \ -d '{"domain":"example.com"}' # 201 { "domain": "example.com", "selector": "api", "dkim_verified_at": null, "dns_records": [ { "type":"TXT", "name":"api._domainkey.example.com", "value":"v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBg…" }, { "type":"TXT", "name":"example.com", "value":"v=spf1 ip4:203.0.113.10 ~all" }, { "type":"TXT", "name":"_dmarc.example.com", "value":"v=DMARC1; p=none; adkim=r; aspf=r" } ] }
Already have an SPF record? Merge, never add a second one.
A domain with two SPF records is a permanent error and every receiver will treat
it as a failure. Add ip4:… to the existing record instead.
get/v1/domainsadmin
Lists registered domains, newest first. Private keys are never included.
{ "domains": [
{ "domain":"example.com", "selector":"api",
"dkim_verified_at":"2026-08-24T02:14:00Z",
"inbound_webhook_url":null, "created_at":"2026-08-24 02:10:41" }
] }
get/v1/domains/:domainadmin
One domain, including its DNS records — useful for re-reading the DKIM value without regenerating the key.
post/v1/domains/:domain/verifyadmin
Checks what is actually published in DNS against the key held here. The SPF
check resolves a and mx mechanisms rather than only
looking for a literal IP, so the common v=spf1 a mx -all form is
recognised correctly.
# 200 — ready { "domain": "example.com", "dkim": { "ok":true, "detail":"published key matches the private key held here" }, "spf": { "ok":true, "detail":"authorised via 'a' (example.com -> 203.0.113.10)" }, "ready_to_send": true }
ready_to_send tracks DKIM only. SPF failing is reported but does
not block sending — some deployments legitimately authorise the server through a
mechanism this check cannot follow.
put/v1/domains/:domain/webhookadmin
Sets or clears the inbound webhook. Pass null to disable. The
response includes the webhook_secret used to sign deliveries.
curl -X PUT $BASE/v1/domains/example.com/webhook \ -H "Authorization: Bearer $ADMIN_KEY" \ -H 'content-type: application/json' \ -d '{"inbound_webhook_url":"https://yourapp.com/hooks/mail"}'
delete/v1/domains/:domainadmin
Removes the domain and destroys its private key. Irreversible — re-adding the domain generates a new key, so the published DKIM record must be updated too.
API keys
post/v1/keysadmin
Mints a key. The plaintext appears in this response and nowhere else — only a SHA-256 hash is stored, so a stolen database yields no working credentials.
Request
| Field | Description | |
|---|---|---|
| name | required | Label, e.g. billing-service |
| scope | optional | send (default) or admin |
{
"name": "billing-service",
"scope": "send",
"key": "sk_9f3aK2mN…",
"warning": "Store this now — it cannot be retrieved again."
}
get/v1/keysadmin
Lists keys by prefix, with last-used timestamps. Never returns key material.
delete/v1/keys/:idadmin
Revokes immediately. The next request using that key gets
401 invalid_credentials.
Messages
post/v1/messagessend
Signs the message with the sending domain's DKIM key and hands it to postfix
for delivery. The domain in from must be registered.
Request
| Field | Description | |
|---|---|---|
| from | required | Sender. Accepts a@b.com or Name <a@b.com> |
| to | required | String or array, up to 50 recipients |
| subject | optional | Defaults to empty |
| text | one of | Plain-text body |
| html | one of | HTML body. At least one of text/html is required |
| cc | optional | String or array |
| bcc | optional | String or array |
| reply_to | optional | Reply-To header |
| headers | optional | Object of extra headers |
| attachments | optional | Up to 20; see below |
# request curl -X POST $BASE/v1/messages \ -H "Authorization: Bearer $SEND_KEY" \ -H 'content-type: application/json' \ -d '{ "from": "noreply@example.com", "to": ["someone@gmail.com"], "subject": "Your receipt", "text": "Thanks for your order.", "html": "<p>Thanks for your order.</p>", "attachments": [ { "filename": "receipt.pdf", "content": "<base64>", "content_type": "application/pdf" } ] }' # 202 { "id": "3f2c8e10-…", "status": "sent", "message_id": "<abc@mail.example.com>", "accepted": ["someone@gmail.com"], "rejected": [], "smtp_response": "250 2.0.0 Ok: queued as 4bT2s1" }
202 means queued, not delivered. It confirms
postfix accepted the message. Actual delivery is asynchronous and can take
minutes — many receivers greylist first contact and expect a retry. Poll
GET /v1/messages/:id for the outcome.
Attachment fields: filename, content (base64 by
default), optional content_type and encoding. Total
message size is capped by MAX_MESSAGE_BYTES, 25 MB by default.
get/v1/messages/:idsend
Status of one message.
| status | Meaning |
|---|---|
| queued | Accepted here, not yet handed to postfix |
| sent | Postfix accepted it for delivery |
| failed | Rejected — see error |
get/v1/messagessend
Recent sends, newest first. ?limit= defaults to 50, capped at 200.
Inbound webhooks
Point a domain's MX record at this server, list it in
INBOUND_DOMAINS, and set a webhook URL. Arriving mail is parsed and
POSTed as JSON.
{
"event": "inbound.message",
"domain": "example.com",
"from": "sender@elsewhere.com",
"to": ["support@example.com"],
"subject": "Help please",
"text": "…",
"html": "…",
"message_id": "<x@elsewhere.com>",
"in_reply_to": null,
"date": "2026-08-24T02:30:00.000Z",
"headers": { "subject": "Help please" },
"attachments": [
{ "filename":"a.pdf", "content_type":"application/pdf",
"size":18422, "content":"<base64>" }
],
"received_at": "2026-08-24T02:30:02.113Z"
}
Retry behaviour
| Your response | What happens |
|---|---|
| 2xx | Acknowledged; postfix is told delivery succeeded |
| 4xx | Treated as permanent — not retried |
| 5xx / timeout | Retried with backoff, then requeued in postfix |
Because a failing webhook requeues rather than bounces, an outage on your side delays mail instead of losing it.
Verifying webhook signatures
Every delivery carries an HMAC over the raw body:
X-Mail-Signature: sha256=<hmac> X-Mail-Domain: example.com
Always verify this. Without it, anyone who learns your webhook URL can post forged inbound mail into your application. Compare in constant time, and hash the raw body — re-serialising JSON changes the bytes and the signature will not match.
const expected = crypto.createHmac('sha256', secret) .update(rawBody).digest('hex'); const ok = crypto.timingSafeEqual( Buffer.from(`sha256=${expected}`), Buffer.from(req.get('x-mail-signature')), );
Health
get/healthno auth
Liveness plus a real check that postfix is reachable. Returns
503 when it is not, which makes it usable as a container
healthcheck.
{ "status":"ok", "smtp":"reachable", "uptime_seconds":3841 }
Deliverability
Two things this API cannot fix, worth knowing before debugging configuration:
Outbound port 25 is often blocked. Most cloud providers block it on new accounts. The signature is unmistakable: everything works except 25/465/587 outbound. Only a support ticket lifts it.
IP reputation is judged separately. Perfect SPF, DKIM and DMARC do not guarantee delivery — a spammer can configure all three in ten minutes, so receivers assess the sending IP independently and first. Mail from a residential or dynamic address is refused by Gmail and Outlook regardless of how correct your authentication is. Send from a datacenter IP with clean history, or relay through a provider whose IPs already have reputation.