Mail Console
Portal API reference

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:

StepEndpointWhat it gives you
1POST /v1/domainsA DKIM keypair and the DNS records to publish
2— (publish DNS)Done at your DNS provider, not here
3POST …/verifyConfirmation that DNS matches the key held here
4POST /v1/messagesA 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:

CredentialPrefixScopeUse for
Admin keymk_…adminManaging domains and credentials
Send keysk_…sendApplications that only send mail
Session token—adminThe 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." }
  ]
}
StatusCodeMeaning
400invalid_requestMalformed or missing fields
400unknown_sender_domainThe from domain is not registered
401missing_credentialsNo Authorization header
401invalid_credentialsKey unrecognised, revoked, or token expired
403insufficient_scopeSend key used on an admin endpoint
404not_foundNo such domain, key, or message
409domain_existsDomain already registered
502delivery_failedThe 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

FieldDescription
passwordrequiredThe 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

FieldDescription
domainrequiredDomain to send from, e.g. example.com
selectoroptionalDKIM selector. Defaults to api
inbound_webhook_urloptionalSet 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

FieldDescription
namerequiredLabel, e.g. billing-service
scopeoptionalsend (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

FieldDescription
fromrequiredSender. Accepts a@b.com or Name <a@b.com>
torequiredString or array, up to 50 recipients
subjectoptionalDefaults to empty
textone ofPlain-text body
htmlone ofHTML body. At least one of text/html is required
ccoptionalString or array
bccoptionalString or array
reply_tooptionalReply-To header
headersoptionalObject of extra headers
attachmentsoptionalUp 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.

statusMeaning
queuedAccepted here, not yet handed to postfix
sentPostfix accepted it for delivery
failedRejected — 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 responseWhat happens
2xxAcknowledged; postfix is told delivery succeeded
4xxTreated as permanent — not retried
5xx / timeoutRetried 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.