Documentation

One REST API over HTTPS, JSON in and JSON out. Everything below is what the API actually does today; nothing here is planned or partial.

Getting started

Create an API key under API Keys in the dashboard, then send. You can send from a sandbox key straight away; a real domain needs its DNS records first.

curl https://api.maillog.dev/v1/emails \
  -H "Authorization: Bearer ma_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Maillog <[email protected]>",
    "to": ["[email protected]"],
    "subject": "hello world",
    "html": "<p>it works</p>"
  }'

The answer carries the id of the message. Use it on GET /v1/emails/:id to see how delivery went.

Authentication

Every call carries Authorization: Bearer <key>. A key belongs to one team, and you see the full key exactly once: at creation. After that we only keep a hash, so a lost key is replaced, never recovered.

RoleMay do
send_onlySend mail. Cannot read what was sent.
full_accessEverything this API offers, for this team.

A sandbox key is a throwaway: mail sent with it never leaves the server and lands in a web inbox of its own. Handy for testing without touching a real domain.

Sending mail

Either html or text is required; sending both is fine and preferred. Everything else is optional.

FieldTypeWhat it does
fromstringSender, plain address or Name <[email protected]>. Must be on a domain you proved.
tostring[]At least one recipient.
cc, bccstring[]Optional extra recipients.
reply_tostringWhere replies should go if that is not the sender.
subjectstringRequired, at most 998 characters.
html, textstringThe body. At least one of the two.
headersobjectExtra headers, as key-value pairs.
tagsarrayLabels of your own, as {name, value}.
attachmentsarrayfilename plus base64 content. Inline images need a content_id.
scheduled_atstringISO timestamp. Held until then.
trackingobject{opens, clicks}. May be stricter than the domain setting, never wider.

Batch takes an array of the same objects and answers per message, so one rejected address does not sink the rest of the batch.

Domain setup

A domain has three layers that are switched on separately. Ownership first; without it the other two stay closed.

LayerRecordWhy
OwnershipTXTProves the domain is yours. Required.
SendingSPF + DKIMLets receiving servers see the mail really came from you.
ReceivingMXPoints incoming mail at us. Replaces your current mail server.
DMARCTXTBlocks nothing on its own, but tells the world what to do with forgeries.
Link domainCNAMEOnly if you measure clicks. Without it, links point at our address instead of yours.

DNS spreads slowly. POST /v1/domains/:id/verify checks right now instead of waiting for the next scheduled run.

Webhooks

We POST JSON to your HTTPS URL when something happens to a message. The secret you receive at creation signs every call, so you can tell our calls from anyone else's.

email.sentemail.deliveredemail.openedemail.clickedemail.bouncedemail.complainedemail.failed
// Node: check the signature before you trust the body
import crypto from "node:crypto";

const expected = crypto
  .createHmac("sha256", process.env.MAILLOG_WEBHOOK_SECRET)
  .update(rawBody)
  .digest("hex");

if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
  return res.status(401).end();
}

Compare with timingSafeEqual and not with ===: a normal comparison stops at the first wrong character, and the time it took gives away how far someone guessed correctly.

Errors

Failures answer with a JSON body carrying statusCode and either message or a list of errors per field.

CodeMeansWhat to do
401Key missing or unknownCheck the Authorization header.
403Key may not do thisA send_only key cannot read. Use a full_access key.
404Not found for this teamAlso what you get for something that belongs to someone else.
422Body does not validateRead errors: it names the field and the reason.
429Too many requestsBack off and retry later.
5xxOur side brokeRetry; the same message is not sent twice.

API reference

Every endpoint the API answers on today, grouped by what it is for.

Sending

One call sends one email. The batch call takes up to a hundred at a time and answers per message, so one bad address does not sink the rest.

MethodPathWhat it does
POST/v1/emailsSend one email
POST/v1/emails/batchSend up to 100 at once
GET/v1/emailsList sent emails, newest first
GET/v1/emails/:idOne email with its delivery status

