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_URL to 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:

FieldDefaultWhat it does
selectorhsThe DKIM selector — your key lives at <selector>._domainkey.<domain>
algorithmrsa-sha256DKIM signing algorithm — rsa-sha256 (RSA-2048) or ed25519-sha256
spfIncludeA host to add as include: in your SPF record
bounceDomainA domain you control for bounce return-paths
dmarcRuaAn 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 TXT record at <selector>._domainkey.<domain> carrying your public key (v=DKIM1; k=…; p=…). This is what lets receivers verify your signature.
  • SPF — a single TXT record on the domain apex. If you passed spfInclude, it's v=spf1 include:<host> ~all; otherwise a bare v=spf1 ~all. Keep it to one SPF record — multiple v=spf1 records are an error that verification will flag.
  • DMARC — a TXT record at _dmarc.<domain> with a p=none starter policy (and your rua= 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:

  • DKIMverified when the selector TXT contains your published key; failed if a record exists but the key doesn't match; pending if there's no record yet.
  • SPFverified for exactly one v=spf1 record (that includes your spfInclude, if set); failed for multiple SPF records or a missing include; pending if none is found.
  • DMARCverified if any v=DMARC1 record is present, otherwise pending. 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:

  • from must be a verified sending domain on your account — send from an unverified domain and you get a 403.
  • subject is required, and you must provide html or text (or both).
  • At least one recipient across to / cc / bcc; the ceiling is 1000 recipients.
  • Attachments are base64 content or a public https URL in path — 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 429 with a Retry-After tells 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_URL pointed 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 queued and 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:

StatusMeaning
queuedAccepted and waiting for the send worker
sendingThe worker has claimed it and is delivering
sentHanded to the MTA successfully (a provider message id is recorded)
failedDelivery 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.

  • 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.