Domains
You can only send from a domain you have added to Ha Tang and verified. Each domain gets its own DKIM key and its own return-path subdomain, so receiving mailboxes can check that the mail really comes from you. This page covers adding a domain, the DNS records, verification, one-click setup with Domain Connect and what to do when a record does not verify.
Before you start
- Access to the DNS settings of the domain (Cloudflare, your registrar or your DNS host).
- For the API: a key with the
email:writescope. Domain endpoints refuse keys without it.
Pick the domain to send from
The domain part of your From address must match a verified domain exactly. To send from [email protected], add updates.example.com; adding example.com is not enough, and the reverse is also true.
A subdomain such as updates.example.com or mail.example.com keeps its sending reputation apart from your root domain. If a campaign goes badly, the mail your staff send from example.com is not affected. Many teams use one subdomain for transactional mail and another for broadcasts.
Add a domain
- cURL
- Node.js
- Python
- PHP
curl -X POST https://api.hatang.dev/domains \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "example.com" }'
import { Hatang } from "@hatang/sdk";
const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" });
const { data: domain, error } = await hatang.domains.create({ name: "example.com" });
if (error) throw new Error(error.message);
console.table(domain.records);
import os
import requests
res = requests.post(
"https://api.hatang.dev/domains",
headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"},
json={"name": "example.com"},
)
res.raise_for_status()
for record in res.json()["records"]:
print(record["type"], record["name"], record["value"])
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://api.hatang.dev']);
$res = $client->post('/domains', [
'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')],
'json' => ['name' => 'example.com'],
]);
$domain = json_decode($res->getBody(), true);
In the dashboard, open Email → Domains, type the domain in Domain and click Add domain. Advanced options holds the same settings as the API fields below.
| Field | Default | Description |
|---|---|---|
name | required | The domain or subdomain, for example example.com or updates.example.com. Stored in lower case. |
custom_return_path | send | Subdomain label for the bounce address and SPF, so send gives send.example.com. Letters, digits and hyphens, up to 63 characters. Dashboard: Custom Return-Path. |
tracking_subdomain | none | Optional label for branded tracking links, for example links gives links.example.com. Must differ from the return-path label. Dashboard: Tracking Subdomain. See Open and click tracking. |
open_tracking | false | Add an open-tracking pixel to HTML emails from this domain. |
click_tracking | false | Rewrite links in HTML emails from this domain to record clicks. |
region | platform region | Accepted for Resend compatibility and stored. Sending always uses the platform's Amazon SES region. |
The response (201) is the domain with the records to publish:
{
"object": "domain",
"id": "dom_0199e6c17a2b7f009c1e3f6a2b1d9e10",
"name": "example.com",
"status": "not_started",
"created_at": "2026-10-14T09:12:03.000Z",
"region": "us-west-2",
"dkim_selector": "rs7hq2kx9p",
"open_tracking": false,
"click_tracking": false,
"tracking_subdomain": null,
"tracking_domain_verified": false,
"custom_return_path": "send",
"ses_status": "pending",
"tls": "opportunistic",
"capabilities": { "sending": "enabled", "receiving": "disabled" },
"records": [
{ "record": "MX", "name": "send.example.com", "type": "MX", "ttl": "Auto", "status": "not_started", "value": "feedback-smtp.us-west-2.amazonses.com", "priority": 10, "required": true },
{ "record": "SPF", "name": "send.example.com", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=spf1 include:amazonses.com ~all", "required": true },
{ "record": "DKIM", "name": "rs7hq2kx9p._domainkey.example.com", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh…", "required": true },
{ "record": "DMARC", "name": "_dmarc.example.com", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=DMARC1; p=none;", "required": false }
]
}
Adding the same domain twice returns 409.
The DNS records
Always copy names and values from the dashboard or from the records array. The values below are examples.
| Record | Type | Name | Value | Required |
|---|---|---|---|---|
| DKIM | TXT | <selector>._domainkey.example.com | v=DKIM1; k=rsa; p=<public key> | Yes |
| Return-path MX | MX, priority 10 | send.example.com | feedback-smtp.<region>.amazonses.com | Yes |
| Return-path SPF | TXT | send.example.com | v=spf1 include:amazonses.com ~all | Yes |
| DMARC | TXT | _dmarc.example.com | v=DMARC1; p=none; | No, recommended |
| Tracking | CNAME | links.example.com | The tracking host shown in the dashboard | Only with a tracking subdomain |
| Receiving | MX, priority 10 | example.com | The inbound host shown in the dashboard | Only with receiving turned on |
DKIM
Ha Tang creates a 2048-bit RSA key for each domain. The public half goes in a TXT record under a selector that starts with rs followed by eight random characters (the dkim_selector field). Every message is signed with the private half for your domain, which is what lets DMARC pass on DKIM.
A 2048-bit key is longer than 255 characters. Most DNS hosts split it into several strings for you; paste the whole value as one record. Ha Tang ignores whitespace when it compares the key.
Return path: MX and SPF on send.
The bounce address (the envelope sender, or Return-Path) of every message is on your return-path subdomain, for example bounce+…@send.example.com. Two records on that subdomain make it work:
- The MX record sends bounces and complaints back to Amazon SES, which reports them to Ha Tang.
- The SPF TXT record authorizes Amazon SES to send for that subdomain. Because the return path is a subdomain of your domain, SPF aligns with your
Fromdomain under DMARC's relaxed alignment.
The records go on the subdomain, not on the root, so they never collide with the SPF or MX records your root domain already has. If the subdomain already has an SPF record, Ha Tang accepts any v=spf1 record that contains include:amazonses.com.
DMARC
DMARC tells receiving mailboxes what to do with mail that fails SPF and DKIM alignment. Ha Tang proposes v=DMARC1; p=none;, which only asks for monitoring. Any record that starts with v=DMARC1 counts as present, so keep your existing policy if you have one. DMARC does not block verification. Gmail and Yahoo expect bulk senders to publish one, so add it. See Deliverability.
Tracking and receiving
These two records appear only when you use the feature: the tracking CNAME when the domain has a tracking subdomain, and the receiving MX when you turn on receiving. Neither blocks sending. See Open and click tracking and Receive email.
Many DNS hosts add the zone to the name you type. If you type send.example.com in the Name field for the zone example.com, you may end up with send.example.com.example.com. Type only the part before your domain (send, rs7hq2kx9p._domainkey, _dmarc) when your host works that way.
Verify the domain
When the records are published, start verification:
- Dashboard: open the domain and click Verify DNS records. While the domain is pending, the page checks again and refreshes by itself.
- API:
POST /domains/{id}/verify, orhatang.domains.verify(id)in the Node.js SDK.
curl -X POST https://api.hatang.dev/domains/dom_0199e6c17a2b7f009c1e3f6a2b1d9e10/verify \
-H "Authorization: Bearer $HATANG_API_KEY"
{ "object": "domain", "id": "dom_0199e6c17a2b7f009c1e3f6a2b1d9e10", "status": "pending" }
Ha Tang checks every record right away, then keeps checking in the background until the domain is verified. DNS changes can take up to an hour to reach everyone. A domain is Verified when every required record (DKIM, return-path MX and SPF) matches and Amazon SES has confirmed the DKIM signature. Optional records never hold verification back.
Domain statuses
| Status | Dashboard | Meaning |
|---|---|---|
not_started | Not started | The domain was added; verification has not been requested yet. |
pending | Pending | Ha Tang is checking. Some required records do not match yet, or Amazon SES has not confirmed DKIM yet. |
verified | Verified | You can send from the domain. |
temporary_failure | Temporary failure | A DNS lookup failed for a reason other than "not found", for example a timeout. Ha Tang tries again. |
failed | Failed | Verification failed, for example because Amazon SES rejected the DKIM setup. Fix the records and verify again. |
Each record also has its own status (not_started, pending, verified or failed) and, when it fails, a last_error that says what Ha Tang found instead, such as found: nothing or the value that is there now.
Ha Tang sends the domain.created, domain.updated and domain.deleted webhooks, with the domain as GET /domains/{id} shows it. A status change fires domain.updated.
One-click setup with Domain Connect
If your DNS host supports Domain Connect, the domain page shows a DNS Records panel with Auto configure with your host's name. Click it, sign in at your DNS host and approve the change. The host adds the records, sends you back to Ha Tang and verification starts right away. Manual setup jumps to the table of records instead.
Domain Connect adds:
- the return-path MX record and the SPF rule (
include:amazonses.com, merged into an existing SPF record on that subdomain), - the DKIM record,
- the DMARC record (
v=DMARC1; p=none;), - the tracking CNAME, when the domain has a tracking subdomain.
Review the changes your DNS host shows before you approve them, especially if the domain already has a DMARC record you want to keep.
When the DNS host does not support Domain Connect but the domain is on Cloudflare, the domain page links straight to the zone's DNS records instead.
From the API:
# Can this domain be set up in one click?
curl https://api.hatang.dev/domains/$DOMAIN_ID/domain-connect -H "Authorization: Bearer $HATANG_API_KEY"
# { "object": "domain_connect", "available": true, "reason": null, "dns_host": "Cloudflare", "zone": "example.com" }
# Get the signed URL to send the user to
curl -X POST https://api.hatang.dev/domains/$DOMAIN_ID/domain-connect \
-H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" -d '{}'
# { "object": "domain_connect", "id": "dom_…", "url": "https://…" }
When available is false, reason is no_domain_connect (the DNS host does not support it), template_not_supported (it supports Domain Connect but not Ha Tang's template yet) or not_configured. POST takes an optional redirect_uri; without it, the user comes back to the domain page in the dashboard.
GET /domains/{id}/dns-provider tells you which DNS host serves the domain:
{ "object": "dns_provider", "zone": "example.com", "provider": "cloudflare", "nameservers": ["ada.ns.cloudflare.com", "bob.ns.cloudflare.com"], "cloudflare_url": "https://dash.cloudflare.com/…" }
Custom return path
The return-path label is send unless you choose another one when you add the domain. Change it only if send.example.com is already in use for something else, for example by another email provider's bounce records. It cannot be changed afterwards: delete the domain and add it again with the new label.
Change or delete a domain
PATCH /domains/{id} changes the settings that can change after creation:
curl -X PATCH https://api.hatang.dev/domains/$DOMAIN_ID \
-H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \
-d '{ "open_tracking": true, "click_tracking": true, "tracking_subdomain": "links" }'
| Field | Effect |
|---|---|
open_tracking, click_tracking | Turn tracking on or off for emails sent from now on. |
tracking_subdomain | Set, change or remove (null) the branded tracking host. A new CNAME record appears to publish. |
capabilities.receiving | "enabled" or "disabled". See Receive email. |
DELETE /domains/{id} removes the domain and its Amazon SES identity. Emails still queued for that domain bounce with "Sending domain no longer exists".
Troubleshooting
| Symptom | Cause and fix |
|---|---|
A record shows found: nothing | The record is missing or under the wrong name. Check for the zone being added twice (see the caution above), then wait a few minutes for DNS to update. |
| DKIM fails but the record is there | The value was cut or changed. Paste the full v=DKIM1; k=rsa; p=… value again. Some hosts need the long value in quotes or split into several strings. |
SPF on send. fails | The subdomain has an SPF record without include:amazonses.com, or has two SPF records. Keep one v=spf1 record that includes include:amazonses.com. |
All records verify but the status stays pending | Amazon SES has not confirmed DKIM yet. This usually follows the DNS within minutes; click Re-check DNS later. |
| The return-path MX conflicts with another record | send.example.com already has a CNAME. A name with a CNAME cannot have other records. Remove the CNAME or add the domain again with another Custom Return-Path. |
403 "The example.com domain is not verified" when sending | The From domain is not added, not verified, or is a different subdomain. See Pick the domain to send from. |
401 restricted_api_key when sending | The API key is limited to another domain. Use a key for this domain or an unrestricted key. |