Documentation

Public docsYou can read this page without an account. Links to paths like /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.

API origin:https://api.zidimails.com·Versioned prefix:/v1

Overview

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_token cookie after login; many read endpoints accept either JWT or API key via the same Authorization: Bearer header.
  • Inbound provider hooks (/inbound/ses, /inbound/mailgun) are internal — not for customer use.

Quick start

  1. Create an account and confirm your email if prompted.
  2. Open Domains, add your sending domain, and add the DNS records shown (SPF, DKIM, DMARC as applicable). Use Verify until the domain is verified.
  3. Open API Keys, create a key, and copy the full secret once — it is shown only at creation time.
  4. Call POST https://api.zidimails.com/v1/emails from your server with Authorization: Bearer <full key>.
  5. Optionally configure Webhooks for delivery events.

Dashboard areas

Emails

Searchable log of sent messages, statuses, and per-email detail.

Domains

Add domains, copy DNS records, run verification, remove domains.

API Keys

Create and revoke keys. Full key secret is shown only once.

Webhooks

Register HTTPS endpoints, choose events, copy signing secret once.

Analytics

Charts and delivery metrics driven by /v1/emails/stats.

Billing

Plan upgrades (PayPal subscription flow) and quota context.

Settings

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.

CORS is restricted to 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

POST{origin}/v1/emails

Requires an API key (not JWT alone). The from address must use a domain that is verified for your organization.

Request body

fromrequired
string
Sender email; domain must match a verified domain.
torequired
string | string[]
Recipient(s).
subjectrequired
string
Subject line (max 998 chars per RFC).
html
string
HTML body. At least one of html or text is required.
text
string
Plain-text body.
cc
string | string[]
CC recipients.
bcc
string | string[]
BCC recipients.
reply_to
string
The address recipients see when they hit Reply. If omitted, replies go to the from address — which on a sending subdomain (e.g. mail.yourdomain.com) is usually a dead end. Always set this to a real monitored inbox.
headers
Record<string,string>
Custom headers (key-value).
tags
{ name, value }[]
Optional metadata tags (for your own analytics pipelines).

💡 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

GET{origin}/v1/emails

List recent messages for the organization. Accepts JWT or API key.

limit
number
1–100, default 50.
page
number
Page index, default 1.
search
string
Filters by recipient substring, subject, or from.
curl "https://api.zidimails.com/v1/emails?limit=20&page=1" \
  -H "Authorization: Bearer <jwt_or_api_key>"

Get one email

GET{origin}/v1/emails/:id

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

GET{origin}/v1/emails/stats

Aggregates sent volume and rates for the org. Accepts JWT or API key.

days
number
Lookback window, capped at 90, default 30.

Response 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

POST{origin}/v1/domains
domainrequired
string
Hostname, e.g. mail.example.com or example.com.

Returns id, domain, and dns_records for SPF/DKIM/DMARC and Mailgun-aligned records.

List domains

GET{origin}/v1/domains

Get domain

GET{origin}/v1/domains/:id

Includes merged DNS guidance and verification flags after checks.

Verify DNS

POST{origin}/v1/domains/:id/verify

Re-runs SPF/DKIM (and related) checks and updates stored verification. Response includes checks and verified when SPF and DKIM pass.

Remove domain

DELETE{origin}/v1/domains/:id

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

POST{origin}/v1/api-keys
namerequired
string
Display label (1–100 chars).

Response includes key once: zm_live_<id>_<secret>. Save it immediately; only the prefix is listed afterward.

List keys

GET{origin}/v1/api-keys

Revoke key

DELETE{origin}/v1/api-keys/:id

Webhooks API

Register HTTPS URLs to receive JSON notifications when delivery events occur. Requires JWT or API key for management routes.

Create webhook

POST{origin}/v1/webhooks
urlrequired
string (URL)
Must be a valid https URL.
eventsrequired
string[]
Non-empty subset of: email.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

GET{origin}/v1/webhooks

Secrets are omitted from list responses.

Delete webhook

DELETE{origin}/v1/webhooks/:id

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 as type in 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.

401

Unauthorized

Missing/invalid Bearer token or malformed API key.

403

Forbidden

Sandbox restriction, or account suspended.

404

Not found

Unknown email or domain id.

409

Conflict

e.g. domain already added.

413

Payload too large

Global body limit 2 MB per request.

422

Unprocessable

Validation, unverified domain, or all recipients suppressed.

429

Rate limited

Burst, daily, monthly, warm-up, or IP limits. Also returned when the free-plan monthly unique recipient cap (100) is exceeded.

500

Server 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.

LimitFreeProScale
Monthly emails3,00030,00065,000
Daily emails250UnlimitedUnlimited
Unique recipients/month100UnlimitedUnlimited
Burst rate2/secUnlimitedUnlimited
OverageHard 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
  }
]
⚠ Google Drive and Dropbox share links will not work.
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.

PlanTotal attachment size per email
Free5 MB — good for most invoices and PDFs
Pro25 MB — suitable for larger reports and images
Scale40 MB — same as major providers like Resend

Important things to know

  • Always include a filename. The filename field 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.

Dashboard onlyContact management routes require a dashboard JWT (the 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.

PlanTotal contactsPer-import limitMax lists
Free5005003
Starter2,5002,50010
Pro25,0005,00025
Scale250,00010,000100
Enterprise1,000,00050,000500

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

.csv

Comma-separated values — the most portable format. Export from Excel, Google Sheets, Mailchimp, HubSpot, or any CRM using "Save as CSV" or "Export".

.xlsx / .xls

Microsoft Excel workbooks. Only the first worksheet is read. Formulas, styles, and cell formatting are ignored — only the cell values matter.

Google Sheets

Paste 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.

ColumnRequired?Accepted header names
emailRequiredAny 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.
nameOptionalAny 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 columnsIgnoredPhone, 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 size
10 MB
Checked in the browser before the file is parsed. Files larger than 10 MB are rejected with an error; split into smaller batches.
Rows per import
plan limit
The client caps the parsed row count to your plan's per-import limit before sending. The API also enforces this as a hard ceiling — if you somehow send more rows than your plan allows, the entire request is rejected with 422.
Total contact pool
plan limit
Your organisation has a total contact ceiling. Importing stops filling slots once it is hit (see partial import behaviour below).
API body limit
2 MB
The import endpoint accepts JSON up to 2 MB. Very large imports may need to be split into smaller batches, especially when rows include long names or email addresses.

How duplicates are handled

The importer goes through four deduplication steps before writing anything to the database:

  1. Normalise. Every email address is lowercased and trimmed of whitespace. User@Example.COM becomes user@example.com.
  2. 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.
  3. 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.
  4. 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

  1. Open your spreadsheet in Google Sheets. Make sure row 1 contains the column headers (email, name, etc.).
  2. Click File → Share → Share with others. Under General access, choose Anyone with the link and set the role to Viewer. Save.
  3. Copy the URL from your browser address bar (e.g. https://docs.google.com/spreadsheets/d/ABC123.../edit).
  4. In the ZidiMail import wizard, choose Google Sheets, paste the URL, and click Fetch Sheet.
  5. 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.
Sheets larger than 10 MB of CSV text are rejected. If your sheet is very large, export a filtered range or split the sheet by filtering rows and exporting multiple CSVs.

SMTP relay

✦ Pro featureAvailable on Pro and Scale plans

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

Hostsmtp.zidimails.com
Port2525
Usernameapikey
Passwordzm_live_YOUR_API_KEY
EncryptionNone / STARTTLS (port 2525)
From addressMust be on a verified domain

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 must be verified. The 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 →