API Reference

The Maildoot REST API lets you send email, manage sending domains, SMTP users, webhooks, and view reports — all via standard HTTP + JSON.

Base URL
https://api.maildoot.net/v1
Content Type
application/json
Response Format
JSON — always
Versioning
/v1/ in URL path

Authentication

All API requests require a Bearer token in the Authorization header. Two token types are accepted:

JWT Session Token

Issued by POST /auth/login. Valid for 1 hour. Use for dashboard-style integrations. Refresh with POST /auth/refresh.

API KEY Static API Key

Prefixed with mdsk_. Long-lived, never expires unless you set an expiry or revoke it. Best for server-to-server integrations stored in env vars.

# Using an API key
Authorization: Bearer mdsk_AbCdEfGh1234...

# Using a JWT session token
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...

Error Codes

All errors follow a consistent JSON shape with a machine-readable code.

{
  "error": {
    "code":    "INVALID_CREDENTIALS",
    "message": "Invalid email or password"
  }
}
HTTP StatusCodeMeaning
400VALIDATION_ERRORMissing or invalid request body field
401MISSING_TOKENAuthorization header not provided
401INVALID_TOKENToken invalid, expired, or revoked
401INVALID_CREDENTIALSWrong email or password
403INSUFFICIENT_SCOPEAPI key scope doesn't allow this action
403ACCOUNT_SUSPENDEDUser account is inactive or suspended
404NOT_FOUNDResource does not exist or doesn't belong to you
409DOMAIN_EXISTSDomain already added to your account
409USERNAME_TAKENSMTP username already in use globally
500INTERNAL_ERRORUnexpected server error

Auth

POST /auth/login No auth required

Exchange email + password for a JWT access token (1 hour TTL) and an httpOnly refresh token cookie (7 days).

Request Body

FieldTypeDescription
emailrequiredstringAccount email address
passwordrequiredstringAccount password
Request
POST /v1/auth/login

{
  "email": "you@company.com",
  "password": "your-password"
}
Response 200
{
  "access_token": "eyJhbGci...",
  "token_type":   "Bearer",
  "expires_in":   3600
}
// + Set-Cookie: mdrt=... (httpOnly)
POST /auth/refresh Refresh cookie required

Issue a new access token using the httpOnly refresh token cookie. The refresh token is rotated on every call.

Request
POST /v1/auth/refresh
// No body — uses cookie automatically
Response 200
{
  "access_token": "eyJhbGci...",
  "token_type":   "Bearer",
  "expires_in":   3600
}
POST /auth/logout Bearer token required

Invalidates the current JWT (adds its jti to the Redis blocklist) and deletes the refresh token cookie.

{ "message": "Logged out successfully" }

API Keys

GET/api-keys

List all API keys on your account. Raw key values are never returned — only metadata.

{
  "api_keys": [
    {
      "id":           "664abc...",
      "label":        "Production",
      "scope":        "send",
      "active":       true,
      "expires_at":   null,
      "last_used_at": "2025-05-01T10:22:00Z",
      "created_at":   "2025-04-01T08:00:00Z"
    }
  ],
  "total": 1
}
POST/api-keys

Create a new API key. The raw key value is returned exactly once — it is hashed and stored, so it cannot be retrieved again.

Request Body

FieldTypeDescription
labeloptionalstringHuman-readable label e.g. "Production API"
scopeoptionalstringfull | send | read — defaults to full
expires_in_daysoptionalintegerDays until expiry. null = never expires
Request
{
  "label":           "Production",
  "scope":           "send",
  "expires_in_days": 90
}
Response 201
{
  "id":         "664abc...",
  "key":        "mdsk_AbCd1234...",
  "label":      "Production",
  "scope":      "send",
  "expires_at": "2025-08-17T...",
  "_notice":    "Save this key now."
}
DELETE/api-keys/:key_id

Revoke an API key immediately. In-flight requests using this key will fail within seconds (Redis cache invalidation).

{ "message": "API key revoked" }

Sending Domains

All domain endpoints are under /v1/user-settings/domains

GET/user-settings/domains

Returns all sending domains on your account with their verification status.

{
  "domains": [
    {
      "_id":        "665def...",
      "domain":     "mail.yourcompany.com",
      "status":     "verified",
      "verified_at":"2025-05-10T09:00:00Z"
    }
  ],
  "total": 1
}
POST/user-settings/domains

