Skip to content
Docs menu

Receiving email

After this page you'll be able to turn a domain's receiving capability on, publish the one DNS record it needs, know exactly what zSender stores and when, read a message back through the API, and understand what the email.received webhook does and doesn't carry, including why forwarding to a real mailbox isn't something zSender does.

Receiving mail through zSender means: a message arrives at a domain you've verified for receiving, it's stored in your account and readable through the API, and, if you've subscribed an endpoint, you get a webhook telling you it arrived. There is no inbox and no forwarding to a mailbox; see What is not offered before you build around the wrong mental model.

Turn receiving on

Receiving is the receiving capability on the ordinary domain object, not a separate resource, not a separate API. The same domain row that sends mail can also receive it; enabling, verifying, pausing and tearing down both capabilities is covered in Domains and won't be repeated here. In short:

Enable receiving on a domain
curl -X POST https://api.zsender.io/v1/domains \
  -H "Authorization: Bearer YOUR_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "example.com",
    "capabilities": { "receiving": { "enabled": true } }
  }'

The response's dns_records includes an ownership TXT record and a routing MX record for the receiving capability. Publish both, then call POST /v1/domains/:id/verify once, the same way you would for sending. Only the TXT record gates verification; a missing or wrong MX is reported separately and never blocks it on its own, but get it right anyway, because it's the record that actually routes mail to you. See the next section.

The MX record: DNS-only, never proxied

The routing record is MX example.com pointing at mx.zsender.io, priority 10. It must be DNS-only. If your DNS host offers a proxy toggle (Cloudflare's orange cloud is the common case), it has to be off for this record: SMTP cannot be proxied through an HTTP/TLS proxy at all, proxied or not is not a matter of degree. Publish it proxied and mail addressed to this domain has nowhere to go; the failure looks identical to never having published the record in the first place, which is what makes it easy to publish once, watch verification succeed on the TXT record alone, and only notice the MX is wrong when real mail starts bouncing.

What happens when mail arrives

Every message addressed to a domain gets checked against a fixed sequence of gates, in this order, before anything is stored:

  1. The recipient domain has to resolve to a domain you own at all.
  2. Your tenant must not be suspended.
  3. Some user on your tenant must have completed email verification: receiving is gated on this exactly like sending and API-key creation are.
  4. The domain's receiving capability must be enabled. A domain can sit at a verified status with receiving paused, and paused mail is dropped the same as never having received the domain at all.
  5. The receiving capability's status must be VERIFIED: ownership proven, not merely enabled.

Fail any of these and the message is dropped without being stored: no row, nothing to read back later. Clear all five, and the message is stored first, before any webhook is even considered, and independent of whether you have an endpoint subscribed to email.received at all. If nobody is listening, the mail still exists and is still readable; only the notification is skipped. This is a deliberate design: mail arriving at a domain you proved you own is never silently lost because you hadn't wired up a webhook yet.

One consequence worth knowing if a message you expected never shows up: the receiver that accepts inbound mail from the mail server can never answer anything but a 200, by contract: a non-2xx there causes the mail server to bounce the message back to the original outside sender, not to you. So a dropped message never surfaces as an error on your side; it just isn't in your stored list. If mail to a domain you believe is fully set up isn't arriving, check, in this order, that your tenant isn't suspended, that some account on it has completed email verification, and that the domain's receiving capability reads VERIFIED and enabled on the Domains page.

Reading stored messages

Stored mail is read through four routes under /v1/emails/received: list, get one (with full text/html body), download an attachment's bytes, and fetch the raw MIME. Full field list: /docs/api/emails.

curl "https://api.zsender.io/v1/emails/received?limit=20" \
  -H "Authorization: Bearer YOUR_API_KEY"

These routes need a live key: a test-mode (zst_<prefix>_<secret>) key gets a 403 test_mode_restricted on all four. Inbound mail has no test mode: a stored message is always something that really arrived, so there's nothing for a sandbox key to legitimately read here.

Retention is 30 days from receipt, then swept: the same window zSender's webhook-delivery log keeps, so there's one retention number to remember for "how long do we keep something we received."

The raw MIME route is not the sender's original bytes

GET /v1/emails/received/:id/raw returns message/rfc822, but it's a live fetch of the mail server's stored copy, not what the sender actually transmitted. The mail server prepends four X-Postal-* headers on receipt (spam score, spam threshold, spam verdict, threat verdict); everything from the first Received: line onward is intact. Don't treat this route as forensic proof of exactly what crossed the wire. Treat it as "the message, plus four headers we can trust because we added them."

Because it's a live fetch rather than something zSender stores itself, two distinct failures can come back, and they mean opposite things:

  • 409 conflict: the mail server has already pruned its copy on its own age/disk policy, which zSender doesn't control. This can happen before your 30-day retention window closes. It will never succeed on retry; the bytes are gone.
  • 502 bad_gateway: the mail server couldn't be reached right now. The message is very likely still there; retry shortly.

Don't collapse these into one "raw fetch failed" case: one tells you to stop asking, the other tells you to ask again.

The email.received payload is metadata only

If you've subscribed an endpoint to email.received (covered in Webhooks, along with the event list and signature verification shared by every event zSender sends), the payload's data carries the message's envelope and headers: the stored message's id, received_at, from/from_address, to/cc (the raw header strings, not parsed address lists), received_for (the parsed envelope recipient, and the field to route on, not to), threading fields, subject, spam status, and per-attachment metadata (filename, content type and size, no bytes).

What it deliberately does not carry: the body, and no attachment bytes. No text, no html. This keeps the webhook payload small regardless of message size: a large inbound message with attachments could previously mean tens of megabytes of base64 landing on your endpoint, which is a poor fit for anything running behind a typical serverless request-body limit. Fetch the body and attachment bytes with the stored-message routes above once the webhook tells you a message id exists: the id in the payload is exactly the id those routes expect.

What is not offered: forwarding to a mailbox

If what you actually want is for mail sent to support@yourdomain.com to land in a real inbox (Gmail, Outlook, whatever you read email in) zSender's inbound is not that, and it never will be. Point that domain's MX at Cloudflare Email Routing instead: it's free, and it already does the parts a correct forwarder needs (SRS, ARC, and destination-address verification) that zSender's pipeline was never built for.

This isn't an oversight; it's a deliberate line, for reasons that don't go away with more engineering time:

  • Every tenant shares one sending IP. Forwarding means re-emitting arbitrary third-party mail, spam included, from that IP. One tenant forwarding to a catch-all that starts collecting spam degrades deliverability for every other tenant on the platform.
  • zSender's own pipeline would break the forwarded mail's authentication. Outbound mail gets a List-Unsubscribe header, a Message-ID, and (when tracking is on) a tracking pixel injected: every one of those is a body or header mutation. Doing that to a message you're forwarding invalidates the original sender's DKIM signature, which fails DMARC outright at any domain publishing p=reject: most enterprise senders, and Yahoo. A correct forwarder needs an entirely separate, byte-exact relay path with all of that disabled, plus SRS and ARC on top, not a flag on the send path zSender already has.
  • The alternative is free and already solves it. Cloudflare Email Routing forwards mail with SRS, ARC and destination verification built in, at no cost. There's no price zSender could charge that would make sense against that.

Want to process mail (parse it, file a ticket, thread a reply) rather than read it in an inbox? That's exactly what this page covers: the stored-message API and the email.received webhook.