---
title: "Verify a domain"
description: "Add a sending domain, publish its DNS records, and verify it before sending live email."
---

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

Mailfully sends from domains you own. Before live sends are accepted, you add a domain, publish six DNS records, and verify it.

## Why verification is required

Two checks run when the API accepts a live send:

1. **Org-level gate.** If your organization has no verified domain, every live send is rejected with `403 unverified_domain_required`:

```json
{
  "error": {
    "type": "unverified_domain_required",
    "message": "Your organization has no verified sending domain. Add and verify a domain before sending live email, or use test mode."
  }
}
```

2. **Per-send from check.** The domain of the `from` address must be one of your domains with status `verified`. The match is exact: `orders@mail.acme.com` requires `mail.acme.com` itself to be verified; a subdomain of a verified domain does not inherit verification. A mismatch is a `422 validation_error` with `param: "from"`.

Test-mode sends are exempt from both checks, so you can build your integration before DNS is set up.

## Add and verify

<Steps>
<Step title="Create the domain">

<Tabs>
<Tab title="curl">

```bash
curl -X POST https://api.mailfully.com/v1/domains \
  -H "Authorization: Bearer mf_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "mail.acme.com" }'
```

</Tab>
<Tab title="Node SDK">

```typescript
import { Mailfully } from "@mailfully/node";

const mailfully = new Mailfully({ apiKey: process.env.MAILFULLY_API_KEY ?? "" });

const { data, error } = await mailfully.domains.create({ name: "mail.acme.com" });
if (error) {
  console.error(`${error.statusCode} ${error.type}: ${error.message}`);
} else {
  console.log(data.records);
}
```

</Tab>
</Tabs>

The `201` response includes the domain and the exact DNS records to publish:

```json
{
  "object": "domain",
  "id": "dom_01J1PZ3M8Q7VXCK4T2R9WFH6BD",
  "name": "mail.acme.com",
  "region": "us-east-1",
  "mail_from_subdomain": "send.mail.acme.com",
  "dkim_tokens": [
    "6gbrjpgwjskckoa6a5zn6fwqkn67xbtw",
    "yybhvng4tqglsubhkkbwfwbtvvxjb5lb",
    "wrx2s7fgbfyvfh2c3wch5bqk3gpjrmtp"
  ],
  "dkim_status": "not_started",
  "mail_from_status": "not_started",
  "dmarc_status": "not_started",
  "tracking_status": "not_started",
  "tracking_enabled": false,
  "config_set": "mf-org_01J1PZ0R2NQ8XWJH3KTV6C5FYD-dom_01J1PZ3M8Q7VXCK4T2R9WFH6BD",
  "status": "pending",
  "created_at": "2026-07-08T14:05:02.318Z",
  "records": [
    { "type": "CNAME", "name": "6gbrjpgwjskckoa6a5zn6fwqkn67xbtw._domainkey.mail.acme.com", "value": "6gbrjpgwjskckoa6a5zn6fwqkn67xbtw.dkim.amazonses.com" },
    { "type": "CNAME", "name": "yybhvng4tqglsubhkkbwfwbtvvxjb5lb._domainkey.mail.acme.com", "value": "yybhvng4tqglsubhkkbwfwbtvvxjb5lb.dkim.amazonses.com" },
    { "type": "CNAME", "name": "wrx2s7fgbfyvfh2c3wch5bqk3gpjrmtp._domainkey.mail.acme.com", "value": "wrx2s7fgbfyvfh2c3wch5bqk3gpjrmtp.dkim.amazonses.com" },
    { "type": "MX", "name": "send.mail.acme.com", "value": "feedback-smtp.us-east-1.amazonses.com", "priority": 10 },
    { "type": "TXT", "name": "send.mail.acme.com", "value": "v=spf1 include:amazonses.com ~all" },
    { "type": "TXT", "name": "_dmarc.mail.acme.com", "value": "v=DMARC1; p=none;" }
  ]
}
```

The optional `mail_from_prefix` field controls the MAIL FROM subdomain; it defaults to `send`, so the MX and SPF records land on `send.mail.acme.com`.

</Step>
<Step title="Publish the DNS records">

Add each record at your DNS host exactly as returned:

| Records | Purpose |
| --- | --- |
| 3 CNAMEs at `<token>._domainkey.mail.acme.com` | DKIM signing |
| MX at `send.mail.acme.com`, priority 10 | Custom MAIL FROM |
| TXT at `send.mail.acme.com` | SPF for the MAIL FROM subdomain |
| TXT at `_dmarc.mail.acme.com` | DMARC policy, starting at `p=none` (monitor-only) |

If you lose the response, fetch the same records any time with [`GET /v1/domains/{id}`](/api-reference/domains/get).

</Step>
<Step title="Verify">

Amazon SES checks your published DNS on its own cadence; each call to the verify endpoint reads SES's latest recorded result and returns the updated domain:

<Tabs>
<Tab title="curl">

```bash
curl -X POST https://api.mailfully.com/v1/domains/dom_01J1PZ3M8Q7VXCK4T2R9WFH6BD/verify \
  -H "Authorization: Bearer mf_live_xxxxxxxxxxxx"
```

</Tab>
<Tab title="Node SDK">