Add a new sending domain. Maildoot generates a 2048-bit RSA DKIM key pair and returns the full set of DNS records you need to configure.

FieldTypeDescription
domainrequiredstringThe domain you want to send from e.g. mail.yourco.com
Request
{ "domain": "mail.yourco.com" }
Response 201
{
  "domain": "mail.yourco.com",
  "status": "pending",
  "dns_records": {
    "dkim": {
      "type":  "TXT",
      "host":  "sel._domainkey.mail.yourco.com",
      "value": "v=DKIM1; k=rsa; p=MII..."
    },
    "spf": {
      "type":  "TXT",
      "host":  "mail.yourco.com",
      "value": "v=spf1 include:spf.maildoot.net ~all"
    },
    "dmarc": { ... },
    "return_path": { ... },
    "tracking": { ... }
  }
}
POST/user-settings/domains/:domain/verify

Triggers a live DNS lookup to check if the DKIM TXT record is correctly published. Updates the domain status to verified or failed.

{
  "domain":      "mail.yourco.com",
  "status":      "verified",
  "verified_at": "2025-05-17T11:00:00Z",
  "check": {
    "verified": true,
    "host":     "sel._domainkey.mail.yourco.com"
  }
}
DELETE/user-settings/domains/:domain

Permanently removes the domain and its DKIM keys from your account.

{ "message": "Domain mail.yourco.com deleted" }

SMTP Users

All SMTP user endpoints are under /v1/user-settings/smtp-users

POST/user-settings/smtp-users

Create a new SMTP user. A random 18-character password is generated and returned once only.

FieldTypeDescription
usernamerequiredstringGlobally unique SMTP login username
labeloptionalstringHuman-readable label e.g. "My App Production"
Request
{
  "username": "myapp-prod",
  "label":    "Production App"
}
Response 201
{
  "id":       "abc123...",
  "username": "myapp-prod",
  "password": "Ax9#mK2!Lp3@...",
  "_notice":  "Save this password now."
}
POST/user-settings/smtp-users/:id/reset-password

Generates a new password and replaces the existing one. New password returned once only.

{
  "username": "myapp-prod",
  "password": "NewP@ss9#Xy...",
  "_notice":  "Save this password now."
}
PATCH/user-settings/smtp-users/:id

Enable or disable an SMTP user without deleting it.

Request
{ "active": false }
Response
{ "active": false, "username": "myapp-prod" }

Callbacks (Webhooks)

All callback endpoints are under /v1/user-settings/callbacks

POST/user-settings/callbacks

Register a webhook endpoint. A random HMAC secret is generated and returned once only — use it to verify incoming payloads.

FieldTypeDescription
urlrequiredstringYour HTTPS endpoint URL
eventsrequiredstring[]Array of event types to subscribe to (see below)
labeloptionalstringHuman-readable label
Request
{
  "url": "https://yourapp.com/hooks",
  "events": [
    "message.delivered",
    "message.bounced",
    "email.opened"
  ],
  "label": "Production Hooks"
}
Response 201
{
  "id":      "667xyz...",
  "url":     "https://yourapp.com/hooks",
  "secret":  "a1b2c3d4e5f6...",
  "events":  ["message.delivered", ...],
  "status":  "active",
  "_notice": "Save this secret now."
}
GET/user-settings/callbacks/:id

Get callback detail including current health status — failure count, last response code, and last error message.

{
  "url": "https://yourapp.com/hooks",
  "health": {
    "status":            "active",
    "failure_count":     0,
    "last_triggered_at": "2025-05-17T10:30:00Z",
    "last_response_code":200,
    "last_error":        null
  }
}

Webhook Event Reference

Every webhook POST includes an X-Maildoot-Signature: sha256={hmac} header. Verify it using your callback secret before processing the payload.

EventTrigger
message.deliveredMessage accepted by the recipient MX server (2xx response)
message.bouncedPermanent 5xx rejection from recipient MX
message.deferredTemporary 4xx — retry has been scheduled
message.permanently_failedAll 5 retry attempts exhausted
email.openedTracking pixel fired (first open)
email.clickedTracking link clicked (first click)
email.unsubscribedOne-click unsubscribe processed
email.spam_complaintFBL complaint received from ISP

