Transactional Email API Documentation

Practical API, SMTP, SDK, webhook, and domain setup notes for sending application email with zaSend.

Quick Starts

Getting Started

All API requests are made to:

https://zasend.com/api/v1

Steps to start sending:

  1. Create an account and log in
  2. Add and verify a sending domain
  3. Create an API key or a domain sending key
  4. Start sending via the API or SMTP

Authentication

All API endpoints require a Bearer token in the Authorization header:

curl -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
     https://zasend.com/api/v1/emails/send

Account API keys start with sk_live_ and can manage account resources. Domain Sending Keys start with dsk_live_ and can only send from one verified domain.

SDKs

Node.js

npm / GitHub

npm install zasend-node
const { ZaSend } = require("zasend-node");

const zasend = new ZaSend(process.env.ZASEND_API_KEY);
await zasend.sendEmail({
  from: "Acme <noreply@acme.com>",
  to: "user@example.com",
  subject: "Welcome",
  text: "Thanks for signing up."
});

Python

PyPI / GitHub

pip install zasend
from zasend import ZaSend

client = ZaSend("sk_live_...")
client.send_email(
    from_email="Acme <noreply@acme.com>",
    to="user@example.com",
    subject="Welcome",
    text="Thanks for signing up.",
)

The SDKs are thin wrappers over https://zasend.com/api/v1. Domain Sending Keys can use send methods only; Account API keys can use all SDK methods.

Send Email

POST /api/v1/emails/send

Send a transactional email. Supports direct content or template-based sending with variable substitution.

Direct Content (single recipient)

curl -X POST https://zasend.com/api/v1/emails/send \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "noreply@yourdomain.com",
    "to": "user@example.com",
    "subject": "Hello!",
    "html": "<h1>Welcome</h1><p>Thanks for signing up.</p>",
    "text": "Welcome! Thanks for signing up.",
    "list_unsubscribe": [
      "mailto:unsubscribe@yourdomain.com",
      "https://yourdomain.com/unsubscribe/user-token"
    ],
    "list_unsubscribe_post": true
  }'

Multiple Recipients

curl -X POST https://zasend.com/api/v1/emails/send \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "noreply@yourdomain.com",
    "to": ["user1@example.com", "user2@example.com"],
    "cc": ["admin@example.com"],
    "subject": "Team Update",
    "html": "<p>New update available.</p>"
  }'

Template-Based

curl -X POST https://zasend.com/api/v1/emails/send \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "noreply@yourdomain.com",
    "to": "user@example.com",
    "template": "welcome",
    "variables": {"name": "Ahmed", "company": "Acme"}
  }'

Request Body

Field Type Required Description
fromstringYesSender email or "Name <email>"
tostring/arrayYesRecipient(s). Max 50 per request.
ccstring/arrayNoCC recipients
bccstring/arrayNoBCC recipients
subjectstringYes*Email subject (*not needed with template)
htmlstringYes*HTML body (*at least one of html/text)
textstringYes*Plain text body
templatestringNoTemplate slug (replaces subject/html/text)
variablesobjectNoTemplate variables, e.g. {"name": "John"}
list_unsubscribestring/arrayNoOptional unsubscribe links for bulk or recurring mail. Entries must use mailto: or https:.
list_unsubscribe_postboolean/stringNoSet true for one-click unsubscribe. Requires an HTTPS unsubscribe URL.

Use unsubscribe headers for newsletters, product updates, and recurring non-critical email. They are optional for pure transactional messages such as password resets, verification codes, and receipts.

Response (single recipient)

{
  "success": true,
  "message_id": "msg_1234@yourdomain.com",
  "status": "queued"
}

Response (multiple recipients)

{
  "success": true,
  "batch_id": "uuid-here",
  "recipients": [
    {"to": "user1@example.com", "message_id": "msg_aaa@domain", "status": "queued"},
    {"to": "user2@example.com", "message_id": "msg_bbb@domain", "status": "queued"}
  ]
}

If any recipient in a request is suppressed, the whole request is rejected with 422 suppressed. Split requests if you want independent handling.

SMTP

Use SMTP when your app already supports standard mail settings.

Host
smtp.zasend.com
Port
587 STARTTLS
Account key username
api
Domain key username
noreply@yourdomain.com
# Python
import smtplib
from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "noreply@yourdomain.com"
msg["To"] = "user@example.com"
msg["Subject"] = "Hello"
msg["List-Unsubscribe"] = "<mailto:unsubscribe@yourdomain.com>, <https://yourdomain.com/unsubscribe/user-token>"
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
msg.set_content("Hello from zaSend")

with smtplib.SMTP("smtp.zasend.com", 587) as smtp:
    smtp.starttls()
    smtp.login("noreply@yourdomain.com", "dsk_live_...")
    smtp.send_message(msg)
// Node.js with nodemailer
const transporter = nodemailer.createTransport({
  host: "smtp.zasend.com",
  port: 587,
  secure: false,
  auth: { user: "noreply@yourdomain.com", pass: "dsk_live_..." }
});

Django/Laravel/WordPress/WooCommerce use the same host, port, STARTTLS, username, and password fields. Use a Domain Sending Key for per-domain credentials, or username api with an account API key.

Email Status

