REST API
Send emails using the Mail.Land REST API with full request and response reference
Send transactional emails via POST /v1/transactional/send. This is the primary way to send email through MailLand.
Authentication
Include your API key in the X-API-Key header:
X-API-Key: ml_pet_Ab3Kx9mN2pQ7rT4wL8yD...The credential must have the Api channel enabled and be a Sending product credential.
Send a Single Email
POST https://api.mail.land/v1/transactional/sendcurl -X POST https://api.mail.land/v1/transactional/send \
-H "X-API-Key: ml_pet_Ab3Kx9mN2pQ7rT4wL8yD..." \
-H "Content-Type: application/json" \
-d '{
"from": {"email": "[email protected]"},
"to": [{"email": "[email protected]"}],
"subject": "Hello from MailLand",
"htmlContent": "<p>Your email content here</p>"
}'Request Body
{
"from": {
"email": "[email protected]",
"name": "Your App"
},
"to": [
{
"email": "[email protected]",
"name": "Jane Doe",
"variables": {
"firstName": "Jane",
"orderNumber": "ORD-12345"
}
}
],
"subject": "Your order #{{orderNumber}} has shipped",
"htmlContent": "<h1>Hi {{firstName}},</h1><p>Your order #{{orderNumber}} is on its way.</p>",
"textContent": "Hi {{firstName}}, Your order #{{orderNumber}} is on its way.",
"replyTo": "[email protected]",
"tags": ["order-shipped", "transactional"],
"customHeaders": {
"X-Entity-Ref-ID": "order-12345"
},
"tracking": {
"opens": true,
"clicks": true
},
"priority": "High",
"idempotencyKey": "order-12345-shipped-v1"
}Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
from | object | Yes | Sender address |
from.email | string | Yes | Sender email (must be on a verified domain) |
from.name | string | No | Display name |
to | array | Yes | Recipient list |
to[].email | string | Yes | Recipient email address |
to[].name | string | No | Recipient display name |
to[].variables | object | No | Per-recipient template variables |
subject | string | Conditional | Required unless using a template |
templateId | string | No | Use a stored template instead of inline content |
htmlContent | string | Conditional | HTML body (htmlContent or textContent required if no templateId) |
textContent | string | No | Plain text fallback |
variables | object | No | Global template variables. Applied whether or not templateId is set; on a key overlap, the recipient's own to[].variables wins |
replyTo | string | No | Reply-to email address |
tags | array | No | Tags for filtering in analytics and webhooks |
customHeaders | object | No | Custom email headers (key-value pairs) |
tracking | object | No | Override tracking settings |
tracking.opens | boolean | No | Ask for open tracking on this message (default: off on both streams). Used only while the sending domain has made no choice; a domain's own per-stream choice, set on the Domains page, wins. false always turns it off |
tracking.clicks | boolean | No | Enable click tracking (default: false for transactional streams unless your account enables it, true for marketing) |
ipPoolId | string | No | Reserved for dedicated IP pools. Not yet available: sending it returns 400 IP_POOL_NOT_FOUND. Omit it. |
scheduledAt | datetime | No | Schedule for future delivery (UTC), up to 7 days ahead |
idempotencyKey | string | No | Prevent duplicate sends for the same key. Prefer the X-Idempotency-Key header |
attachments | array | No | File attachments (max 10, max 10 MiB decoded total, see Attachments) |
priority | enum | No | Low, Normal (default), High, or Critical |
Attachments
Each attachment requires three fields:
{
"attachments": [
{
"fileName": "invoice.pdf",
"contentType": "application/pdf",
"content": "JVBERi0xLjQKJ...",
"inline": false
},
{
"fileName": "logo.png",
"contentType": "image/png",
"content": "iVBORw0KGgoAAAA...",
"contentId": "company-logo",
"inline": true
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
fileName | string | Yes | File name with extension |
contentType | string | Yes | MIME type (e.g., application/pdf) |
content | string | Yes | Standard Base64-encoded file content: padded, with no whitespace or line breaks |
contentId | string | No | Content ID for inline attachments (reference in HTML as cid:company-logo) |
inline | boolean | No | Whether this is an inline image (default: false) |
At most 10 attachments per email, and at most 10 MiB decoded (post-Base64) in total. A missing contentType, non-canonical Base64, exceeding the count, or exceeding the size returns 400 VALIDATION_FAILED. POST /v1/transactional/send-batch has no attachments field.
Response
Status: 202 Accepted
{
"success": true,
"jobId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"messageIds": ["t_a1b2c3d4e5f6..."],
"acceptedRecipients": ["[email protected]"],
"rejectedRecipients": [],
"queuedAt": "2026-02-17T10:30:00Z",
"isSandboxed": false
}| Field | Type | Description |
|---|---|---|
success | boolean | Whether the request was accepted |
jobId | string | Unique job identifier for tracking |
messageIds | array | Per-recipient message IDs (t_ prefix for transactional, m_ for marketing) |
acceptedRecipients | array | Email addresses accepted for delivery |
rejectedRecipients | array | Recipients that were rejected |
queuedAt | datetime | When the email was queued |
isSandboxed | boolean | true if the request used a sandbox credential (see Sandbox Mode) |
sandboxReason | string | credential_sandbox_mode (logged, not delivered) or all_recipients_not_in_sandbox_allowlist (no recipient was on the allow list); absent otherwise |
Rejected Recipients
Recipients can be rejected for several reasons:
{
"success": true,
"jobId": "...",
"acceptedRecipients": ["[email protected]"],
"rejectedRecipients": [
{
"email": "[email protected]",
"reason": "Suppressed"
},
{
"email": "[email protected]",
"reason": "domain_typo",
"suggestion": "[email protected]"
}
]
}Common rejection reasons:
Suppressed: address is on the suppression list (previous bounces/complaints)disposable_domain: disposable email provider detecteddomain_typo: likely typo in domain (includessuggestionfield)
Pre-flight Check
Verify your ability to send before making the actual request:
GET https://api.mail.land/v1/transactional/send/check?recipientCount=1curl https://api.mail.land/v1/transactional/send/check?recipientCount=1 \
-H "X-API-Key: ml_pet_Ab3Kx9mN2pQ7rT4wL8yD..."{
"canSend": true,
"rateLimitStatus": {
"currentSecond": 2,
"limitPerSecond": 50,
"currentHour": 450,
"limitPerHour": 5000,
"currentDay": 8200,
"limitPerDay": 50000
},
"monthlyUsed": 125000,
"monthlyLimit": 500000
}If canSend is false, the reason field explains why (rate limit, quota, account status, etc).
Idempotency
Retrying a send after a network timeout must not deliver the email twice. Supply an idempotency key and a retry replays the original response instead of sending again.
Send the key as a header (recommended):
X-Idempotency-Key: order-12345-shipped-v1Or use the idempotencyKey body field. Both are accepted, and you may send both only if the values are identical.
Rules
- Scope.
X-Idempotency-Keyis accepted onPOST /v1/transactional/sendandPOST /v1/transactional/send-batchonly. No other endpoint reads it. - Format. 1–255 characters, letters, digits,
_and-. Keys are compared exactly: no trimming, no case folding. - Window. A key stays replayable for 24 hours. After that the same key is treated as a fresh request.
- Replay. A retry within the window returns the original response verbatim (same IDs, same status) and sends nothing.
- Uniqueness. Keys are unique per account. Reusing one key for a different endpoint is rejected with
409; use a distinct key per operation.
Correlation ID
Track requests across systems by including a correlation ID:
X-Correlation-ID: my-app-order-12345or
X-Request-ID: my-app-order-12345The correlation ID appears in logs and the activity stream.
Rate Limit Headers
Successful responses include per-IP rate limit information:
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 295When rate limited (429) by the send policy engine, a Retry-After header indicates seconds to wait:
Retry-After: 30A transactional request may name up to 5 recipients, and each one counts against your rate limits. If that is more than your per-second allowance (for example 1 per second on a plan of 60 per minute), the request is accepted when no other request was accepted in the same second; otherwise it gets 429 with Retry-After: 1 and succeeds when retried in the next second, within your per-minute limit. See Rate Limits.
An IP-level 429 also includes X-RateLimit-Reset as an ISO-8601 UTC time.
Error Responses
All errors return a JSON body:
{
"code": "ERROR_CODE",
"message": "Human-readable description",
"timestamp": "2026-02-17T10:30:00Z",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}Error Codes
| Status | Code | Description |
|---|---|---|
400 | INVALID_REQUEST | Malformed request body, or scheduledAt more than 7 days ahead ("Cannot schedule email more than 7 days in advance") |
400 | VALIDATION_FAILED | No htmlContent, textContent, or templateId; or an attachment fails validation (see Attachments). Response includes an errors[] array with the specific field and rule that failed. (Previously: MISSING_CONTENT: deprecated 2026-04.) |
413 | CONTENT_TOO_LARGE | The assembled message would exceed the platform's message-size limit ("The message is too large to send: it would exceed the platform's message-size limit. Reduce the HTML, text, attachments or recipients."). Nothing is queued |
400 | transactional_recipient_limit_exceeded | More than 5 recipients on a transactional credential (1,000 on marketing) |
400 | TEMPLATE_NOT_FOUND | templateId names no active template of this account (also returned for a deleted template) |
400 | IP_POOL_NOT_FOUND | ipPoolId names no pool assigned to this account (currently always, since no pool can be assigned yet, see the field reference above) |
422 | TEMPLATE_VARIABLE_MISSING | The content has a {{placeholder}} with no value; details.missingVariables lists the names. Nothing is queued |
400 | IDEMPOTENCY_KEY_INVALID | The X-Idempotency-Key header is empty, malformed, over 255 characters, or sent more than once (a malformed idempotencyKey body field returns VALIDATION_FAILED instead) |
400 | IDEMPOTENCY_KEY_CONFLICT | Header and idempotencyKey body field were both supplied and differ |
400 | SEND_FAILED | All recipients are suppressed (message: "All recipients are suppressed") |
409 | IDEMPOTENCY_KEY_REUSED | Key is already in use for a different endpoint on this account |
401 | UNAUTHORIZED | Missing, invalid, expired or revoked X-API-Key |
403 | ACCOUNT_SUSPENDED | Account has been suspended |
403 | ACCOUNT_DISABLED | Account has been disabled |
403 | ACCOUNT_NOT_ACTIVE | Account is not active |
403 | NO_ACTIVE_SUBSCRIPTION | Account has no active subscription |
403 | SUBSCRIPTION_EXPIRED | Account's subscription has expired |
403 | IP_NOT_ALLOWED | Request IP not in credential's allowed list |
403 | SENDING_BLOCKED | Sending is blocked for this account |
403 | ACTION_REQUIRED | An account action is required before sending |
403 | TENANT_APPROVAL_REQUIRED | The account is awaiting review before it can send. Retrying does not help; contact support |
422 | DOMAIN_NOT_VERIFIED | Sender domain not verified |
422 | DOMAIN_NOT_ALLOWED_FOR_CREDENTIAL | Domain not in credential's scope |
422 | DOMAIN_NOT_FOUND | Sender domain not found |
429 | RATE_LIMIT_EXCEEDED | Rate limit exceeded |
429 | QUOTA_EXCEEDED | Monthly quota exhausted |
429 | WARMUP_LIMIT_EXCEEDED | The account's daily ramp-up limit for new sending was reached; resets at 00:00 UTC |
429 | DAILY_LIMIT_EXCEEDED | Daily send limit reached |
429 | WARMUP_POOL_BUDGET_EXCEEDED | Platform capacity. Message: "Sending is temporarily paused for today. It resumes automatically at 00:00 UTC; no action is needed." (or "Sending is briefly delayed. Please retry in a few minutes." with a short Retry-After) |
429 | WARMUP_POOL_EMPTY | Platform capacity. Message: "Sending is temporarily paused. It resumes automatically; no action is needed." |
429 | PLATFORM_DAILY_CAPACITY_REACHED | Platform sending capacity for today is fully booked; resets at 00:00 UTC. Not your account quota |
429 | PLATFORM_CAPACITY_PROTECTION | New sending is briefly paused to protect platform capacity; retry shortly |
429 | PROBATION_CAP_REACHED | A new account's rolling 24-hour recipient cap was reached; capacity returns as earlier sends age out |
429 | PAYMENT_DAILY_LIMIT_EXCEEDED | Daily limit reached on a past-due account |
429 | MARKETING_PAUSED | Marketing sending paused for this account |
429 | SENDING_BLOCKED | Account verification level doesn't allow this send (sandbox/verification refusals; a hard sending block instead returns 403, see above) |
503 | SERVICE_UNAVAILABLE | Email service temporarily unavailable |
The 429 platform-capacity rows are not caused by your own usage. Nothing was queued, Retry-After says when to retry, and no action is needed on your side; see Error Codes for the exact messages. Marketing-only refusals (MARKETING_NOT_YET_ALLOWED, MARKETING_SUSPENDED, both 403) are listed there too.
Transactional Recipient Limit
Transactional credentials enforce a maximum of 5 recipients per send request. This prevents misuse of transactional streams for bulk sending. If you need more recipients, use a Marketing credential with batch sending.
Using Templates
Instead of inline HTML, reference a stored template:
{
"from": { "email": "[email protected]" },
"to": [
{
"email": "[email protected]",
"variables": {
"firstName": "Jane",
"resetLink": "https://app.example.com/reset?token=abc123"
}
}
],
"templateId": "t8x2m4q9zk7wpr",
"variables": {
"companyName": "Acme Inc"
}
}The subject comes from the template. On a key overlap, the recipient's own to[].variables overrides the global variables: the same rule as inline content. See Templates for creating and managing templates.
Next Steps
- Batch Sending: send to multiple recipients at once
- SMTP Relay: send via SMTP instead of REST
- Templates: create reusable email templates
- Scheduled Emails: schedule emails for future delivery