Transactional Email API Documentation
Practical API, SMTP, SDK, webhook, and domain setup notes for sending application email with zaSend.
Quick Starts
API quick start
Use an account API key for password resets, login codes, receipts, and product notifications.
SMTP quick start
Connect WordPress, WooCommerce, Django, Laravel, Flask, or any SMTP client.
SDKs
Install the Node.js or Python SDK and keep keys on the server side.
Domain verification
Add SPF, DKIM, and DMARC records before sending production email.
Webhooks
Receive signed events for accepted, failed, bounced, and suppressed messages.
Troubleshooting
Understand “Accepted by Postfix,” bounces, suppressions, and failed messages.
Getting Started
All API requests are made to:
Steps to start sending:
- Create an account and log in
- Add and verify a sending domain
- Create an API key or a domain sending key
- 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 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."
});
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
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 |
|---|---|---|---|
| from | string | Yes | Sender email or "Name <email>" |
| to | string/array | Yes | Recipient(s). Max 50 per request. |
| cc | string/array | No | CC recipients |
| bcc | string/array | No | BCC recipients |
| subject | string | Yes* | Email subject (*not needed with template) |
| html | string | Yes* | HTML body (*at least one of html/text) |
| text | string | Yes* | Plain text body |
| template | string | No | Template slug (replaces subject/html/text) |
| variables | object | No | Template variables, e.g. {"name": "John"} |
| list_unsubscribe | string/array | No | Optional unsubscribe links for bulk or recurring mail. Entries must use mailto: or https:. |
| list_unsubscribe_post | boolean/string | No | Set 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.
# 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
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
List all domains on your account.
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.
Triggers DNS verification. Check domain status afterwards.
Delete a domain. Fails if there are in-flight emails.
DNS Setup Guide
After adding a domain, create these 3 DNS records:
| Type | Name | Value |
|---|---|---|
| TXT | @ | v=spf1 include:spf.zasend.com ~all |
| TXT | zasend._domainkey | v=DKIM1; p=YOUR_PUBLIC_KEY |
| TXT | _dmarc | v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com |
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.
| Method | Endpoint | Description |
|---|---|---|
| GET | /templates | List all templates |
| POST | /templates | Create 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.
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)
| Method | Endpoint | Description |
|---|---|---|
| GET | /webhooks | List webhooks |
| POST | /webhooks | Create webhook |
| DEL | /webhooks/{id} | Delete webhook |
Suppressions
| Method | Endpoint | Description |
|---|---|---|
| GET | /suppressions?page=1&per_page=50 | List suppressions (paginated, filter by ?reason=bounce) |
| POST | /suppressions | Add 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
| Method | Endpoint | Description |
|---|---|---|
| GET | /api-keys | List your API keys (prefix only, no full key) |
| POST | /api-keys | Create 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
{
"sent": 5,
"limit": 50,
"remaining": 45
}
Two layers of rate limiting:
| Limit | Default | Scope |
|---|---|---|
| Daily quota | 50/day | Per user, resets at midnight UTC |
| Per-minute burst | 30/min | Per user, sliding window |
| Per-request | 50 recipients | Max recipients per send request |
All send responses include these headers:
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" }
| HTTP | Code | Description |
|---|---|---|
| 400 | bad_request | Invalid request body, missing fields, bad email format |
| 401 | unauthorized | Missing or invalid API key |
| 403 | forbidden | Domain not owned or not verified |
| 404 | not_found | Resource not found |
| 422 | suppressed | Recipient is in suppression list |
| 429 | rate_limited | Daily or per-minute limit exceeded |
| 500 | internal_error | Server error |