Sending domains

Add a domain, publish six DNS records, and send from your own address.

Until a domain is verified, the only address you can send from is onboarding@emails.sh, and that one only reaches addresses on your own workspace. Verifying a domain is what turns emails.sh into something your customers see.

Use a subdomain

Add mail.acme.com or send.acme.com rather than acme.com. Three reasons, and all of them matter later: a subdomain builds its own sending reputation, so a bad week of transactional mail does not follow your sales team into their inbox; you can publish an MX record on it for replies without competing with the mail host serving acme.com; and you can hand it a stricter DMARC policy than the parent domain is ready for.

Add it

POST /v1/domains
curl -X POST https://emails.sh/v1/domains \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domain": "mail.acme.com"}'
201 Created
{
  "id": "dom_01J9X8W1A2",
  "domain": "mail.acme.com",
  "verification_status": "pending",
  "records": [
    { "type": "CNAME", "name": "abc123._domainkey.mail.acme.com", "value": "abc123.dkim.amazonses.com", "purpose": "DKIM" },
    { "type": "CNAME", "name": "def456._domainkey.mail.acme.com", "value": "def456.dkim.amazonses.com", "purpose": "DKIM" },
    { "type": "CNAME", "name": "ghi789._domainkey.mail.acme.com", "value": "ghi789.dkim.amazonses.com", "purpose": "DKIM" },
    { "type": "TXT", "name": "_emailssh.mail.acme.com", "value": "emailssh-verify=9f2c4e1b", "purpose": "ownership" },
    { "type": "TXT", "name": "mail.acme.com", "value": "v=spf1 include:amazonses.com ~all", "purpose": "SPF" },
    { "type": "TXT", "name": "_dmarc.mail.acme.com", "value": "v=DMARC1; p=none;", "purpose": "DMARC" }
  ]
}

The records

RecordWhy it is there
CNAME ..._domainkeyThree of them, one per DKIM key. They let a receiving server check that the message body was signed by us and not altered on the way. Publish all three: rotation moves between them.
TXT _emailssh.<domain>Proves you control the domain. Nothing sends until this resolves.
TXT <domain> (SPF)v=spf1 include:amazonses.com ~all. If the domain already has an SPF record, add include:amazonses.com to the existing one rather than publishing a second: two SPF records is a hard failure, not a merge.
TXT _dmarc.<domain>v=DMARC1; p=none; to start. It tells receivers what to do when DKIM and SPF disagree, and turns on the reports you need before you tighten it.
MX <domain>Only if you want to receive replies at this domain. Optional, and on an apex domain it competes with whatever already serves your mail.

Verify

Publish the records, then ask for a check. Verification also runs nightly on its own, so a domain left alone finishes eventually, but the call is instant and tells you exactly which record has not landed.

Check now
curl -X POST https://emails.sh/v1/domains/dom_01J9X8W1A2/verify \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
One record still missing
{
  "domain": "mail.acme.com",
  "verified": false,
  "dkim_status": "pending",
  "records": [
    { "type": "TXT", "name": "_emailssh.mail.acme.com", "value": "emailssh-verify=9f2c4e1b", "purpose": "ownership", "found": true },
    { "type": "TXT", "name": "mail.acme.com", "value": "v=spf1 include:amazonses.com ~all", "purpose": "SPF", "found": false }
  ]
}

found: false on a record you published usually means the DNS host appended the zone to a name that was already absolute. If you entered _emailssh.mail.acme.com and your host shows _emailssh.mail.acme.com.mail.acme.com, enter just _emailssh instead.

One domain, by its id

The id in the create response addresses the domain on its own, so nothing has to filter the list to find one row. GET /v1/domains/:id returns it, PATCH /v1/domains/:id changes the one setting it has, and DELETE /v1/domains/:id removes it. The older DELETE /v1/domains?id= spelling still works and runs the same code, so anything already written against it keeps working; the path form is the one to write now, and it is the URL an SDK or a coding assistant reaches for first.

GET, PATCH, and DELETE /v1/domains/:id
# One domain, with the records still to publish if it is pending
curl https://emails.sh/v1/domains/dom_01J9X8W1A2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Put your own hostname in front of tracked links
curl -X PATCH https://emails.sh/v1/domains/dom_01J9X8W1A2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tracking_host": "links.mail.acme.com"}'

# Back to the shared tracking host
curl -X PATCH https://emails.sh/v1/domains/dom_01J9X8W1A2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tracking_host": null}'

# Remove it
curl -X DELETE https://emails.sh/v1/domains/dom_01J9X8W1A2 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

tracking_host is the only setting a domain has, and PATCH is a second door onto POST /v1/domains/:id/tracking rather than a second implementation of it: the host is validated, the CNAME is checked live, and the change is refused with 422 tracking_cname_not_found until the record resolves. A bare label is expanded under the domain, so "links" on mail.acme.com means links.mail.acme.com. Send null to go back to the shared host. See /docs/tracking-domain for the record itself.

A body carrying open_tracking or click_tracking is refused by name with 422 tracking_switch_not_supported rather than accepted and dropped. There is no such switch here: opens are reported per send and links are only rewritten on a broadcast that asked for it. A setting that reports success and changes nothing is worse than no setting, because it ends up on somebody's list of reasons to believe tracking is off.

DELETE answers 409 domain_in_use when sending addresses are still on the domain, and error.mailboxes carries the count of them, because removing the domain would take their mail with it. Remove those first. The shared workspace subdomain is not deletable through the API at all and answers 404, which is the same answer an id belonging to another workspace gets.

domains.list

Every sending domain on the workspace, with the DNS records a pending one still needs.

Takes no arguments.

Returns{ domains: Domain[] }

domains.create

Add a sending domain. The response carries every DNS record to publish, so setup can finish without opening the dashboard.

Arguments

domain string required
A domain or subdomain you control, for example mail.acme.com.

Returns{ id, domain, verification_status, records[] }

domains.get

One domain, in the shape the list gives it, with the DNS records still to publish if it is pending. An id from another workspace reads as missing.

Arguments

id string required
The id the create response returned.

ReturnsDomain

domains.verify

Check the records now rather than waiting for the nightly pass. Safe to call repeatedly, and the answer says which records are still missing.

Arguments

id string required

Returns{ domain, verified, records: (Record & { found })[] }

domains.delete

Remove a domain. Anything still sending from it starts failing, so move senders first.

Arguments

id string required

Returns{ deleted: id }

After it verifies

Change the from address in your code and send. Then move DMARC from p=none to p=quarantine once a week of reports shows your own mail passing, and warm up gradually rather than moving a hundred thousand messages a day onto a domain that sent nothing yesterday.