Documentation
/domains or /api-keys go to the dashboard and require signing in. For common questions, see the FAQ; for policies, see Legal.ZidiMail is a transactional email platform: verify your domain, create API keys, send mail over HTTPS, and receive delivery webhooks. This page describes the customer dashboard and the public HTTP API your applications call.
https://api.zidimails.com·Versioned prefix:/v1Overview
The stack has two surfaces you care about: the dashboard (browser UI at your app URL) for domains, keys, billing, and logs; and the REST API at https://api.zidimails.com/v1 for sending and querying from servers.
- Sending uses an API key (
POST /v1/emails) so only backend services hold secrets. - Dashboard session uses a JWT stored in the
zm_tokencookie after login; many read endpoints accept either JWT or API key via the sameAuthorization: Bearerheader. - Inbound provider hooks (
/inbound/ses,/inbound/mailgun) are internal — not for customer use.
Quick start
- Create an account and confirm your email if prompted.
- Open Domains, add your sending domain, and add the DNS records shown (SPF, DKIM, DMARC as applicable). Use Verify until the domain is verified.
- Open API Keys, create a key, and copy the full secret once — it is shown only at creation time.
- Call
POST https://api.zidimails.com/v1/emailsfrom your server withAuthorization: Bearer <full key>. - Optionally configure Webhooks for delivery events.
Dashboard areas
Searchable log of sent messages, statuses, and per-email detail.
Add domains, copy DNS records, run verification, remove domains.
Create and revoke keys. Full key secret is shown only once.
Register HTTPS endpoints, choose events, copy signing secret once.
Charts and delivery metrics driven by /v1/emails/stats.
Plan upgrades (PayPal subscription flow) and quota context.
Organization and account preferences; account deactivation flows.
Operators with admin privileges also see an Admin entry for internal tooling (JWT required, separate from API keys).
Authentication
Send Authorization: Bearer <token> on every request to /v1/*.
API key (server-to-server)
Keys look like zm_live_<id>_<secret>. Use them for automation and for POST /v1/emails (sending always requires an API key). Store keys in environment variables or a secrets manager — never in frontend code.
curl https://api.zidimails.com/v1/emails \ -H "Authorization: Bearer zm_live_<keyId>_<secret>"
Session JWT (dashboard)
After login, the UI stores a JWT in the zm_token cookie. The same token can be passed as Bearer for endpoints that use requireAuth (JWT or API key). Endpoints that require requireJwt accept only the dashboard JWT — for example creating API keys and managing PayPal billing.
APP_URL on the API. Call the REST API from your backend, or from the dashboard origin as the app already does.Send an email
Requires an API key (not JWT alone). The from address must use a domain that is verified for your organization.
Request body
fromrequiredtorequiredsubjectrequiredhtmlhtml or text is required.textccbccreply_tofrom address — which on a sending subdomain (e.g. mail.yourdomain.com) is usually a dead end. Always set this to a real monitored inbox.headerstags💡 From address & Reply-To — how they work
The part before the @ in your from address can be anything — hello@, noreply@, invoices@, alerts@ — it is just a display label. What actually controls deliverability is the domain after the @, which must match one of your verified sending domains.
Your verified sending domain (e.g. mail.yourdomain.com) is typically a subdomain with no real mailbox behind it. If a recipient hits Reply and you haven't set reply_to, their reply goes to that subdomain and disappears. Always point reply_to at a real inbox you monitor.
// ✅ Correct — replies land in a real inbox
"from": "noreply@mail.yourdomain.com",
"reply_to": "support@yourdomain.com"
// ❌ Risky — replies vanish if no mailbox exists on the subdomain
"from": "support@mail.yourdomain.com" // no reply_to set
Common patterns: use noreply@ as the from label to signal the address is not monitored, and set reply_to to your support or team inbox so replies are never lost.
Example
curl -X POST https://api.zidimails.com/v1/emails \
-H "Authorization: Bearer zm_live_your_full_key" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": "user@example.com",
"subject": "Welcome",
"html": "<p>Thanks for signing up.</p>",
"text": "Thanks for signing up."
}'Response
{
"id": "<nanoid>",
"warning": "Optional — some recipients skipped (suppression list)"
}List & get emails
List recent messages for the organization. Accepts JWT or API key.
limitpagesearchcurl "https://api.zidimails.com/v1/emails?limit=20&page=1" \ -H "Authorization: Bearer <jwt_or_api_key>"
Get one email
Returns the email row plus related delivery events when present.
curl https://api.zidimails.com/v1/emails/<id> \ -H "Authorization: Bearer <jwt_or_api_key>"
Analytics & stats
Aggregates sent volume and rates for the org. Accepts JWT or API key.
daysResponse includes totals, counts by status, daily buckets, and computed delivery / bounce / complaint rates. The dashboard Analytics page consumes this endpoint.
curl "https://api.zidimails.com/v1/emails/stats?days=30" \ -H "Authorization: Bearer <jwt_or_api_key>"
Domains API
All routes require JWT or API key via Bearer auth.
Add domain
domainrequiredmail.example.com or example.com.Returns id, domain, and dns_records for SPF/DKIM/DMARC and Mailgun-aligned records.
List domains
Get domain
Includes merged DNS guidance and verification flags after checks.
Verify DNS
Re-runs SPF/DKIM (and related) checks and updates stored verification. Response includes checks and verified when SPF and DKIM pass.
Remove domain
API keys API
Creating and revoking keys requires a JWT (dashboard session), not an API key alone — so compromised automation keys cannot mint new keys.
Create key
namerequiredResponse includes key once: zm_live_<id>_<secret>. Save it immediately; only the prefix is listed afterward.
List keys
Revoke key
Webhooks API
Register HTTPS URLs to receive JSON notifications when delivery events occur. Requires JWT or API key for management routes.
Create webhook
urlrequiredeventsrequiredemail.sent, email.delivered, email.bounced, email.complained, email.opened, email.clicked.Response returns secret once (prefix whsec_) — used to verify ZidiMail-Signature on deliveries.
List webhooks
Secrets are omitted from list responses.
Delete webhook
Webhook signatures
Each delivery is a POST with body signed using HMAC-SHA256 and the webhook secret.
- Header
ZidiMail-Signature: sha256=<hex>— HMAC of the raw JSON body. - Header
ZidiMail-Event— same string astypein the JSON payload.
Payload shape
{
"type": "email.delivered",
"created_at": "2026-05-13T12:00:00.000Z",
"data": {
"email_id": "...",
"to": ["user@example.com"],
"from": "hello@yourdomain.com",
"subject": "Welcome"
}
}Verifying the signature
Recompute HMAC-SHA256 of the raw request body bytes using your webhook secret and compare to the header value (use constant-time comparison to prevent timing attacks).
import { createHmac, timingSafeEqual } from 'crypto'
// Express example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['zidimail-signature'] // "sha256=<hex>"
const secret = process.env.WEBHOOK_SECRET // "whsec_..."
const expected = 'sha256=' + createHmac('sha256', secret)
.update(req.body) // raw Buffer — do NOT use req.body as string after JSON parse
.digest('hex')
const sigBuf = Buffer.from(signature)
const expBuf = Buffer.from(expected)
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send('Invalid signature')
}
const event = JSON.parse(req.body)
console.log('Verified event:', event.type)
res.sendStatus(200)
})Sandbox mode & ZidiGuard
Sandbox
Organizations in sandbox mode may only send to the org owner's registered email. Attempts to reach other recipients return 403 with guidance to contact support for production activation.
ZidiGuard (suppressions)
Addresses that hard-bounced or complained are suppressed. If every recipient in a send is suppressed, the API returns 422 and does not consume quota for those recipients. Partial suppression adds a warning in the send response.
Billing & plans
The dashboard Billing flow uses PayPal subscriptions (/billing/subscribe and related routes) with JWT auth. Plan tiers adjust monthly and daily sending limits (see table below).
Exact PayPal plan IDs and webhook verification are server-side; integrate billing only through the dashboard unless you are extending the API intentionally.
HTTP errors
Errors return JSON with at least error and often message and action for remediation.
401Unauthorized
Missing/invalid Bearer token or malformed API key.
403Forbidden
Sandbox restriction, or account suspended.
404Not found
Unknown email or domain id.
409Conflict
e.g. domain already added.
413Payload too large
Global body limit 2 MB per request.
422Unprocessable
Validation, unverified domain, or all recipients suppressed.
429Rate limited
Burst, daily, monthly, warm-up, or IP limits. Also returned when the free-plan monthly unique recipient cap (100) is exceeded.
500Server error
Unexpected failure — retry with backoff or contact support.
Example body
{
"error": "Domain \"example.com\" is not verified.",
"message": "Your sending domain must pass SPF/DKIM verification...",
"action": "Visit your ZidiMail dashboard → Domains → verify your DNS records."
}Rate limits
Multiple layers apply: per-IP limits on the API (e.g. login/register stricter than global traffic), per-org burst and “slow start” for new accounts, and plan daily/monthly quotas.
| Limit | Free | Pro | Scale |
|---|---|---|---|
| Monthly emails | 3,000 | 30,000 | 65,000 |
| Daily emails | 250 | Unlimited | Unlimited |
| Unique recipients/month | 100 | Unlimited | Unlimited |
| Burst rate | 2/sec | Unlimited | Unlimited |
| Overage | Hard stop | $1.50 / 1k | $1.60 / 1k |
Monthly quota resets on the 1st of each month (UTC). When blocked, expect 403 (recipient cap) or 429 (email quota) with an explanatory message and a resetsAt timestamp.
How recipients are counted
Every address in to, cc, and bcc counts as a separate recipient — both against your daily/monthly email quota and against the free-plan unique-recipient cap.
Example: one API call with 2 to addresses and 1 cc address consumes 3 emails from your daily quota and registers up to 3 new unique recipients against your monthly cap. Addresses already seen this month are not double-counted.
Attachments
You can attach files to any email — PDFs, images, spreadsheets, anything. Attachments work on all plans. There are two ways to include a file: you can either embed it directly in the request, or give ZidiMail a link to where the file is already stored.
Option 1 — Embed the file directly (most common)
Your code reads the file (e.g. a PDF you just generated), converts it to a Base64 string — think of Base64 as a way to turn any file into plain text so it can travel inside a JSON request — and puts that string in the content field. ZidiMail decodes it and attaches it to the email. No file hosting needed.
This is the best approach for files your server generates on the fly, like invoices, receipts, tickets, or reports.
curl -X POST https://api.zidimails.com/v1/emails \
-H "Authorization: Bearer zm_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "billing@yourdomain.com",
"to": "customer@example.com",
"subject": "Your invoice",
"html": "<p>Please find your invoice attached.</p>",
"attachments": [
{
"filename": "invoice-1234.pdf", // The name the recipient will see
"content": "JVBERi0xLjQK..." // Your file converted to a Base64 string
}
]
}'Option 2 — Link to a file you already have hosted
If your file is already stored somewhere online (like Amazon S3, Cloudflare R2, or your own server), you can give ZidiMail a public HTTPS direct download link. ZidiMail fetches the file at send time, rejects private/internal hosts, and counts downloaded bytes toward your plan's attachment limit.
"attachments": [
{
"filename": "terms-and-conditions.pdf", // The name the recipient will see
"path": "https://files.yourdomain.com/terms.pdf" // A direct link to your file
}
]When you copy a share link from Google Drive or Dropbox, it opens a webpage — not the actual file. ZidiMail needs an HTTPS link that downloads the raw file directly, like a link from Amazon S3, Cloudflare R2, or your own web server.
You can attach more than one file
Just add more items to the attachments array. You can mix and match — one file embedded directly, another from a URL, all in the same email. Up to 10 attachments per email.
"attachments": [
{
"filename": "invoice.pdf",
"content": "JVBERi0xLjQK..." // Embedded directly as Base64
},
{
"filename": "terms.pdf",
"path": "https://files.yourdomain.com/terms.pdf" // Fetched from your file storage
}
]How big can my attachments be?
The size limit applies to the total of all attachments combined in one email — not each file individually.
| Plan | Total attachment size per email |
|---|---|
| Free | 5 MB — good for most invoices and PDFs |
| Pro | 25 MB — suitable for larger reports and images |
| Scale | 40 MB — same as major providers like Resend |
Important things to know
- Always include a filename. The
filenamefield is required — it is what the recipient sees in their email client (e.g. "invoice-1234.pdf"). - Remote URLs must be public HTTPS URLs. Links to localhost, private networks, intranet hosts, or non-HTTPS URLs are rejected.
- Keep your own copy. ZidiMail does not store attachments after sending. If you need to retrieve what you sent, save the file on your end before sending.
- Batch sending. Attachments cannot be used when sending to multiple recipients via the batch endpoint.
- Coming soon. A future update will let you view and re-download attachments from sent emails directly in your ZidiMail dashboard.
Contact lists
Contact lists let you store, organise, and bulk-send to groups of email addresses directly from the ZidiMail dashboard. You can create named lists (e.g. Customers, Leads, VIP Members), import contacts from a CSV, Excel, or Google Sheet, and then pick a list as the recipient when composing a test send.
zm_token cookie set after login). API keys cannot create, import, or delete contacts. This prevents a leaked sending key from being used to exfiltrate your audience.Plan quotas
Every plan has three contact limits. All three are enforced server-side on every request — the dashboard UI also surfaces them, but the API is the authoritative gate.
| Plan | Total contacts | Per-import limit | Max lists |
|---|---|---|---|
| Free | 500 | 500 | 3 |
| Starter | 2,500 | 2,500 | 10 |
| Pro | 25,000 | 5,000 | 25 |
| Scale | 250,000 | 10,000 | 100 |
| Enterprise | 1,000,000 | 50,000 | 500 |
List management
Open Contacts in the dashboard sidebar to manage your lists. From there you can:
- Create a list — give it a name (and an optional description). You can have up to your plan's list limit at any one time.
- View members — click any list card to open a paginated member table showing email and name, with the option to remove individual contacts from the list.
- Delete a list — removes the list and all its memberships. The underlying contacts are not deleted; they stay in your org's contact pool and still count toward your total quota.
- Quota bar — a colour-coded bar at the top of the Contacts page shows how many of your plan's total slots are used. It turns yellow at 80 % and red at 95 %.
Sending to a list
In the Compose page, click From list next to the To field. Select a contact list from the dropdown. The send button dispatches one email per list member, showing live progress as each message is queued. Each individual send still passes through the normal domain verification, suppression, and rate-limit checks.
Importing contacts
Click Import contacts on the Contacts page to open the import wizard. It has four steps: choose a source, preview parsed contacts, optionally assign them to a list, and review the result.
Supported sources
.csvComma-separated values — the most portable format. Export from Excel, Google Sheets, Mailchimp, HubSpot, or any CRM using "Save as CSV" or "Export".
.xlsx / .xlsMicrosoft Excel workbooks. Only the first worksheet is read. Formulas, styles, and cell formatting are ignored — only the cell values matter.
Google SheetsPaste the sheet URL. The sheet must be shared as Anyone with the link → Viewer (no sign-in required). ZidiMail fetches the sheet as a CSV from Google's export endpoint on the server — no OAuth or Google account needed on your side.
Required columns
Your file or sheet must have a header row in the first row. Column matching is case-insensitive and looks for the word anywhere in the header name.
| Column | Required? | Accepted header names |
|---|---|---|
| Required | Any header containing the word email: Email, Email Address, email_address, Customer Email, etc. If no header contains “email”, the first column is used as a fallback. Rows where the value is not a valid email address (x@y.z pattern) are silently dropped. | |
| name | Optional | Any header containing the word name (but not the same column already mapped to email): Name, Full Name, First Name, Display Name, etc. If absent, contacts are stored with no name. |
| any other columns | Ignored | Phone, company, tags, and any other columns are passed over without error. You can export your full CRM dump — extra columns do not cause failures. |
Download the ZM-contacts-import-template.csv for a ready-to-fill starter file.
File size and row limits
Max file sizeRows per import422.Total contact poolAPI body limitHow duplicates are handled
The importer goes through four deduplication steps before writing anything to the database:
- Normalise. Every email address is lowercased and trimmed of whitespace.
User@Example.COMbecomesuser@example.com. - Self-deduplicate. If the same normalised address appears more than once in the file itself (e.g. a row duplicated by accident), only the first occurrence is kept. The rest are counted as skipped and do not consume quota slots.
- DB-deduplicate. The server checks which of the remaining addresses already exist in your organisation's contact pool. Existing addresses are also counted as skipped — they are not re-inserted and do not consume quota slots.
- Apply quota cap to truly-new only. After removing self-duplicates and DB-duplicates, the remaining genuinely new contacts are counted against your available slots. Only this net-new count is capped.
Example — free plan, 490 of 500 contacts used
You import a file with 100 rows: 30 are addresses already in your account, 70 are brand new.
Rows in file: 100
Self-duplicates: 0
Already in DB: 30 ← skipped, quota not charged
Truly new: 70
Available slots: 10 (500 − 490)
Inserted: 10 ← fills the 10 remaining slots
Capped (not added): 60 ← upgrade prompt shown
Without the DB-dedup step, those 30 duplicates would have appeared to "use" 30 of the 10 available slots, resulting in 0 contacts imported even though space existed. The dedup-first approach ensures every free slot is filled with an actual new contact.
Partial imports and the upgrade prompt
When new contacts are available but your quota is full or partially full, the importer adds as many as possible and reports back exactly what happened:
{
"ok": true,
"added": 10, // contacts actually inserted into the database
"skipped": 30, // duplicates (self or DB) — not charged
"capped": 60, // genuinely new rows dropped because quota was full
"available": 10, // slots that were free before this import
"listId": null, // list ID if you assigned contacts to a list
"quota": {
"maxContacts": 500,
"currentCount": 500 // after the import
}
}When capped is greater than zero, the dashboard shows a prominent banner: “N contacts were not imported — you hit your plan's contact limit. Upgrade your plan to import the rest.” The contacts that were capped are not lost — fix your quota (by upgrading or deleting existing contacts) and re-import the same file; duplicates will be skipped for free and only the remaining new ones will be added.
Assigning to a list during import
Step 3 of the import wizard lets you optionally place the imported contacts into a list. You can either:
- Assign to an existing list — select it from the dropdown. Any contacts from the import that already existed in your account are also added to the list (even though they count as “skipped” for quota purposes — they still belong there).
- Create a new list on the fly — type a name in the “New list name” field. The list is created and the imported contacts are assigned in the same operation. This counts toward your plan's list limit.
- No list — contacts go into your general contact pool and can be added to a list later.
Google Sheets — step by step
- Open your spreadsheet in Google Sheets. Make sure row 1 contains the column headers (
email,name, etc.). - Click File → Share → Share with others. Under General access, choose Anyone with the link and set the role to Viewer. Save.
- Copy the URL from your browser address bar (e.g.
https://docs.google.com/spreadsheets/d/ABC123.../edit). - In the ZidiMail import wizard, choose Google Sheets, paste the URL, and click Fetch Sheet.
- ZidiMail's server fetches the sheet as a CSV via Google's export API. Your browser never talks to Google directly, which avoids CORS restrictions.
SMTP relay
ZidiMail includes a full SMTP relay — so you can connect any application that supports standard SMTP without changing a single line of business logic. WordPress, Laravel, Django, legacy PHP apps, internal tools — if it can send email, it can send through ZidiMail.
Your ZidiMail API key doubles as the SMTP password. No separate credentials to manage. Every email sent via SMTP goes through the same delivery pipeline, suppression lists, analytics, and webhooks as the REST API.
Connection settings
WordPress (WP Mail SMTP)
Install the free WP Mail SMTP plugin, choose Other SMTP as the mailer, and enter:
SMTP Host: smtp.zidimails.com SMTP Port: 2525 Encryption: None SMTP Username: apikey SMTP Password: zm_live_YOUR_API_KEY From Email: you@yourdomain.com
PHPMailer
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = 'smtp.zidimails.com';
$mail->SMTPAuth = true;
$mail->Port = 2525;
$mail->Username = 'apikey';
$mail->Password = 'zm_live_YOUR_API_KEY';
$mail->setFrom('you@yourdomain.com', 'Your Name');
$mail->addAddress('recipient@example.com');
$mail->Subject = 'Hello from ZidiMail SMTP';
$mail->Body = '<p>Sent via SMTP relay.</p>';
$mail->isHTML(true);
$mail->send();Nodemailer (Node.js)
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: 'smtp.zidimails.com',
port: 2525,
auth: {
user: 'apikey',
pass: 'zm_live_YOUR_API_KEY',
},
})
await transporter.sendMail({
from: 'you@yourdomain.com',
to: 'recipient@example.com',
subject: 'Hello from ZidiMail SMTP',
html: '<p>Sent via SMTP relay.</p>',
})Django (Python)
# settings.py EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST = 'smtp.zidimails.com' EMAIL_PORT = 2525 EMAIL_USE_TLS = False EMAIL_HOST_USER = 'apikey' EMAIL_HOST_PASSWORD = 'zm_live_YOUR_API_KEY' DEFAULT_FROM_EMAIL = 'you@yourdomain.com'
Laravel (PHP)
# .env MAIL_MAILER=smtp MAIL_HOST=smtp.zidimails.com MAIL_PORT=2525 MAIL_USERNAME=apikey MAIL_PASSWORD=zm_live_YOUR_API_KEY MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS=you@yourdomain.com
Python (smtplib)
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
msg = MIMEMultipart('alternative')
msg['Subject'] = 'Hello from ZidiMail SMTP'
msg['From'] = 'you@yourdomain.com'
msg['To'] = 'recipient@example.com'
msg.attach(MIMEText('<p>Sent via SMTP relay.</p>', 'html'))
with smtplib.SMTP('smtp.zidimails.com', 2525) as s:
s.login('apikey', 'zm_live_YOUR_API_KEY')
s.sendmail(msg['From'], [msg['To']], msg.as_string())From address domain must be verified in your ZidiMail account under Domains. Sending from an unverified domain will be rejected at the SMTP level.Security notes
- API keys and webhook secrets are high-privilege — rotate if leaked.
- The API sets strict security headers (HSTS, frame denial, nosniff, referrer policy).
- JWTs gate dashboard operations; sending requires API keys so browser extensions cannot steal send capability from cookies alone if keys stay server-side.
- Verify webhook signatures before trusting events; reject replayed or tampered bodies.
Looking for what changed? View the changelog →