Skip to content
Docs menu

Sending email

Everything POST /v1/emails/send accepts beyond the basics: custom and reserved headers, threading a reply, transactional mail's exact two effects, tags, and the recipient caps.

Send a message

A send is one POST /v1/emails/send, authenticated with a key that carries the send permission. to, from, and subject are required, and you must supply html, text, or both: a body with neither is rejected. from accepts a bare address or a display name followed by one in angle brackets (Acme <hello@yourverifieddomain.com>), on a domain whose sending capability is VERIFIED and enabled.

curl -X POST https://api.zsender.io/v1/emails/send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["recipient@example.com"],
    "from": "hello@yourverifieddomain.com",
    "subject": "Hello from zSender",
    "text": "Your first email through the API."
  }'

The response carries a message_id and a per-recipient messages map. Keep the id if you plan to thread a reply (below) or look the message back up with GET /v1/emails/:id. The request body is strict: a field the schema doesn't recognize is a 400 validation_error, never a silent drop. That matters more than it sounds: it's the only reason a typo'd field, or one this guide hasn't caught up with yet, doesn't just vanish into a 200. The full field list, including attachments and cc/bcc, is the generated reference: /docs/api/emails.

Custom headers and reserved headers

headers is a flat string-to-string map merged into the outgoing message as-is:

{
  "headers": {
    "X-Order-Id": "4471",
    "X-Campaign": "post-purchase"
  }
}

A name from the reserved set is refused instead of silently dropped or overwritten: the response is a 400 validation_error whose details array names every rejected header at once, so you can fix a whole request in one round trip instead of discovering the list one header at a time: {"field": "headers.X-Postal-Tag", "message": "'X-Postal-Tag' is a reserved header and cannot be set through the API."}. Three groups make up the reserved set, each for a different reason:

  • Names the mail server overwrites anyway (it applies your custom headers first and then assigns its own to/from/subject/reply-to and, last, its own message-id), accepting one of these as a custom header would mean accepting a value that's guaranteed to be discarded. from, to, cc, bcc, sender, subject, reply-to, message-id, date and received. reply-to has its own typed field instead. Send a reply_to address at the top level of the request body, not a custom header.
  • Names zSender owns: return-path, list-unsubscribe, and list-unsubscribe-post: zSender builds and signs these itself (see Transactional mail, below), and in-reply-to/references, which have their own typed fields. See Threading, below.
  • MIME framing and the mail server's own control namespace: mime-version, content-type, content-transfer-encoding, dkim-signature, and the whole X-Postal- prefix. That whole prefix is reserved, not just the names in current use: X-Postal-Tag in particular would set a tag inside the mail server that never reaches this API's own tag field (see Tags, below), so it would silently disagree with it.

Header names must also be valid RFC 5322 field names (printable ASCII, no spaces, no colon) and values must not contain a line break: both are rejected the same way, named in the same details array.

Threading a reply

Use in_reply_to and references to thread a message under an earlier one. Both take an RFC 5322 Message-ID wrapped in angle brackets, e.g. <abc123@yourdomain.com>, and a value that isn't is rejected rather than repaired, so the caller finds out rather than getting a thread that silently didn't link. references takes either an array of ids or one space-separated string, oldest first.

There is no message_id input field

You cannot set a message's own Message-ID: the mail server assigns it last, after every other header, so accepting one as input would mean accepting a value guaranteed to be overwritten (the same reasoning as the reserved-header set above). To thread a reply, read message_id back off the send response of the message you're replying to, and pass that value as this send's in_reply_to. message_id is nullable: it comes back null when every recipient of that earlier send was suppressed and nothing reached the mail server (see Suppressions), so check for null before threading off it; passing null as in_reply_to is rejected with a 400.

Transactional mail

transactional: true marks a message as 1:1 operational mail (a password reset, a receipt, a support reply) rather than list mail. It has exactly two effects:

  1. The signed, one-click List-Unsubscribe/List-Unsubscribe-Post pair that every other send gets automatically is skipped entirely: a message nobody can subscribe to shouldn't offer to unsubscribe them, and mail clients render that offer as a one-click button that would suppress the recipient for everything the tenant sends, including the password reset they were trying to read.
  2. A recipient already on the tenant's suppression list for reason UNSUBSCRIBE or MANUAL is delivered to anyway. HARD_BOUNCE and COMPLAINT suppressions are never overridden, transactional or not: every tenant shares one sending IP, and overriding those would damage deliverability for all of them.

That's the whole list: transactional mail does not disable open or click tracking, and no header or field can. Tracking is controlled per domain (PATCH /v1/domains/:id with capabilities.sending.open_tracking / click_tracking. See Domains), never per message.

There is also a per-link escape: write a URL as https+notrack:// and it will not be rewritten for click tracking. You do not need to know the domain's tracking state to use it: the marker is removed on the way out either way, so the scheme means the same thing whether click tracking is on or off.

One exception, on the SMTP relay only. That transport hands your MIME onward byte for byte and never decodes it, so a marker inside a base64-encoded part cannot be removed and is delivered as the literal https+notrack://example.com/page, which no browser can open. Quoted-printable and plain 7bit/8bit parts are handled, including a marker that quoted-printable split across a line break. If your mail library base64-encodes HTML bodies, write a plain https:// URL instead: with click tracking off, and for as long as it stays off, nothing rewrites it anyway. The JSON send API on this page has no such gap for the html and text you submit; an HTML attachment is not scanned on either transport.

Tags

tag is a free-form string you attach at send time. It's indexed, so it drives GET /v1/emails?tag= (an exact match) and the by_tag breakdown on GET /v1/analytics: a cheap way to separate, say, receipt-monthly from password-reset in your delivery and engagement numbers without standing up a second sending domain.

Recipients and the cap

to, cc, and bcc are each validated independently, and each is capped at 50 addresses: there is no combined limit across all three, but a message with all three at the cap addresses up to 150 recipients in one call. to also requires at least one address; the whole send fails validation if it's empty.

Sending through the SMTP relay instead of this HTTP endpoint has a different cap, enforced at the SMTP envelope rather than in this schema: 50 total RCPT TO recipients across to+cc+bcc combined, past which the relay answers 550. Don't assume the two caps mean the same thing.

Retrying a send safely, without risking a duplicate, is a separate concern from anything on this page: pass an Idempotency-Key header, covered in Limits and reliability.