Example payload — message.delivered

{
  "event":      "message.delivered",
  "msg_id":     "a1b2c3d4-...",
  "user_id":    "664abc...",
  "to":         "customer@example.com",
  "from":       "hello@yourco.com",
  "subject":    "Welcome!",
  "timestamp":  "2025-05-17T11:05:00Z",
  "sending_ip": "13.127.255.192"
}

Verifying the signature (Node.js)

const crypto = require('crypto');

function verifyWebhook(body, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return signature === `sha256=${expected}`;
}

// In your Express handler:
app.post('/hooks', express.raw({ type: '*/*' }), (req, res) => {
  const sig = req.headers['x-maildoot-signature'];
  if (!verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  const event = JSON.parse(req.body);
  // handle event...
  res.sendStatus(200);
});

Send Email

POST /messages Scope: full, send

Send a single email. Returns a unique msg_id (reference ID) you can use to query delivery status and reports.

Request Body

FieldTypeDescription
fromrequiredstringSender email address. Must be on a verified sending domain.
from_nameoptionalstringSender display name e.g. Acme Support. Shown in inbox as "Acme Support <hello@acme.com>".
torequiredstring | string[]One or more recipient email addresses.
ccoptionalstring[]CC recipients.
bccoptionalstring[]BCC recipients — addresses are hidden from all other recipients.
subjectrequiredstringEmail subject line.
htmloptional*stringHTML body. At least one of html or text is required.
textoptional*stringPlain text body. Used as fallback when HTML is not rendered.
attachmentsoptionalobject[]Array of attachment objects. See Attachment Object below.
headersoptionalobjectCustom headers as key-value pairs. These are injected into the email and also returned in webhook callback payloads.
trackingoptionalobjectOverride account defaults: { "open": true, "click": false }
tagsoptionalstring[]Custom tags for filtering in reports e.g. ["welcome", "onboarding"].
send_atoptionalISO 8601Schedule future delivery. Maximum 72 hours ahead. Omit to send immediately.
pooloptionalstringOverride the default IP pool for this message e.g. transactional, marketing.

Attachment Object

FieldTypeDescription
filenamerequiredstringName shown to the recipient e.g. invoice.pdf
contentrequiredstring (base64)Base64-encoded file content.
content_typerequiredstringMIME type e.g. application/pdf, image/png
Request — Full Example
POST /v1/messages

{
  "from":       "hello@acme.com",
  "from_name":  "Acme Support",
  "to":         "user@example.com",
  "cc":         ["manager@acme.com"],
  "bcc":        ["audit@acme.com"],
  "subject":    "Your invoice #1042",
  "html":       "<p>Hi, please find your invoice.</p>",
  "text":       "Hi, please find your invoice.",
  "attachments": [{
    "filename":     "invoice.pdf",
    "content":      "JVBERi0xLjQK...",
    "content_type": "application/pdf"
  }],
  "headers": {
    "X-Order-ID":    "ORD-9982",
    "X-Customer-ID": "CUST-1234"
  },
  "tracking": { "open": true, "click": true },
  "tags":        ["invoice", "billing"],
  "pool":        "transactional"
}
Response 202
{
  "msg_id":     "a1b2c3d4-e5f6-...",
  "status":     "queued",
  "queued_at":  "2025-05-17T11:00:00Z"
}

// msg_id is your reference ID.
// Use it to query status via:
// GET /reports/messages/{msg_id}
Custom Headers in Webhook
// Your custom headers are echoed back
// in every webhook event payload:
{
  "event":    "message.delivered",
  "msg_id":   "a1b2c3d4...",
  "headers": {
    "X-Order-ID":    "ORD-9982",
    "X-Customer-ID": "CUST-1234"
  }
}
GET /messages/:msg_id Scope: full, read

Fetch the full delivery record for a single message using the msg_id returned when the message was sent.

{
  "msg_id":        "a1b2c3d4-e5f6-...",
  "from":          "hello@acme.com",
  "to":            ["user@example.com"],
  "subject":       "Your invoice #1042",
  "status":        "delivered",
  "sending_ip":    "13.127.255.192",
  "pool_name":     "transactional",
  "tags":          ["invoice", "billing"],
  "queued_at":     "2025-05-17T11:00:00Z",
  "delivered_at":  "2025-05-17T11:00:03Z",
  "opened_at":     "2025-05-17T11:04:22Z",
  "clicked_at":    null,
  "bounce_code":   null,
  "bounce_reason": null,
  "retry_count":   0
}

Templates

How templates work: Create a template with HTML/text body containing {{variable_name}} placeholders. Each template gets a unique template_id. At send time, pass that ID plus a variables object — Maildoot substitutes every placeholder before delivery. Templates can be created via the dashboard GUI or this API.
GET/templates

List all templates in your account.

{
  "templates": [
    {
      "template_id": "tpl_welcome_user",
      "name":        "Welcome Email",
      "subject":     "Welcome, {{first_name}}!",
      "variables":   ["first_name", "last_name", "activation_link"],
      "created_at":  "2025-05-01T08:00:00Z",
      "updated_at":  "2025-05-10T14:30:00Z"
    }
  ],
  "total": 1
}
POST/templates

Create a new template. Use {{variable_name}} syntax in subject, html, and text fields. Maildoot auto-detects all variables and stores the list.

Request Body

FieldTypeDescription
template_idrequiredstringYour unique identifier. Lowercase, underscores allowed. e.g. tpl_otp_verify. Must be unique in your account.
namerequiredstringHuman-readable template name for dashboard display.
subjectrequiredstringSubject line. Supports {{variables}}.
htmloptional*stringHTML body. At least one of html or text required. Supports {{variables}}.
textoptional*stringPlain text fallback. Supports {{variables}}.
fromoptionalstringDefault sender address for this template. Can be overridden at send time.
from_nameoptionalstringDefault sender display name.
Request
{
  "template_id": "tpl_otp_verify",
  "name":        "OTP Verification",
  "subject":     "Your OTP is {{otp}}",
  "html": "<p>Hi {{first_name}},</p>
<p>Your one-time password is:</p>
<h2>{{otp}}</h2>
<p>Valid for {{expiry_minutes}} minutes.
Do not share it with anyone.</p>",
  "text": "Hi {{first_name}}, your OTP is {{otp}}. Valid for {{expiry_minutes}} minutes.",
  "from":        "noreply@acme.com",
  "from_name":   "Acme Security"
}
Response 201
{
  "template_id": "tpl_otp_verify",
  "name":        "OTP Verification",
  "variables": [
    "first_name",
    "otp",
    "expiry_minutes"
  ],
  "created_at": "2025-05-17T11:00:00Z"
}

// Variables are auto-detected from
// {{...}} placeholders in all fields.
GET/templates/:template_id

Fetch a single template including its full HTML/text body and detected variable list.

{
  "template_id": "tpl_otp_verify",
  "name":        "OTP Verification",
  "subject":     "Your OTP is {{otp}}",
  "html":        "<p>Hi {{first_name}}...</p>",
  "text":        "Hi {{first_name}}, your OTP...",
  "variables":   ["first_name", "otp", "expiry_minutes"],
  "from":        "noreply@acme.com",
  "from_name":   "Acme Security",
  "created_at":  "2025-05-17T11:00:00Z",
  "updated_at":  "2025-05-17T11:00:00Z"
}
PUT /templates/:template_id

Replace a template's content. All fields are optional — only provided fields are updated. The template_id itself cannot be changed.

Request
{
  "subject": "{{first_name}}, your OTP: {{otp}}",
  "html":    "<h1>OTP: {{otp}}</h1>"
}
Response 200
{
  "template_id": "tpl_otp_verify",
  "variables":   ["first_name", "otp"],
  "updated_at":  "2025-05-17T12:00:00Z"
}
DELETE/templates/:template_id

Permanently delete a template. Any future send calls using this template_id will return a 404.

{ "message": "Template tpl_otp_verify deleted" }
POST /templates/:template_id/send Scope: full, send

Send an email using a saved template. Pass variables as a key-value object — every {{placeholder}} in the template is replaced before delivery. Supports all the same recipient and tracking fields as POST /messages.

Request Body

FieldTypeDescription
torequiredstring | string[]Recipient email address(es).
variablesrequiredobjectKey-value map of template variables. Every {{key}} in the template is replaced with its value.
fromoptionalstringOverride the template's default sender address.
from_nameoptionalstringOverride the template's default sender name.
ccoptionalstring[]CC recipients.
bccoptionalstring[]BCC recipients.
headersoptionalobjectCustom headers echoed in webhook callbacks.
attachmentsoptionalobject[]Additional attachments for this send only.
tagsoptionalstring[]Tags for report filtering.
send_atoptionalISO 8601Schedule future delivery.
Request
POST /v1/templates/tpl_otp_verify/send

{
  "to": "ram@example.com",
  "variables": {
    "first_name":      "Ram",
    "otp":             "847291",
    "expiry_minutes":  "10"
  },
  "headers": {
    "X-User-ID": "USR-9821"
  }
}
Response 202
{
  "msg_id":       "c3d4e5f6-...",
  "template_id":  "tpl_otp_verify",
  "status":       "queued",
  "queued_at":    "2025-05-17T11:05:00Z"
}

// Subject becomes:
// "Ram, your OTP: 847291"
//
// HTML placeholder {{first_name}}
// becomes "Ram", {{otp}} becomes
// "847291", etc.
Missing variables: If a required {{variable}} is not provided in the variables object, the placeholder is left as-is and a warning is included in the response. Always provide all variables declared in the template.

Reports

GET /reports/messages/:msg_id Scope: full, read

Fetch the complete delivery report for a specific transaction using the msg_id reference returned when the message was originally sent. This is the primary way to check whether a specific email was delivered, opened, clicked, or bounced.

Request
GET /v1/reports/messages/a1b2c3d4-e5f6-...
Status Values
StatusMeaning
queuedAccepted, awaiting delivery
deliveredAccepted by recipient MX
bouncedHard bounce — permanent 5xx
deferredSoft bounce — retry scheduled
permanently_failedAll retries exhausted
Response 200
{
  "msg_id":         "a1b2c3d4-...",
  "template_id":    "tpl_otp_verify",
  "from":           "noreply@acme.com",
  "to":             ["ram@example.com"],
  "subject":        "Ram, your OTP: 847291",
  "status":         "delivered",
  "sending_ip":     "13.127.255.192",
  "pool_name":      "transactional",
  "tags":           ["otp"],
  "retry_count":    0,
  "queued_at":      "2025-05-17T11:05:00Z",
  "delivered_at":   "2025-05-17T11:05:02Z",
  "opened_at":      "2025-05-17T11:06:14Z",
  "clicked_at":     null,
  "unsubscribed_at":null,
  "bounce_code":    null,
  "bounce_reason":  null,
  "s3_key":         "emails/usr123/a1b2c3d4.eml"
}
GET /reports/summary

Aggregate delivery statistics for your account. Supports date range, domain, tag, and status filters.

Query Parameters

ParameterTypeDescription
start_dateoptionalISO 8601Start of date range. Defaults to 30 days ago.
end_dateoptionalISO 8601End of date range. Defaults to now.
domainoptionalstringFilter by sending domain e.g. mail.acme.com
tagoptionalstringFilter by message tag.
template_idoptionalstringFilter by template ID.
GET /v1/reports/summary?start_date=2025-05-01&tag=invoice

{
  "period": { "from": "2025-05-01", "to": "2025-05-17" },
  "sent":              12450,
  "delivered":        12301,
  "bounced":          82,
  "deferred":         67,
  "permanently_failed":0,
  "opened":           4820,
  "clicked":          1103,
  "unsubscribed":     14,
  "spam_complaints":  2,
  "delivery_rate":    98.8,
  "open_rate":        39.2,
  "click_rate":       8.9
}
GET /reports/timeseries

Hourly or daily volume broken down over a date range. Use granularity=hour or granularity=day (default: day). Supports the same filters as /reports/summary.

GET /v1/reports/timeseries?granularity=day&start_date=2025-05-15

{
  "granularity": "day",
  "data": [
    {
      "period":     "2025-05-15",
      "sent":       3200,
      "delivered":  3168,
      "bounced":    22,
      "opened":     1140,
      "clicked":    290
    },
    {
      "period":     "2025-05-16",
      "sent":       4100,
      "delivered":  4055,
      "bounced":    30,
      "opened":     1560,
      "clicked":    401
    }
  ]
}