API Reference
The Maildoot REST API lets you send email, manage sending domains, SMTP users, webhooks, and view reports — all via standard HTTP + JSON.
https://api.maildoot.net/v1
application/json
JSON — always
/v1/ in URL path
Authentication
All API requests require a Bearer token in the Authorization header. Two token types are accepted:
Issued by POST /auth/login. Valid for 1 hour. Use for dashboard-style integrations. Refresh with POST /auth/refresh.
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 Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing or invalid request body field |
| 401 | MISSING_TOKEN | Authorization header not provided |
| 401 | INVALID_TOKEN | Token invalid, expired, or revoked |
| 401 | INVALID_CREDENTIALS | Wrong email or password |
| 403 | INSUFFICIENT_SCOPE | API key scope doesn't allow this action |
| 403 | ACCOUNT_SUSPENDED | User account is inactive or suspended |
| 404 | NOT_FOUND | Resource does not exist or doesn't belong to you |
| 409 | DOMAIN_EXISTS | Domain already added to your account |
| 409 | USERNAME_TAKEN | SMTP username already in use globally |
| 500 | INTERNAL_ERROR | Unexpected server error |
Auth
/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
| Field | Type | Description |
|---|---|---|
emailrequired | string | Account email address |
passwordrequired | string | Account password |
POST /v1/auth/login
{
"email": "you@company.com",
"password": "your-password"
}
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600
}
// + Set-Cookie: mdrt=... (httpOnly)
/auth/refresh
Refresh cookie required
Issue a new access token using the httpOnly refresh token cookie. The refresh token is rotated on every call.
POST /v1/auth/refresh
// No body — uses cookie automatically
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600
}
/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
/api-keysList 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
}
/api-keysCreate 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
| Field | Type | Description |
|---|---|---|
labeloptional | string | Human-readable label e.g. "Production API" |
scopeoptional | string | full | send | read — defaults to full |
expires_in_daysoptional | integer | Days until expiry. null = never expires |
{
"label": "Production",
"scope": "send",
"expires_in_days": 90
}
{
"id": "664abc...",
"key": "mdsk_AbCd1234...",
"label": "Production",
"scope": "send",
"expires_at": "2025-08-17T...",
"_notice": "Save this key now."
}
/api-keys/:key_idRevoke 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
/user-settings/domainsReturns 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
}
/user-settings/domainsAdd 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.
| Field | Type | Description |
|---|---|---|
domainrequired | string | The domain you want to send from e.g. mail.yourco.com |
{ "domain": "mail.yourco.com" }
{
"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": { ... }
}
}
/user-settings/domains/:domain/verifyTriggers 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"
}
}
/user-settings/domains/:domainPermanently 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
/user-settings/smtp-usersCreate a new SMTP user. A random 18-character password is generated and returned once only.
| Field | Type | Description |
|---|---|---|
usernamerequired | string | Globally unique SMTP login username |
labeloptional | string | Human-readable label e.g. "My App Production" |
{
"username": "myapp-prod",
"label": "Production App"
}
{
"id": "abc123...",
"username": "myapp-prod",
"password": "Ax9#mK2!Lp3@...",
"_notice": "Save this password now."
}
/user-settings/smtp-users/:id/reset-passwordGenerates 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."
}
/user-settings/smtp-users/:idEnable or disable an SMTP user without deleting it.
{ "active": false }{ "active": false, "username": "myapp-prod" }Callbacks (Webhooks)
All callback endpoints are under /v1/user-settings/callbacks
/user-settings/callbacksRegister a webhook endpoint. A random HMAC secret is generated and returned once only — use it to verify incoming payloads.
| Field | Type | Description |
|---|---|---|
urlrequired | string | Your HTTPS endpoint URL |
eventsrequired | string[] | Array of event types to subscribe to (see below) |
labeloptional | string | Human-readable label |
{
"url": "https://yourapp.com/hooks",
"events": [
"message.delivered",
"message.bounced",
"email.opened"
],
"label": "Production Hooks"
}
{
"id": "667xyz...",
"url": "https://yourapp.com/hooks",
"secret": "a1b2c3d4e5f6...",
"events": ["message.delivered", ...],
"status": "active",
"_notice": "Save this secret now."
}
/user-settings/callbacks/:idGet 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.
| Event | Trigger |
|---|---|
message.delivered | Message accepted by the recipient MX server (2xx response) |
message.bounced | Permanent 5xx rejection from recipient MX |
message.deferred | Temporary 4xx — retry has been scheduled |
message.permanently_failed | All 5 retry attempts exhausted |
email.opened | Tracking pixel fired (first open) |
email.clicked | Tracking link clicked (first click) |
email.unsubscribed | One-click unsubscribe processed |
email.spam_complaint | FBL 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
/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
| Field | Type | Description |
|---|---|---|
fromrequired | string | Sender email address. Must be on a verified sending domain. |
from_nameoptional | string | Sender display name e.g. Acme Support. Shown in inbox as "Acme Support <hello@acme.com>". |
torequired | string | string[] | One or more recipient email addresses. |
ccoptional | string[] | CC recipients. |
bccoptional | string[] | BCC recipients — addresses are hidden from all other recipients. |
subjectrequired | string | Email subject line. |
htmloptional* | string | HTML body. At least one of html or text is required. |
textoptional* | string | Plain text body. Used as fallback when HTML is not rendered. |
attachmentsoptional | object[] | Array of attachment objects. See Attachment Object below. |
headersoptional | object | Custom headers as key-value pairs. These are injected into the email and also returned in webhook callback payloads. |
trackingoptional | object | Override account defaults: { "open": true, "click": false } |
tagsoptional | string[] | Custom tags for filtering in reports e.g. ["welcome", "onboarding"]. |
send_atoptional | ISO 8601 | Schedule future delivery. Maximum 72 hours ahead. Omit to send immediately. |
pooloptional | string | Override the default IP pool for this message e.g. transactional, marketing. |
Attachment Object
| Field | Type | Description |
|---|---|---|
filenamerequired | string | Name shown to the recipient e.g. invoice.pdf |
contentrequired | string (base64) | Base64-encoded file content. |
content_typerequired | string | MIME type e.g. application/pdf, image/png |
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"
}
{
"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}
// 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"
}
}
/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
{{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.
/templatesList 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
}
/templatesCreate a new template. Use {{variable_name}} syntax in subject, html, and text fields. Maildoot auto-detects all variables and stores the list.
Request Body
| Field | Type | Description |
|---|---|---|
template_idrequired | string | Your unique identifier. Lowercase, underscores allowed. e.g. tpl_otp_verify. Must be unique in your account. |
namerequired | string | Human-readable template name for dashboard display. |
subjectrequired | string | Subject line. Supports {{variables}}. |
htmloptional* | string | HTML body. At least one of html or text required. Supports {{variables}}. |
textoptional* | string | Plain text fallback. Supports {{variables}}. |
fromoptional | string | Default sender address for this template. Can be overridden at send time. |
from_nameoptional | string | Default sender display name. |
{
"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"
}
{
"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.
/templates/:template_idFetch 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"
}
/templates/:template_id
Replace a template's content. All fields are optional — only provided fields are updated. The template_id itself cannot be changed.
{
"subject": "{{first_name}}, your OTP: {{otp}}",
"html": "<h1>OTP: {{otp}}</h1>"
}
{
"template_id": "tpl_otp_verify",
"variables": ["first_name", "otp"],
"updated_at": "2025-05-17T12:00:00Z"
}
/templates/:template_idPermanently delete a template. Any future send calls using this template_id will return a 404.
{ "message": "Template tpl_otp_verify deleted" }
/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
| Field | Type | Description |
|---|---|---|
torequired | string | string[] | Recipient email address(es). |
variablesrequired | object | Key-value map of template variables. Every {{key}} in the template is replaced with its value. |
fromoptional | string | Override the template's default sender address. |
from_nameoptional | string | Override the template's default sender name. |
ccoptional | string[] | CC recipients. |
bccoptional | string[] | BCC recipients. |
headersoptional | object | Custom headers echoed in webhook callbacks. |
attachmentsoptional | object[] | Additional attachments for this send only. |
tagsoptional | string[] | Tags for report filtering. |
send_atoptional | ISO 8601 | Schedule future delivery. |
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"
}
}
{
"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.
{{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
/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.
GET /v1/reports/messages/a1b2c3d4-e5f6-...
| Status | Meaning |
|---|---|
queued | Accepted, awaiting delivery |
delivered | Accepted by recipient MX |
bounced | Hard bounce — permanent 5xx |
deferred | Soft bounce — retry scheduled |
permanently_failed | All retries exhausted |
{
"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"
}
/reports/summary
Aggregate delivery statistics for your account. Supports date range, domain, tag, and status filters.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
start_dateoptional | ISO 8601 | Start of date range. Defaults to 30 days ago. |
end_dateoptional | ISO 8601 | End of date range. Defaults to now. |
domainoptional | string | Filter by sending domain e.g. mail.acme.com |
tagoptional | string | Filter by message tag. |
template_idoptional | string | Filter 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
}
/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
}
]
}