Base URL
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /send | Send a transactional email (single or multi-recipient) |
| POST | /preview | Render a template or inline content without sending |
| GET | /templates | List templates |
| POST | /templates | Create a template |
| PUT | /templates/{id} | Update a template (auto-versioned) |
| GET | /senders | List verified sender domains |
| GET | /suppression | Lookup or list suppressed addresses |
| GET | /stats | Send/delivery analytics (with optional tag filter) |
| GET | /health | Public health probe (no auth) |
| POST | /webhooks | Register an outbound webhook receiver |
Authentication
All API requests require an API key (except /health). Create keys from the dashboard.
API Key Format
Using the API Key
# Option 1: X-API-Key header (recommended) curl -H "X-API-Key: zm_live_your_key" ... # Option 2: Bearer token curl -H "Authorization: Bearer zm_live_your_key" ...
Send Email
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| to | string | string[] | Yes | Recipient(s). Single string or array. |
| cc | string[] | No | CC recipients |
| bcc | string[] | No | BCC recipients |
| toName | string | No | Display name (only used when single recipient) |
| from | string | No | Sender email (must be on a verified domain) |
| fromName | string | No | Sender display name |
| replyTo | string | No | Reply-To address |
| subject | string | Yes* | Subject line |
| html | string | Yes* | HTML body |
| text | string | No | Plain-text fallback |
| templateSlug | string | No | Use a saved template instead of inline content |
| templateId | string | No | Alternate to templateSlug |
| variables | object | No | Template variables (Handlebars-style) |
| tags | string[] | No | Categorize sends (filterable in /stats and webhook payloads) |
| metadata | object | No | Custom JSON object echoed back in webhook payloads |
* Required if not using a template
Headers
| Header | Description |
|---|---|
| X-API-Key | Your API key (or use Authorization: Bearer) |
| Idempotency-Key | Optional. Same key within 24h returns the cached response. Reusing the key with a different body returns 409. |
Single Recipient
curl -X POST https://zmailer-api.zavecoder.com/send \
-H "Content-Type: application/json" \
-H "X-API-Key: zm_live_your_api_key" \
-d '{
"to": "user@example.com",
"subject": "Welcome!",
"html": "<h1>Hello!</h1><p>Welcome to our service.</p>",
"tags": ["welcome"],
"metadata": { "userId": "u_123" }
}'Multiple Recipients (to / cc / bcc)
One email_logs row is created per recipient and grouped by sendId. Per-recipient bounce/complaint/delivery tracking is automatic.
{
"to": ["alice@example.com", "bob@example.com"],
"cc": ["carol@example.com"],
"bcc": ["audit@example.com"],
"subject": "Team update",
"html": "<p>Hi team!</p>",
"replyTo": "noreply@yourcompany.com",
"tags": ["team-update", "weekly"],
"metadata": { "campaign": "q2-update" }
}Idempotency
Provide an Idempotency-Key header to make retries safe — the same key within 24 hours returns the cached response (or 409 if the body differs).
curl -X POST https://zmailer-api.zavecoder.com/send \
-H "Content-Type: application/json" \
-H "X-API-Key: zm_live_your_api_key" \
-H "Idempotency-Key: order-12345-confirmation" \
-d '{ "to": "buyer@example.com", "templateSlug": "order-confirm",
"variables": { "orderId": "12345" } }'Success Response
{
"success": true,
"messageId": "0100018d1234abcd-...", // SES provider message id (unchanged)
"logId": "a1b2c3d4-...", // first log row (unchanged)
"sendId": "550e8400-e29b-41d4-a716-...", // NEW: groups all recipient rows
"providerMessageId": "0100018d1234abcd-...", // NEW: explicit alias of messageId
"recipients": 1, // NEW
"logIds": ["a1b2c3d4-..."] // NEW: all recipient log rows
}Existing fields (messageId, logId) are unchanged from prior versions of the API. New consumers should use sendId to correlate with webhook events — webhook payloads include sendId and providerMessageId (the SES id, equal to messageId).
Preview Email
Render a template or inline content with variables, without sending. Useful for previewing before live sends, or letting users iterate templates.
Example
curl -X POST https://zmailer-api.zavecoder.com/preview \
-H "Content-Type: application/json" \
-H "X-API-Key: zm_live_your_api_key" \
-d '{
"templateSlug": "order-confirmation",
"variables": { "name": "John", "order_id": "12345" }
}'Response
{
"subject": "Order #12345 confirmed for John",
"html": "<h1>Hi John!</h1><p>Your order #12345 is confirmed.</p>",
"text": "Hi John! Your order #12345 is confirmed."
}Inline content (passing subject, html, or text directly) is also supported for previewing unsaved drafts.
Templates
Create reusable email templates with dynamic variables. Updates create a new version automatically.
Variable Syntax
<h1>Hello {{name}}!</h1>
<p>Welcome to {{company}}.</p>
<p>Your order #{{order_id}} is confirmed.</p>List Templates
curl https://zmailer-api.zavecoder.com/templates -H "X-API-Key: zm_live_your_key"
Create Template
{
"name": "Order Confirmation",
"slug": "order-confirmation",
"subject": "Order #{{order_id}} confirmed",
"htmlContent": "<h1>Hi {{name}}!</h1><p>Your order #{{order_id}} is on the way.</p>",
"textContent": "Hi {{name}}! Your order #{{order_id}} is on the way.",
"variables": [
{ "name": "name", "required": true },
{ "name": "order_id", "required": true }
]
}Update Template
Same body as create. The previous version is preserved in template_versions.
Send With a Template
{
"to": "user@example.com",
"templateSlug": "order-confirmation",
"variables": {
"name": "John",
"company": "Acme Inc",
"order_id": "12345"
},
"tags": ["order-confirm"]
}Verified Senders
List the verified domains available for use as from addresses.
curl https://zmailer-api.zavecoder.com/senders -H "X-API-Key: zm_live_your_key"
Response
{
"senders": [
{
"domainId": "550e8400-...",
"domain": "yourcompany.com",
"defaultFromEmail": "noreply@yourcompany.com",
"defaultFromName": "Your Company",
"sendingPaused": false
}
]
}Suppression List
Recipients are auto-added to the suppression list on bounce or complaint. Sending to a suppressed address returns 400 RECIPIENT_SUPPRESSED.
Lookup a Single Address
curl "https://zmailer-api.zavecoder.com/suppression?email=bounced@example.com" \ -H "X-API-Key: zm_live_your_api_key"
Response
{
"email": "bounced@example.com",
"suppressed": true,
"entry": {
"email": "bounced@example.com",
"reason": "bounce",
"bounce_type": "permanent",
"source": "ses_notification",
"created_at": "2026-04-12T10:33:00Z"
}
}Paginated List
Omit email to list all suppressions. Supports ?limit (max 500) and ?offset.
curl "https://zmailer-api.zavecoder.com/suppression?limit=100&offset=0" \ -H "X-API-Key: zm_live_your_key"
Stats
Send/delivery counts, broken down by status and sending domain. Optionally filter by tag.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| days | integer | 30 | Lookback window |
| tag | string | — | Filter to sends carrying this tag |
Examples
# All sends, last 7 days curl "https://zmailer-api.zavecoder.com/stats?days=7" \ -H "X-API-Key: zm_live_your_key" # Just "invite" tagged sends, last 30 days curl "https://zmailer-api.zavecoder.com/stats?tag=invite" \ -H "X-API-Key: zm_live_your_key"
Response
{
"period": { "days": 7, "from": "...", "to": "..." },
"tag": null,
"stats": { "total": 1250, "sent": 1200, "delivered": 1180, "failed": 35, "bounced": 15 },
"daily": [{ "date": "2026-04-26", "total": 150, "delivered": 145, "failed": 5 }],
"byDomain": { "yourcompany.com": { "total": 1250, "sent": 1200, "failed": 50 } }
}Health Probe
Public, unauthenticated. Returns 200 when DB & SES are reachable, 503 otherwise. Use as a circuit breaker on consumer apps.
curl https://zmailer-api.zavecoder.com/health
Response
{
"status": "ok",
"checks": { "database": "ok", "ses_configured": "ok" },
"timestamp": "2026-05-03T18:58:34Z"
}Outbound Webhooks
zMailer can POST signed events to your application as emails progress through their lifecycle. All payloads are signed with HMAC-SHA256 and retried with exponential backoff on failure.
Event Types
| Event | Fires | Per |
|---|---|---|
| email.sent | SES accepted the message | send (one event) |
| email.delivered | SES confirmed delivery to the recipient's mailbox | recipient |
| email.bounced | Hard or soft bounce | recipient |
| email.complained | Recipient marked as spam | recipient |
| email.failed | SES rejected the message | send (one event) |
Multi-recipient sends produce N email.delivered/bounced/complained events, all sharing the same sendId.
1. Register a Receiver
curl -X POST https://zmailer-api.zavecoder.com/webhooks \
-H "Content-Type: application/json" \
-H "X-API-Key: zm_live_your_api_key" \
-d '{
"url": "https://yourapp.com/api/zmailer/webhook",
"events": ["email.sent", "email.delivered", "email.bounced", "email.complained"],
"description": "Production webhook receiver"
}'Pass events: [] (empty array) to subscribe to all event types.
Response
{
"endpoint": {
"id": "550e8400-...",
"url": "https://yourapp.com/api/zmailer/webhook",
"events": ["email.sent", "email.delivered", "email.bounced", "email.complained"],
"secret": "whsec_a1b2c3d4...",
"active": true,
"created_at": "..."
}
}Save the secret — store it as ZMAILER_WEBHOOK_SECRET on your server. You can re-fetch it via GET /webhooks/{id}, but treat it as sensitive.
Manage Endpoints
# List all
curl https://zmailer-api.zavecoder.com/webhooks -H "X-API-Key: zm_live_your_key"
# Get one (includes the secret)
curl https://zmailer-api.zavecoder.com/webhooks/{id} -H "X-API-Key: zm_live_your_key"
# Update
curl -X PUT https://zmailer-api.zavecoder.com/webhooks/{id} \
-H "X-API-Key: zm_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "active": false }'
# Delete
curl -X DELETE https://zmailer-api.zavecoder.com/webhooks/{id} -H "X-API-Key: zm_live_your_key"2. Receive & Verify
Each webhook arrives with these headers:
X-Zmailer-Signature: t=<unix_ts>,v1=<hex_hmac> X-Zmailer-Event: email.sent | email.delivered | ... X-Zmailer-Delivery-Id: <uuid> Content-Type: application/json
Signature Verification
v1 = HMAC_SHA256(secret, `${t}.${rawBody}`). Reject if |now − t| > 300 seconds (replay protection).
import crypto from 'node:crypto';
function verifyZmailerSignature(rawBody, signatureHeader, secret) {
// signatureHeader = "t=1715000000,v1=hex..."
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('='))
);
const ts = parseInt(parts.t, 10);
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
if (expected !== parts.v1) return false;
if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // 5min replay window
return true;
}Sample Payload — email.delivered
{
"event": "email.delivered",
"timestamp": "2026-05-03T18:58:34Z",
"sendId": "550e8400-e29b-41d4-a716-446655440000",
"messageId": "550e8400-e29b-41d4-a716-446655440000", // = sendId in webhook payloads
"providerMessageId": "0100018d1234abcd-...",
"recipient": "user@example.com",
"subject": "Welcome!",
"tags": ["welcome"],
"metadata": { "userId": "u_123" },
"deliveredAt": "2026-05-03T18:58:30Z"
}Sample Payload — email.bounced
{
"event": "email.bounced",
"timestamp": "2026-05-03T18:58:34Z",
"sendId": "550e8400-...",
"messageId": "550e8400-...",
"providerMessageId": "0100018d1234abcd-...",
"recipient": "bad-address@example.com",
"bounceType": "Permanent",
"bounceSubType": "General",
"diagnosticCode": "smtp; 550 5.1.1 user unknown",
"subject": "Welcome!",
"tags": ["welcome"],
"metadata": { "userId": "u_123" }
}Retries
Receivers must respond with 2xx within 10 seconds. On 5xx, timeout, or network error, the delivery is retried with exponential backoff:
Attempt 1: immediate Attempt 2: +30s Attempt 3: +2m Attempt 4: +10m Attempt 5: +1h Attempt 6: +6h Attempt 7: +24h (final — marked as failed)
Use X-Zmailer-Delivery-Id to deduplicate on your side.
Auto-Suppression & Auto-Pause
- Permanent bounces & complaints add the recipient to the suppression list automatically.
- Project sending is paused when bounce rate > 5% or complaint rate > 0.1% (after at least 50 sends).
- While paused,
/sendreturns403 SENDING_PAUSED. Resume via the dashboard after fixing your lists.
Code Examples
JavaScript / Node.js
const response = await fetch('https://zmailer-api.zavecoder.com/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'zm_live_your_api_key',
'Idempotency-Key': 'unique-request-id', // optional, 24h dedup
},
body: JSON.stringify({
to: ['user@example.com'],
subject: 'Hello World',
html: '<h1>Hello!</h1>',
tags: ['transactional'],
}),
});
const result = await response.json();
console.log(result.sendId, result.providerMessageId);Python
import requests
response = requests.post(
'https://zmailer-api.zavecoder.com/send',
headers={
'Content-Type': 'application/json',
'X-API-Key': 'zm_live_your_api_key',
'Idempotency-Key': 'unique-request-id', # optional
},
json={
'to': ['user@example.com'],
'subject': 'Hello World',
'html': '<h1>Hello!</h1>',
'tags': ['transactional'],
},
)
print(response.json())Domain Verification
Before sending, verify a domain by adding DNS records. zMailer can auto-configure these for Cloudflare-managed domains.
| Type | Name | Value |
|---|---|---|
| TXT | @ | v=spf1 include:amazonses.com ~all |
| CNAME | xxx._domainkey | xxx.dkim.amazonses.com |
| TXT | _dmarc | v=DMARC1; p=quarantine |
The exact records are shown in the dashboard when you add a domain.
Quotas & Limits
| Limit | Default | Notes |
|---|---|---|
| Daily sends per project | 1,000 | Counts each recipient (to + cc + bcc). Returns 429 DAILY_LIMIT_REACHED. |
| Idempotency key TTL | 24h | Cached response returned for matching keys; 409 on body mismatch. |
| Webhook receiver timeout | 10s | Non-2xx triggers retry with exponential backoff (max 7 attempts over 24h). |
Errors
| HTTP | Code | Description |
|---|---|---|
| 400 | RECIPIENT_SUPPRESSED | One or more recipients are on the suppression list |
| 400 | — | Bad request — missing or invalid parameters / invalid JSON |
| 401 | — | Invalid or missing API key |
| 403 | SENDING_PAUSED | Project paused (high bounce / complaint rate) |
| 403 | — | From-address domain not verified for this project |
| 404 | — | Template not found |
| 409 | IDEMPOTENCY_MISMATCH | Idempotency-Key reused with a different request body |
| 429 | DAILY_LIMIT_REACHED | Daily sending quota exceeded |
| 500 | — | Server error / SES failure |
| 503 | — | /health only — degraded |