GET /api/v1/emails/{message_id}
curl -H "Authorization: Bearer sk_live_..." \
  https://zasend.com/api/v1/emails/msg_1234@yourdomain.com
{
  "message_id": "msg_1234@yourdomain.com",
  "from": "noreply@yourdomain.com",
  "to": "user@example.com",
  "subject": "Hello!",
  "status": "accepted_by_postfix",
  "latest_error": null,
  "attempts": 1,
  "created_at": "2026-05-18T10:00:00",
  "accepted_at": "2026-05-18T10:00:01",
  "events": [
    {"type": "queued", "details": "Email queued for sending", "created_at": "..."},
    {"type": "sending", "details": "Attempt 1", "created_at": "..."},
    {"type": "accepted_by_postfix", "details": "Email accepted by Postfix for delivery attempt", "created_at": "..."}
  ]
}

Status values: queued, sending, accepted_by_postfix, failed, bounced, soft_bounced

Important: accepted_by_postfix means zaSend's Postfix accepted the message for delivery attempt. It does not guarantee inbox delivery.

Domains

GET /api/v1/domains

List all domains on your account.

POST /api/v1/domains
curl -X POST https://zasend.com/api/v1/domains \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"domain": "example.com"}'

Returns DNS records you need to add. After adding them, call verify.

POST /api/v1/domains/{id}/verify

Triggers DNS verification. Check domain status afterwards.

DELETE /api/v1/domains/{id}

Delete a domain. Fails if there are in-flight emails.

DNS Setup Guide

After adding a domain, create these 3 DNS records:

TypeNameValue
TXT@v=spf1 include:spf.zasend.com ~all
TXTzasend._domainkeyv=DKIM1; p=YOUR_PUBLIC_KEY
TXT_dmarcv=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com

Templates

POST /api/v1/templates
curl -X POST https://zasend.com/api/v1/templates \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Welcome Email",
    "slug": "welcome",
    "subject": "Welcome !",
    "html": "<h1>Hello </h1>",
    "text": "Hello "
  }'

Use syntax for dynamic content. At least one of html or text is required.

MethodEndpointDescription
GET/templatesList all templates
POST/templatesCreate template
GET/templates/{id}Get template (includes variables list)
PUT/templates/{id}Update template (name, subject, html, text)
DEL/templates/{id}Delete template

Webhooks

How to use Webhooks

Open Dashboard → Webhooks, add an HTTPS endpoint, choose the events your app should receive, and store the signing secret when it is shown. You can also create webhooks through the API below.

To test retries, point a temporary HTTPS endpoint at a URL that returns HTTP 500, send one test email, then check Dashboard → Webhooks or Admin → Beta Validation for retry evidence.

POST /api/v1/webhooks
curl -X POST https://zasend.com/api/v1/webhooks \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks",
    "events": ["accepted_by_postfix", "bounced", "failed"]
  }'

The secret is returned once. Use it to verify payloads. Dashboard-created webhooks show the same secret once after creation.

Webhook Payload

{
  "event": "accepted_by_postfix",
  "message_id": "msg_1234@domain.com",
  "from": "noreply@domain.com",
  "to": "user@example.com",
  "subject": "Hello!",
  "status": "accepted_by_postfix",
  "timestamp": "2026-05-18T10:00:01"
}

Event types: queued, sending, accepted_by_postfix, failed, bounced, soft_bounced

Production URLs: Webhook URLs must use HTTPS and cannot target local, private, or reserved IP ranges.

Retry: Failed deliveries are retried 3 times with exponential backoff. Repeated final failures can automatically disable a webhook and alert the admin.

Verifying Webhooks (Python)

import hmac, hashlib

def verify_webhook(payload_body, secret, signature_header):
    received = signature_header.replace("sha256=", "", 1)
    expected = hmac.new(
        secret.encode(),
        payload_body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, received)

# Flask example:
# signature = request.headers.get('X-zaSend-Signature')
# verify_webhook(request.data, YOUR_SECRET, signature)
MethodEndpointDescription
GET/webhooksList webhooks
POST/webhooksCreate webhook
DEL/webhooks/{id}Delete webhook

Suppressions

MethodEndpointDescription
GET/suppressions?page=1&per_page=50List suppressions (paginated, filter by ?reason=bounce)
POST/suppressionsAdd suppression — {"email":"x@y.com","reason":"manual"}
DEL/suppressions/{id}Remove suppression

Hard bounces are auto-added to suppressions. Reasons: manual, bounce, complaint.

API Keys

MethodEndpointDescription
GET/api-keysList your API keys (prefix only, no full key)
POST/api-keysCreate key — {"name":"My Key"}
DEL/api-keys/{id}Revoke an API key

The full API key is only returned once on creation. Store it securely — it cannot be retrieved later.

Rate Limits

GET /api/v1/rate-limits
{
  "sent": 5,
  "limit": 50,
  "remaining": 45
}

Two layers of rate limiting:

LimitDefaultScope
Daily quota50/dayPer user, resets at midnight UTC
Per-minute burst30/minPer user, sliding window
Per-request50 recipientsMax recipients per send request

All send responses include these headers:

X-RateLimit-Limit: 50
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1716105600

Trust, Limits, and Safety

Error Codes

All errors follow this format:

{ "error": "error_code", "message": "Human-readable description" }
HTTPCodeDescription
400bad_requestInvalid request body, missing fields, bad email format
401unauthorizedMissing or invalid API key
403forbiddenDomain not owned or not verified
404not_foundResource not found
422suppressedRecipient is in suppression list
429rate_limitedDaily or per-minute limit exceeded
500internal_errorServer error