```typescript
const { data, error } = await mailfully.domains.verify("dom_01J1PZ3M8Q7VXCK4T2R9WFH6BD");
if (data) {
  console.log(data.status); // "pending" | "partially_verified" | "verified" | "failed"
}
```

</Tab>
</Tabs>

A `200` response mid-verification, with MAIL FROM confirmed and DKIM still propagating:

```json
{
  "object": "domain",
  "id": "dom_01J1PZ3M8Q7VXCK4T2R9WFH6BD",
  "name": "mail.acme.com",
  "region": "us-east-1",
  "mail_from_subdomain": "send.mail.acme.com",
  "dkim_tokens": [
    "6gbrjpgwjskckoa6a5zn6fwqkn67xbtw",
    "yybhvng4tqglsubhkkbwfwbtvvxjb5lb",
    "wrx2s7fgbfyvfh2c3wch5bqk3gpjrmtp"
  ],
  "dkim_status": "pending",
  "mail_from_status": "verified",
  "dmarc_status": "not_started",
  "tracking_status": "not_started",
  "tracking_enabled": false,
  "config_set": "mf-org_01J1PZ0R2NQ8XWJH3KTV6C5FYD-dom_01J1PZ3M8Q7VXCK4T2R9WFH6BD",
  "status": "partially_verified",
  "created_at": "2026-07-08T14:05:02.318Z"
}
```

Repeat the call until `status` is `verified`. Live sends from `orders@mail.acme.com` are accepted from that point on.

</Step>
</Steps>

## Verification statuses

The domain's `status` moves through four values:

| `status` | Meaning |
| --- | --- |
| `pending` | Within the verification window; records not yet confirmed |
| `partially_verified` | Exactly one of DKIM and MAIL FROM has verified |
| `verified` | DKIM and MAIL FROM both verified. The domain can send live email |
| `failed` | The 72-hour window elapsed with neither DKIM nor MAIL FROM verified. If exactly one made it, the domain finalizes as `partially_verified` instead |

Per-record fields (`dkim_status`, `mail_from_status`) move through `not_started`, `pending`, `verified`, and `failed`. Verification gates on DKIM and MAIL FROM only. The DMARC record is advisory and never blocks the `verified` status.

Two rules govern the timeline:

- **72-hour window.** The window starts when the domain is created. If it elapses before both records verify, each unverified record is marked `failed` and the domain finalizes as `failed`, or `partially_verified` if exactly one record made it.
- **Re-verification is always allowed.** Every verify call reads SES's latest check, so a domain that finalized as `failed` or `partially_verified` still flips to `verified` on a later call once SES reports both records verified. Fix the records and verify again.

## Open and click tracking

Tracking is a single boolean per domain that covers opens and clicks together. Enabling it points the domain's tracking links at a custom redirect subdomain, `email.mail.acme.com`, and returns one more CNAME to publish:

<Tabs>
<Tab title="curl">

```bash
curl -X POST https://api.mailfully.com/v1/domains/dom_01J1PZ3M8Q7VXCK4T2R9WFH6BD/tracking \
  -H "Authorization: Bearer mf_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'
```

</Tab>
<Tab title="Node SDK">

```typescript
const { data, error } = await mailfully.domains.tracking(
  "dom_01J1PZ3M8Q7VXCK4T2R9WFH6BD",
  { enabled: true },
);
```

</Tab>
</Tabs>

The response returns the updated domain plus the record to add:

```json
{
  "records": [
    { "type": "CNAME", "name": "email.mail.acme.com", "value": "r.us-east-1.awstrack.me" }
  ]
}
```

Disabling with `{ "enabled": false }` returns `"records": []`, and you can remove the CNAME. See [`POST /v1/domains/{id}/tracking`](/api-reference/domains/update-tracking) for the full response shape.

## Troubleshooting

<Accordion title="Verification is stuck in pending">
DNS propagation can take anywhere from minutes to hours depending on your host and record TTLs. SES re-checks DNS on its own schedule and the verify endpoint reports its latest result, so a record that fails a check inside the 72-hour window is not final. Keep the records published and verify again. Sends do not bounce while the MX record propagates: until it resolves, mail falls back to the default MAIL FROM instead of failing.
</Accordion>

<Accordion title="DKIM CNAMEs don't resolve">
The three DKIM records must be plain CNAMEs that resolve to their `dkim.amazonses.com` targets. If your DNS host proxies or flattens CNAME records, the published name may stop resolving to that target, so publish them as unproxied, DNS-only records. Also check for hosts that auto-append the zone name: if yours does, enter only the host part (`<token>._domainkey`) so the record doesn't end up at `<token>._domainkey.mail.acme.com.mail.acme.com`. Confirm with `dig CNAME <token>._domainkey.mail.acme.com +short`.
</Accordion>

<Accordion title="I already have an SPF record">
Mailfully's SPF TXT record lives on the MAIL FROM subdomain (`send.mail.acme.com`), not your root domain, so it does not conflict with an existing SPF record on your apex. Publish it as returned. If you already publish an SPF TXT on that exact subdomain, merge `include:amazonses.com` into the existing record instead (a single name must not carry two SPF records).
</Accordion>

## Related

- [`POST /v1/domains`](/api-reference/domains/create) — full request and response reference
- [`POST /v1/domains/{id}/verify`](/api-reference/domains/verify)
- [Errors](/concepts/errors) — the full error catalog, including the send-gate errors above
