# 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:write` scope. Domain endpoints refuse keys without it.

## Pick the domain to send from {#pick-the-domain-to-send-from}

The domain part of your `From` address must match a verified domain **exactly**. To send from `hello@updates.example.com`, add `updates.example.com`; adding `example.com` is not enough, and the reverse is also true.

> **Tip: Use a subdomain for marketing**
>
> 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**

```bash
curl -X POST https://api.hatang.dev/domains \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "example.com" }'
```

**Node.js**

```ts
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);
```

**Python**

```python
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**

```php
<?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](https://hatang.dev/docs/guides/email/tracking.md). |
| `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:

```json
{
  "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 `From` domain 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](https://hatang.dev/docs/guides/email/deliverability.md).

### 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](https://hatang.dev/docs/guides/email/tracking.md) and [Receive email](https://hatang.dev/docs/guides/email/receiving.md).

> **Caution: Names at your DNS host**
>
> 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`, or `hatang.domains.verify(id)` in the Node.js SDK.

```bash
curl -X POST https://api.hatang.dev/domains/dom_0199e6c17a2b7f009c1e3f6a2b1d9e10/verify \
  -H "Authorization: Bearer $HATANG_API_KEY"
```

```json
{ "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](https://www.domainconnect.org/), 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.

> **Note**
>
> 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:

```bash
# 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:

```json
{ "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:

```bash
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](https://hatang.dev/docs/guides/email/receiving.md). |

`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](#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. |

## Next steps

- [Send email](https://hatang.dev/docs/guides/email/sending.md)
- [Deliverability](https://hatang.dev/docs/guides/email/deliverability.md)
- [Open and click tracking](https://hatang.dev/docs/guides/email/tracking.md)
- [Receive email](https://hatang.dev/docs/guides/email/receiving.md)

Source: https://hatang.dev/docs/guides/email/domains