Domains

A domain sits above sending and receiving. Prove ownership first; sending and receiving are separate switches after that.

MethodPathWhat it does
POST/v1/domainsAdd a domain
GET/v1/domainsList domains with their DNS records
GET/v1/domains/:idOne domain
PATCH/v1/domains/:idChange tracking or link domain
POST/v1/domains/:id/verifyCheck the DNS records now
DELETE/v1/domains/:idRemove a domain
GET/v1/diagnostics/:domainRead SPF, DKIM, DMARC and MX as they resolve right now

API keys

A key is shown once, at creation. After that we only keep a hash, so a lost key is replaced, never recovered.

MethodPathWhat it does
POST/v1/api-keysCreate a key
GET/v1/api-keysList keys (prefix only)
DELETE/v1/api-keys/:idRevoke a key immediately

Webhooks

We POST an event to your URL. Only HTTPS, and every call carries an HMAC signature made with the secret you get at creation.

MethodPathWhat it does
POST/v1/webhooksCreate a webhook and get its secret
GET/v1/webhooksList webhooks
PATCH/v1/webhooks/:idChange URL, events or enabled
DELETE/v1/webhooks/:idDelete a webhook

Suppressions

Addresses we refuse to send to. Bounces and complaints land here on their own; you can add and remove by hand.

MethodPathWhat it does
GET/v1/suppressionsList, optionally filtered with ?q=
POST/v1/suppressionsAdd an address by hand
DELETE/v1/suppressions/:idAllow mail to this address again

Inbound

Mailboxes are the buckets, addresses are the name plates on the door, and routes decide what happens to mail that arrives.

MethodPathWhat it does
GET/v1/inbound/mailboxesList mailboxes
POST/v1/inbound/mailboxesCreate a mailbox
PATCH/v1/inbound/mailboxes/:idChange storage, quota or retention
POST/v1/inbound/mailboxes/:id/addressesAdd an address to a mailbox
DELETE/v1/inbound/mailboxes/:idDelete an empty mailbox
GET/v1/inbound/routesList routing rules
POST/v1/inbound/routesCreate a routing rule
GET/v1/inbound/messagesList received messages
GET/v1/inbound/messages/:id/bodyMessage body, sanitised
GET/v1/inbound/messages/:id/attachments/:indexOne attachment
GET/v1/inbound/logsWhat happened to each arriving message

Contacts, segments and broadcasts

Contacts belong to your team, segments are filters over them, and a broadcast is one mail to a segment.

MethodPathWhat it does
GET/v1/contactsList contacts
POST/v1/contactsCreate a contact
PATCH/v1/contacts/:idUpdate a contact
DELETE/v1/contacts/:idDelete a contact
GET/v1/segmentsList segments
POST/v1/segmentsCreate a segment
GET/v1/topicsList subscription topics
POST/v1/broadcastsCreate a broadcast
POST/v1/broadcasts/:id/sendSend a broadcast
GET/v1/templatesList templates
POST/v1/templatesCreate a template
POST/v1/templates/:id/publishPublish a template version

Secure portal

Instead of the message, the recipient gets a link. The body stays encrypted with us and is handed over only against a token, and optionally a PIN.

MethodPathWhat it does
POST/v1/secureStore a message and get its link
POST/v1/secure/retrieveRetrieve a message with token and PIN
GET/v1/secure/status/:idWhether it was opened, and when

Account and monitoring

Your team, your workspaces, and what the API did.

MethodPathWhat it does
GET/v1/metricsDaily numbers, ?days=7|30|90
GET/v1/logsEvery API call, newest first
GET/v1/team/membersTeam members and their roles
POST/v1/team/invitesInvite someone
GET/v1/workspacesList workspaces
POST/v1/workspaces/:id/switchSwitch the active workspace
GET/v1/addonsPaid add-ons and their state