API Reference
Send email
POST /v1/emails — deliver transactional email with a raw HTML body, or render against a pre-approved template.
Send a transactional email to a single recipient. You own the subject and body; we handle deliverability, signing (DKIM, SPF, DMARC), and the lifecycle events your app reacts to.
/v1/emails Quick example
The body of your email is generated in your codebase the same way you’d render it for any other transactional email service. Pass the HTML (and optionally a plain-text fallback) and we’ll deliver it.
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "noreply@acme.com",
"to": "ada@example.com",
"subject": "Your receipt for order 12345",
"html": "<p>Hi Ada,</p><p>Thanks for your order. Your receipt is attached.</p>",
"text": "Hi Ada, Thanks for your order. Your receipt is attached."
}'
Request body
| Field | Type | Required | Description |
|---|---|---|---|
to | string | string[] | yes | Primary recipient(s). A single email string or an array of up to 50 addresses. Combined with cc + bcc the total must stay at or under 50. |
cc | string[] | no | Carbon-copy recipients (visible to To + Cc). Up to 50 entries. |
bcc | string[] | no | Blind-carbon-copy recipients. Delivered out-of-band — their addresses are NOT placed in the MIME headers. Up to 50 entries. |
reply_to | string | no | Reply-To header. Hitting Reply in the recipient’s mail client pre-fills this address instead of from. |
attachments | object[] | no | File attachments. See Attachments below. Up to 20 per send. |
from | string | live mode | Sender address. Accepts either a bare email ("noreply@acme.com") or RFC 5322 name-addr ("Acme Receipts <noreply@acme.com>"). Must be on one of your verified + approved domains. Sandbox sends are locked to the platform sender. See Sender display name below. |
from_display_id | string | no | dn_… public id of a registered + admin-approved alternate display name on the sending domain. Recommended over inlining the display string in from — id-based lookup is typo-proof. |
subject | string | direct mode | Subject line. Up to 200 characters. |
html | string | direct mode* | HTML body. At least one of html or text is required when not using a template. |
text | string | direct mode* | Plain-text body. Strongly recommended alongside html for accessibility and spam-filter safety. |
template_id | string | template mode | Public id of a pre-approved template (tpl_…). Mutually exclusive with subject / html / text. |
variables | object | template mode | Values for the template’s declared customer.* variables. |
Two modes for one endpoint
Direct mode
The default path for transactional email. Your codebase owns the body — render it with React Email, MJML, Handlebars, a string template, whatever you already use — and we deliver it. This is how teams migrating from Resend, Postmark, SendGrid, or SES typically integrate.
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-d '{
"from": "noreply@acme.com",
"to": "ada@example.com",
"subject": "Welcome to Acme",
"html": "<p>Hi Ada, welcome aboard.</p>"
}'
Template mode
Reference a template by id and pass per-send variables. The template owns the subject and body; you supply the values. Useful when:
- Your support team needs to tweak wording without a code deploy.
- You want server-injected brand identity (
{{brand.name}}etc.). - You’re sending the same message to many recipients with different per-recipient data.
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-d '{
"to": "ada@example.com",
"template_id": "tpl_01HXYZ...",
"variables": { "first_name": "Ada", "order_id": "ORD-1234" }
}'
See the Templates guide for the full walkthrough.
Response
Every successful send returns the same shape. Fields are always
present, with empty arrays and null for anything the send did
not use — so you can rely on the structure without checking
which fields exist.
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"id": "eml_01HXYZ...",
"to": "ada@example.com",
"to_addresses": ["ada@example.com"],
"cc": [],
"bcc": [],
"reply_to": null,
"from": "noreply@acme.com",
"subject": "Your receipt for order 12345",
"recipient_count": 1,
"attachments_count": 0,
"status": "sending",
"sandbox": false,
"created_at": "2025-05-24T12:34:56.789Z"
}
| Field | Type | Description |
|---|---|---|
id | string | Public id of the message (eml_…). Stable for the message’s lifetime. |
to | string | Primary recipient. Always a single string — the first address in the To list. |
to_addresses | string[] | Full To list. At least one entry; the first matches to. |
cc | string[] | Cc recipients. Empty array when none. |
bcc | string[] | Bcc recipients after any suppression-list drops. Empty array when none. |
reply_to | string | null | Reply-To header. null when not set. |
from | string | The resolved sender address. |
subject | string | The subject as delivered. |
recipient_count | integer | Total recipients across To + Cc + Bcc (after Bcc suppression drops). This is the unit billed against your quota. |
attachments_count | integer | Number of attachments. 0 when none. |
status | string | Initial state. Almost always sending at this point. |
sandbox | boolean | true for sandbox sends. |
created_at | string | ISO 8601 UTC timestamp of the request. |
Sender display name
The from field accepts both a bare email and an RFC 5322
name-addr. zevsend always emits a properly composed From: header
on the wire so recipients see a brand name, not a bare address.
Default — auto-injected brand
If you pass just the email, the display name is taken from the primary brand identity you submitted in the dashboard for that domain. This is the common path.
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-d '{
"from": "noreply@acme.com",
"to": "ada@example.com",
"subject": "Welcome to Acme",
"html": "<p>Hi Ada, welcome aboard.</p>"
}'
Recipient sees:
From: Acme <noreply@acme.com>
The brand name is server-controlled — a leaked key can’t change how your sends present to recipients.
Override — an approved alternate display name
When you need a second brand string on the same domain (e.g.
“Acme Receipts” vs your primary “Acme”), register it in the
dashboard under Domains → [your domain] → Additional display
names and submit it for review. The submission gets a stable
dn_… id immediately, but it only becomes usable on the API
once an admin approves it.
You can reference an approved alternate two ways. Both produce
the same From: header on the wire.
By id (recommended) — copy the dn_… from the dashboard and
pass it on from_display_id. The id is immune to typos, casing
drift, or accidental edits to the display string in your codebase.
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-d '{
"from": "noreply@acme.com",
"from_display_id": "dn_8AYnhCe0z1C4",
"to": "ada@example.com",
"subject": "Receipt for your order",
"html": "<p>Thanks for your purchase.</p>"
}'
By name (RFC 5322) — embed the display string inline. The
server normalises the match (case-insensitive, whitespace-
collapsed) and looks it up against your approved alternates.
Useful when you’re migrating from another provider that already
emits this format. A typo or wording drift will fail the lookup
with display_name_not_approved.
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-d '{
"from": "Acme Receipts <noreply@acme.com>",
"to": "ada@example.com",
"subject": "Receipt for your order",
"html": "<p>Thanks for your purchase.</p>"
}'
If neither path matches a registered + approved alternate (and the string isn’t an exact match for your primary brand), the request is rejected. Send without any display string or id to fall back to your primary brand automatically.
Recipients — cc, bcc, reply-to
Pass cc and bcc as arrays of email addresses. The combined
count across to + cc + bcc must stay at or under 50.
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "noreply@acme.com",
"to": ["ada@example.com"],
"cc": ["accounts@example.com"],
"bcc": ["audit@acme.com"],
"reply_to": "support@acme.com",
"subject": "Your receipt for order 12345",
"html": "<p>Hi Ada, your receipt is below.</p>"
}'
to is the primary recipient. Pass a single string for
backward compatibility ("to": "ada@example.com") or an array for
multiple addresses.
cc addresses appear in the visible Cc header alongside the
To list. Every recipient sees them.
bcc addresses receive the message but are not placed in any
visible header. To and Cc recipients can’t see them; the bcc
address knows it was a bcc only because the message arrived. We
deliver bcc out-of-band per RFC 5322 §3.6.3.
reply_to sets the Reply-To header. When set, recipients
hitting Reply pre-fill this address instead of the From address.
Useful when you send from noreply@ but want replies to land
with your support team.
Suppressed recipients
Each team carries a suppression list of addresses that previously hard-bounced or complained.
- A suppressed address in
toorccrejects the whole send with403 recipient_suppressed. We name the blocked addresses in the error message — they’re already visible to the caller anyway. - A suppressed address in
bccis silently dropped and the send proceeds with the remaining recipients. Surfacing the block would leak the fact that the bcc address exists to the caller, which is the inverse of what bcc is for. The drop is logged in ops; the customer sees a normal202response.
To clear an address from the suppression list, use the dashboard
Suppressions page or the DELETE /v1/suppressions/:email
endpoint.
Attachments
Send up to 20 files per message. Each attachment is an object
with filename, content (base64-encoded bytes), and
content_type.
curl https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "billing@acme.com",
"to": "ada@example.com",
"subject": "Your invoice",
"html": "<p>Your invoice is attached.</p>",
"attachments": [
{
"filename": "invoice-12345.pdf",
"content_type": "application/pdf",
"content": "JVBERi0xLjQKJ..."
}
]
}'
Limits
| Limit | Value |
|---|---|
| Attachments per send | 20 |
| Per-file size | 10 MB (after decode) |
| Total per send | 25 MB (sum) |
Allowed content types
We accept common business document and image types. Anything else
returns 400 unsupported_attachment_type.
- Documents:
application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation - Data:
application/json,text/csv,text/plain,text/calendar - Archives:
application/zip,application/x-zip-compressed - Images:
image/png,image/jpeg,image/gif,image/webp,image/svg+xml
Executables, scripts, and other delivery-risky types are deliberately blocked. If you have a legitimate need for a type outside this list, contact support.
Encoding the content
The content field expects standard base64 (RFC 4648). Most
languages and HTTP clients have a built-in encoder.
// Node
const fs = require('fs');
const content = fs.readFileSync('./invoice.pdf').toString('base64');
# Python
import base64
with open('invoice.pdf', 'rb') as f:
content = base64.b64encode(f.read()).decode()
# Ruby
require 'base64'
content = Base64.strict_encode64(File.binread('invoice.pdf'))
Lifecycle
| State | Meaning |
|---|---|
queued | Persisted but not yet handed off. |
sending | Handed off to the upstream network; awaiting accept. |
sent | Accepted by the upstream network and in transit. |
delivered | Confirmed accepted by the recipient’s inbox provider. |
bounced | Hard bounce — the address is invalid or the inbox refused it permanently. |
complained | Recipient flagged the message as spam. |
failed | Rejected at submit or permanent failure before reaching the inbox. |
State changes fire as webhook events. You can also look up the current state from the dashboard.
Retrying safely with an idempotency key
If your process crashes after calling us but before it records the result, you cannot know whether the email went out. Retrying might send it twice; not retrying might lose it.
Send an Idempotency-Key header and the retry is safe. The first
request sends the email and we remember the response. A retry with the
same key returns that same response without sending again.
curl -X POST https://api.zevsend.com/v1/emails \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-receipt" \
-d '{
"from": "receipts@acme.com",
"to": "ada@example.com",
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>"
}'
The header is optional. Leave it off and the endpoint behaves exactly as it always has.
Choosing a key. Use something your own system can derive again after a restart, such as the id of the thing the email is about. A random value generated fresh on each attempt defeats the purpose. Keys are up to 255 characters and are scoped to the API key that sent them, so two integrations on the same team never collide.
How long it lasts. A key is remembered for 24 hours. That is the window in which a retry makes sense; after it, the same key is treated as a new request.
A replayed response carries Idempotency-Replayed: true and is
byte-for-byte what the first call returned, including the original
message id. Nothing is sent.
| Status | Code | Cause |
|---|---|---|
409 | idempotency_key_reused | The same key was used with a different request body. Use a new key for a different email. |
409 | idempotency_request_in_progress | An earlier request with this key is still running. Retry in a few seconds. |
If a send fails, the key is released, so you can retry it with the same key once you have fixed the cause.
Pricing
Email is included in your plan up to the monthly quota. Past the quota, each send drains the template’s per-send price from Zev Credit. Most email templates ship priced at zero, so customers who stay within their plan quota never see a charge.
See the Pricing guide for the charge cascade and how Zev Credit is debited.
Errors
| Status | Code | Cause |
|---|---|---|
400 | invalid_email | to isn’t a valid email address. |
400 | missing_body | Direct mode requires subject and at least one of html/text. |
400 | mixed_send_mode | You passed both template_id and direct-mode fields. |
400 | sandbox_from_locked | In sandbox, from must be the platform sender. |
403 | from_domain_unverified | The from address isn’t on a verified domain on your team. |
403 | key_domain_scope | The key is pinned to a different domain than the from. |
400 | invalid_from_address | The from value is not a valid bare email or RFC 5322 name-addr. |
400 | display_name_not_approved | The display name on from is not a registered + approved alternate for this domain. Submit it in the dashboard or omit it. |
400 | display_name_id_not_found | from_display_id doesn’t match any record. Check the value in the dashboard. |
400 | display_name_id_wrong_domain | The dn_… belongs to a different domain than your from addr. |
400 | display_name_id_not_approved | The display-name record exists but isn’t admin-approved yet. |
403 | no_approved_brand_domain | You’re live but no verified domain on the team is approved yet. |
403 | recipient_not_allowed | Sandbox: recipient hasn’t been verified on this team. |
403 | recipient_suppressed | Recipient is on the suppression list (bounce/complaint). |
404 | template_not_found | Template mode: no such template on this team. |
409 | wrong_template_channel | The template is for SMS or WhatsApp, not email. |
409 | idempotency_key_reused | The Idempotency-Key was already used with a different body. |
409 | idempotency_request_in_progress | An earlier request with this Idempotency-Key is still running. |
422 | missing_variable | Template mode: a required variable was missing. |
422 | unknown_variable | Template mode: you passed a variable the template doesn’t declare. |
Suppression list
When a recipient bounces or complains, we automatically add them
to your team’s suppression list. Further sends to that address
return 403 recipient_suppressed without consuming quota. Manage
suppressions from the dashboard under Suppressions.
Updated at, Thursday, October 1, 2026