ZevSend Docs
Sign up

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.

POST /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

FieldTypeRequiredDescription
tostring | string[]yesPrimary 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.
ccstring[]noCarbon-copy recipients (visible to To + Cc). Up to 50 entries.
bccstring[]noBlind-carbon-copy recipients. Delivered out-of-band — their addresses are NOT placed in the MIME headers. Up to 50 entries.
reply_tostringnoReply-To header. Hitting Reply in the recipient’s mail client pre-fills this address instead of from.
attachmentsobject[]noFile attachments. See Attachments below. Up to 20 per send.
fromstringlive modeSender 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_idstringnodn_… 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.
subjectstringdirect modeSubject line. Up to 200 characters.
htmlstringdirect mode*HTML body. At least one of html or text is required when not using a template.
textstringdirect mode*Plain-text body. Strongly recommended alongside html for accessibility and spam-filter safety.
template_idstringtemplate modePublic id of a pre-approved template (tpl_…). Mutually exclusive with subject / html / text.
variablesobjecttemplate modeValues 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"
}
FieldTypeDescription
idstringPublic id of the message (eml_…). Stable for the message’s lifetime.
tostringPrimary recipient. Always a single string — the first address in the To list.
to_addressesstring[]Full To list. At least one entry; the first matches to.
ccstring[]Cc recipients. Empty array when none.
bccstring[]Bcc recipients after any suppression-list drops. Empty array when none.
reply_tostring | nullReply-To header. null when not set.
fromstringThe resolved sender address.
subjectstringThe subject as delivered.
recipient_countintegerTotal recipients across To + Cc + Bcc (after Bcc suppression drops). This is the unit billed against your quota.
attachments_countintegerNumber of attachments. 0 when none.
statusstringInitial state. Almost always sending at this point.
sandboxbooleantrue for sandbox sends.
created_atstringISO 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 to or cc rejects the whole send with 403 recipient_suppressed. We name the blocked addresses in the error message — they’re already visible to the caller anyway.
  • A suppressed address in bcc is 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 normal 202 response.

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

LimitValue
Attachments per send20
Per-file size10 MB (after decode)
Total per send25 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

StateMeaning
queuedPersisted but not yet handed off.
sendingHanded off to the upstream network; awaiting accept.
sentAccepted by the upstream network and in transit.
deliveredConfirmed accepted by the recipient’s inbox provider.
bouncedHard bounce — the address is invalid or the inbox refused it permanently.
complainedRecipient flagged the message as spam.
failedRejected 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.

StatusCodeCause
409idempotency_key_reusedThe same key was used with a different request body. Use a new key for a different email.
409idempotency_request_in_progressAn 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

StatusCodeCause
400invalid_emailto isn’t a valid email address.
400missing_bodyDirect mode requires subject and at least one of html/text.
400mixed_send_modeYou passed both template_id and direct-mode fields.
400sandbox_from_lockedIn sandbox, from must be the platform sender.
403from_domain_unverifiedThe from address isn’t on a verified domain on your team.
403key_domain_scopeThe key is pinned to a different domain than the from.
400invalid_from_addressThe from value is not a valid bare email or RFC 5322 name-addr.
400display_name_not_approvedThe display name on from is not a registered + approved alternate for this domain. Submit it in the dashboard or omit it.
400display_name_id_not_foundfrom_display_id doesn’t match any record. Check the value in the dashboard.
400display_name_id_wrong_domainThe dn_… belongs to a different domain than your from addr.
400display_name_id_not_approvedThe display-name record exists but isn’t admin-approved yet.
403no_approved_brand_domainYou’re live but no verified domain on the team is approved yet.
403recipient_not_allowedSandbox: recipient hasn’t been verified on this team.
403recipient_suppressedRecipient is on the suppression list (bounce/complaint).
404template_not_foundTemplate mode: no such template on this team.
409wrong_template_channelThe template is for SMS or WhatsApp, not email.
409idempotency_key_reusedThe Idempotency-Key was already used with a different body.
409idempotency_request_in_progressAn earlier request with this Idempotency-Key is still running.
422missing_variableTemplate mode: a required variable was missing.
422unknown_variableTemplate 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