zMailer API Documentation

Multi-tenant transactional email with HMAC-signed outbound webhooks, idempotency, multi-recipient sends, and templates.

Base URL

https://zmailer-api.zavecoder.com

Endpoints

MethodPathDescription
POST/sendSend a transactional email (single or multi-recipient)
POST/previewRender a template or inline content without sending
GET/templatesList templates
POST/templatesCreate a template
PUT/templates/{id}Update a template (auto-versioned)
GET/sendersList verified sender domains
GET/suppressionLookup or list suppressed addresses
GET/statsSend/delivery analytics (with optional tag filter)
GET/healthPublic health probe (no auth)
POST/webhooksRegister an outbound webhook receiver

Authentication

All API requests require an API key (except /health). Create keys from the dashboard.

API Key Format

zm_live_xxxxxxxxxxxxxxxxxxxx # production
zm_test_xxxxxxxxxxxxxxxxxxxx # testing

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

POST/send

Request Body

FieldTypeRequiredDescription
tostring | string[]YesRecipient(s). Single string or array.
ccstring[]NoCC recipients
bccstring[]NoBCC recipients
toNamestringNoDisplay name (only used when single recipient)
fromstringNoSender email (must be on a verified domain)
fromNamestringNoSender display name
replyTostringNoReply-To address
subjectstringYes*Subject line
htmlstringYes*HTML body
textstringNoPlain-text fallback
templateSlugstringNoUse a saved template instead of inline content
templateIdstringNoAlternate to templateSlug
variablesobjectNoTemplate variables (Handlebars-style)
tagsstring[]NoCategorize sends (filterable in /stats and webhook payloads)
metadataobjectNoCustom JSON object echoed back in webhook payloads

* Required if not using a template

Headers

HeaderDescription
X-API-KeyYour API key (or use Authorization: Bearer)
Idempotency-KeyOptional. 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

POST/preview

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

GET/templates
curl https://zmailer-api.zavecoder.com/templates -H "X-API-Key: zm_live_your_key"

Create Template

POST/templates
{
  "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

PUT/templates/{id}

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

GET/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

GET/suppression

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

GET/stats

Send/delivery counts, broken down by status and sending domain. Optionally filter by tag.

Query Parameters

ParameterTypeDefaultDescription
daysinteger30Lookback window
tagstringFilter 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

GET/health

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

EventFiresPer
email.sentSES accepted the messagesend (one event)
email.deliveredSES confirmed delivery to the recipient's mailboxrecipient
email.bouncedHard or soft bouncerecipient
email.complainedRecipient marked as spamrecipient
email.failedSES rejected the messagesend (one event)

Multi-recipient sends produce N email.delivered/bounced/complained events, all sharing the same sendId.

1. Register a Receiver

POST/webhooks
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, /send returns 403 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.

TypeNameValue
TXT@v=spf1 include:amazonses.com ~all
CNAMExxx._domainkeyxxx.dkim.amazonses.com
TXT_dmarcv=DMARC1; p=quarantine

The exact records are shown in the dashboard when you add a domain.

Quotas & Limits

LimitDefaultNotes
Daily sends per project1,000Counts each recipient (to + cc + bcc). Returns 429 DAILY_LIMIT_REACHED.
Idempotency key TTL24hCached response returned for matching keys; 409 on body mismatch.
Webhook receiver timeout10sNon-2xx triggers retry with exponential backoff (max 7 attempts over 24h).

Errors

HTTPCodeDescription
400RECIPIENT_SUPPRESSEDOne or more recipients are on the suppression list
400Bad request — missing or invalid parameters / invalid JSON
401Invalid or missing API key
403SENDING_PAUSEDProject paused (high bounce / complaint rate)
403From-address domain not verified for this project
404Template not found
409IDEMPOTENCY_MISMATCHIdempotency-Key reused with a different request body
429DAILY_LIMIT_REACHEDDaily sending quota exceeded
500Server error / SES failure
503/health only — degraded