Sending email with HostSSH
Add a sending domain, publish and verify your DKIM/SPF/DMARC records, check deliverability health, mint an API key, and send transactional email through your own MTA.
Sending email with HostSSH
HostSSH ships a self-hosted, white-label email engine: your own sending domains, your own DKIM keys, a Resend-shape send API, and delivery through your MTA — no SendGrid, Mailgun, SES, or Resend in the path. Nothing carries a vendor identity: the From, bounce, and DKIM domains are always your brand.
This guide walks the whole flow: register a sending domain, publish and verify its DNS, check deliverability health, create an API key, and send a message.
Live outbound: set
HOSTSSH_SMTP_URLto your MTA (fleet default:smtp://usermails-mta:25) and the monitors cron drains the send queue. Domain onboarding can Publish via Cloudflare when a DNS token is configured.
What you'll need
- An account with the Email & Deliverability add-on entitled on your plan.
- A domain you control the DNS for (to add DKIM/SPF/DMARC records).
- An API key (
hs_live_…) — see Create an API key.
Everything below is done over the API. Point the examples at your control-plane host and swap in your key.
1. Add a sending domain
Registering a domain generates a fresh DKIM keypair for it (the private key is sealed at rest — you never see it), and returns the DNS records you must publish.
curl -X POST https://<control-plane>/v1/email/domains \
-H "Authorization: Bearer hs_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"domain": "mail.example.com",
"selector": "hs",
"algorithm": "rsa-sha256",
"spfInclude": "spf.example.net",
"dmarcRua": "dmarc@example.com"
}'
Only domain is required. The rest are optional:
| Field | Default | What it does |
|---|---|---|
selector | hs | The DKIM selector — your key lives at <selector>._domainkey.<domain> |
algorithm | rsa-sha256 | DKIM signing algorithm — rsa-sha256 (RSA-2048) or ed25519-sha256 |
spfInclude | — | A host to add as include: in your SPF record |
bounceDomain | — | A domain you control for bounce return-paths |
dmarcRua | — | An address to receive DMARC aggregate reports |
The response gives you the domain id, its status (pending), and the exact records to
publish:
{
"id": "emd_…",
"domain": "mail.example.com",
"status": "pending",
"records": [
{ "type": "TXT", "name": "hs._domainkey.mail.example.com",
"value": "v=DKIM1; k=rsa; p=MIIBIjAN…", "purpose": "dkim" },
{ "type": "TXT", "name": "mail.example.com",
"value": "v=spf1 include:spf.example.net ~all", "purpose": "spf" },
{ "type": "TXT", "name": "_dmarc.mail.example.com",
"value": "v=DMARC1; p=none; rua=mailto:dmarc@example.com", "purpose": "dmarc" }
]
}
2. Publish the DNS records
Add each record from the response to your DNS provider exactly as given:
- DKIM — a
TXTrecord at<selector>._domainkey.<domain>carrying your public key (v=DKIM1; k=…; p=…). This is what lets receivers verify your signature. - SPF — a single
TXTrecord on the domain apex. If you passedspfInclude, it'sv=spf1 include:<host> ~all; otherwise a barev=spf1 ~all. Keep it to one SPF record — multiplev=spf1records are an error that verification will flag. - DMARC — a
TXTrecord at_dmarc.<domain>with ap=nonestarter policy (and yourrua=address if you supplied one). DMARC is recommended, not required, to verify.
HostSSH doesn't publish these for you — you paste them into your own DNS (or wire up a DNS integration). Verification then checks the live DNS, so give records a few minutes to propagate.
3. Verify the domain
Once the records are live, ask HostSSH to confirm them. It reads the live DNS (via the
system resolver — no third-party lookup) and flips the domain to verified when DKIM
and SPF both pass.
curl -X POST https://<control-plane>/v1/email/domains/emd_…/verify \
-H "Authorization: Bearer hs_live_YOUR_KEY"
{
"id": "emd_…",
"status": "verified",
"verification": {
"domain": "mail.example.com",
"dkim": "verified",
"spf": "verified",
"dmarc": "verified",
"overall": "verified",
"details": []
}
}
How each check is read:
- DKIM —
verifiedwhen the selector TXT contains your published key;failedif a record exists but the key doesn't match;pendingif there's no record yet. - SPF —
verifiedfor exactly onev=spf1record (that includes yourspfInclude, if set);failedfor multiple SPF records or a missing include;pendingif none is found. - DMARC —
verifiedif anyv=DMARC1record is present, otherwisepending. It's advisory and never blocks verification.
If overall isn't verified yet, details tells you exactly what's missing. Verify is
read-only and safe to re-run — a transient DNS hiccup won't un-verify a domain that
was already good.
4. Create an API key
Sending requires an hs_live_… API key. Today, keys are minted through the operator
endpoint (this is the credential the platform uses now; a self-serve dashboard flow lands
with better-auth). The plaintext token is shown exactly once — store it immediately.
// POST /v1/email/keys →
{
"id": "emk_…",
"token": "hs_live_xxxxxxxxxxxxxxxxxxxxxxxx",
"keyPrefix": "hs_live_xxxx",
"tenantId": "…",
"name": "production"
}
Only token is secret and only keyPrefix is ever shown again in dashboards. If a key
leaks, revoke it — revocation is immediate and idempotent.
5. Check deliverability health
Before (and after) you go live, run a deliverability health check on a sending domain. It's a read-only analysis of your live mail DNS — MX, SPF (including the RFC 7208 10-lookup budget), DMARC policy, and DKIM — scored 0–100 with a plain-English risk list.
curl https://<control-plane>/v1/email/domains/emd_…/health \
-H "Authorization: Bearer hs_live_YOUR_KEY"
{
"id": "emd_…",
"health": {
"domain": "mail.example.com",
"score": 92,
"mx": [ … ],
"spf": { "record": "v=spf1 …", "recordCount": 1, "lookups": 3, "lookupLimit": 10, "overLimit": false, "all": "~all" },
"dmarc": { "record": "v=DMARC1; …", "policy": "none", "pct": 100, "rua": [ … ] },
"dkim": [ … ],
"risks": [
{ "level": "warn", "title": "DMARC p=none", "detail": "…" }
]
}
}
Use the risks list to fix the highest-impact issues first: a missing SPF or DMARC record
costs the most points, an over-limit SPF (too many DNS lookups) or an open +all is a
spoofing risk, and a p=none DMARC policy is a nudge to tighten enforcement once you're
confident.
6. Send a transactional email
The send API is Resend-shaped — if you've used a modern email API, the payload is familiar. Both camelCase and snake_case field names are accepted.
curl -X POST https://<control-plane>/v1/email/send \
-H "Authorization: Bearer hs_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@mail.example.com",
"to": ["customer@example.org"],
"subject": "Welcome aboard",
"html": "<h1>Thanks for signing up</h1>",
"text": "Thanks for signing up"
}'
Rules the send enforces before it accepts your message:
frommust be a verified sending domain on your account — send from an unverified domain and you get a403.subjectis required, and you must providehtmlortext(or both).- At least one recipient across
to/cc/bcc; the ceiling is 1000 recipients. - Attachments are base64
contentor a publichttpsURL inpath— never a server-local file path, and private/internal URLs are rejected. Up to 25 attachments. - If every recipient is on your suppression list, the send is rejected. (Individual suppressed recipients are dropped later, at delivery time.)
On success you get a 202 with a message id and status: "queued":
{ "id": "em_…", "status": "queued" }
The message is now on HostSSH's native send queue. From there a worker signs it with your domain's DKIM key, sets a VERP bounce return-path, and hands it to your MTA.
The API is rate-limited per key (120 sends/min) and per IP, so bursts are smoothed rather than dropped — a
429with aRetry-Aftertells you to back off.
When does mail actually go out?
This is the honest part. Everything above — register, verify, key, health, send → queued — works today. But HostSSH does not deliver live mail until the operator enables it:
- The send worker needs
HOSTSSH_SMTP_URLpointed at a real MTA, plus the sending sidecar (a box with port-25 egress and a proper PTR record) turned on. - Sending live mail is an irreversible, outward-facing action, so it's wired deliberately,
not by default. Until then, your messages sit
queuedand the delivery lifecycle (queued → sending → sent) doesn't advance.
You can build and test your entire integration against the API right now; the last hop lights up when your operator flips it on.
What happens to a queued message
Once delivery is enabled, each message moves through a visible lifecycle:
| Status | Meaning |
|---|---|
queued | Accepted and waiting for the send worker |
sending | The worker has claimed it and is delivering |
sent | Handed to the MTA successfully (a provider message id is recorded) |
failed | Delivery exhausted its retries, or every recipient was suppressed |
Transient failures are retried automatically with exponential backoff before a message is given up on, so a briefly unreachable MTA doesn't lose your mail.
Related
- DNS Tools — the same DKIM/SPF/DMARC/blacklist diagnostics, run interactively, plus scheduled monitors for reputation and record drift.
- Connections (BYOK) — bring-your-own storage and provider keys.