Domains
A domain must be verified before you can send from it or receive mail at it. This covers the model (one domain, two independent capabilities), the DNS records to publish, reading verify's response, pausing versus tearing down, and per-domain tracking.
One domain, two capabilities
A domain in zSender is one resource with two independent capabilities, sending and receiving. There is deliberately no domain-level status: each capability carries its own status, because a domain can be verified to send and not verified to receive (or the reverse), and collapsing two independent verification states into one value can only misrepresent one of them.
Each capability object is either null or a real object, and the two mean different things:
null: the capability has never been registered on this domain, or was torn down back to that same pristine state.{ enabled: false, ... }: paused. Pausing keeps the capability's status, its found DNS values, and, for sending, the DKIM keypair; for receiving, theverification_token. Nothing about the capability's verification history is lost, so re-enabling it doesn't force a fresh DNS round-trip.
One measured exception to that dichotomy: presence isn't stored, it's derived: sendingEnabled === true OR sendingStatus !== PENDING. A sending capability that has never verified and is then paused (PATCH {"capabilities":{"sending":{"enabled":false}}} on a row still sitting at PENDING) leaves both halves of that derivation false, so it serializes as sending: null: indistinguishable from "never registered." Receiving doesn't have this gap: its presence check reads the verification_token, which a pause never clears. This is a known, documented gap (there is no registered column to derive it from otherwise), not a bug to work around client-side.
The full field list for both capability objects is the generated reference at /docs/api/domains. This page won't repeat it.
Add a domain
POST /v1/domains registers a domain with either or both capabilities and returns its DNS records to publish in the same response: no follow-up read needed. Omitting capabilities entirely defaults to sending-only; an explicitly empty {} is a 400, because it names neither capability and expresses no intent.
curl -X POST https://api.zsender.io/v1/domains \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "example.com",
"capabilities": {
"sending": { "enabled": true },
"receiving": { "enabled": true }
}
}'The request body is .strict(): an unrecognized field is a 400 naming it, never a silent drop. Two rules worth knowing before you build the request:
enabledis required inside a capability object here. On create there is nothing else a named capability object could mean, so{"sending":{}}is rejected rather than silently treated as "yes, on."nameis immutable once set: there is no way to rename a domain. Delete it and add the new name instead.
Creating a domain needs an API key with the admin permission. See Permissions below.
The DNS records, published in one visit
Both the create response and GET /v1/domains/:id carry dns_records: one flat list across both capabilities, each row tagged with the capability it serves (sending or receiving). Sending needs SPF, a return-path CNAME, a DKIM TXT record, and, if you want open/click tracking, a track CNAME; receiving needs an ownership TXT record and an MX record. Publish everything in one registrar visit, then verify once. See /docs/api/domains for the exact record shapes. To look at a record you have published, use the free SPF, DKIM and DMARC checkers.
Verify and read results
POST /v1/domains/:id/verify re-runs DNS verification for every capability the domain has enabled and returns the serialized domain plus a results object: { sending, receiving }, each one of three values:
{
"results": {
"sending": "ok",
"receiving": "skipped"
}
}import { Zsender } from 'zsender-sdk';
const zsender = new Zsender(process.env.ZSENDER_API_KEY!);
// Re-runs DNS verification for every capability the domain has enabled and reports each
// in `results`: 'ok' (the check ran; may still be PENDING), 'failed' (the check could not
// complete), or 'skipped' (that capability isn't enabled on this domain).
const verified = await zsender.domains.verify('dom_123');
console.log(verified.results.sending, verified.results.receiving);ok: the check ran; its verdict is on the resource. This does not mean the capability is nowVERIFIED: a domain that hasn't published anything yet is a successful check with aPENDINGverdict, because "nothing is published" is itself an answer. A call that lands inside the server's 15-second per-domain cooldown also reportsok: it serves the row as last checked, which is a true statement about that row. If you poll this endpoint from more than one open tab, expect to see this.failed: the check could not be completed, never "not verified." A DNS resolver failure, a provisioning failure on the mail server, or, easy to miss, areceivingcapability that's enabled but has noverification_tokenyet, so the ownership check has nothing to look up.skippedmeans exactly one thing: that capability is not enabled on this domain. An absent capability is scored neither as a failure nor as a success.
Verifying needs the admin permission, same as create/update/delete. It's not a read: on a conclusive check it writes to the mail server to (re-)register the domain and re-evaluate tracking.
Pause versus tear down
PATCH /v1/domains/:id takes a capabilities object where each named capability carries only the fields you're changing:
{
"capabilities": {
"sending": {
"enabled": false
}
}
}{
"capabilities": {
"sending": null
}
}{ enabled: false } pauses: as above, this keeps the verification state, found DNS values, and (for sending) the DKIM keypair, so re-enabling doesn't require re-publishing anything. null tears the capability all the way down to its pristine, never-registered state.
On PATCH, enabled is optional inside a capability object. Unlike create, a PATCH that only wants to flip a tracking flag shouldn't also have to restate enabled. What's still rejected is a capability object naming no field at all: {"capabilities":{"sending":{}}} is a 400, same as {"capabilities":{}} one level up: both name something without saying what to do with it. null is a named value, not an empty object, so a teardown is never caught by this rule. Like create, the PATCH body is .strict() and name can't be sent at all: there's no field for it.
Open and click tracking
open_tracking and click_tracking live under capabilities.sending: receiving has no tracking concept, and sending either flag under receiving is rejected. Both default to false, and setting either true takes effect only while the track CNAME you publish for that domain actually resolves: publishing it is a precondition, not the switch itself.
{
"capabilities": {
"sending": {
"open_tracking": true,
"click_tracking": true
}
}
}Turning tracking off through this endpoint is safe: it's what stops the mail server from rewriting links on future sends. What isn't safe is doing it in the intuitive order: deleting the track CNAME breaks links already sent, permanently. Tracking rewrite is gated by whether a row exists on the mail server for that domain, not by live DNS, so if you remove the CNAME while that row is still there, every already-delivered message with a track.{domain} link (password resets and unsubscribe links included) now points at a host that no longer resolves, and there is no way to fix a link already sitting in someone's inbox. Setting open_tracking/click_tracking to false through the API clears that row for you; do that, confirm with a real send if you're unsure, and only remove the CNAME afterward if you want to reclaim the DNS name.
Tracking is configured per domain: there is no per-message override, because the mail server has no per-message lever at all. Sending email covers why transactional: true doesn't touch it.
Permissions
Domain routes don't share one permission. Listing and reading domains, including dns_records, need only read. Creating, updating, deleting, and verifying a domain all need admin: the domain lifecycle is account administration, not sending, so a key scoped to only send+read, the usual default for a server-side integration, gets a 403 the moment it tries to add or verify a domain. See Authentication for the full permission model.
Next
Once a domain's sending capability is VERIFIED and enabled, see Sending email. To receive mail at a domain, see Receiving email.