# Mail on your domain

> Add your own domain to Pidgeon inboxes, publish the DNS it needs, and send and receive on your own name instead of the shared one.

A domain is the one thing this API cannot finish for you. We can generate
every record, check them, and tell you the moment they land — but we cannot
write in somebody else's zone, and a product that asked you to move your
nameservers so it could would be a different product.

So the job here is to make that step **delegable**. You get the records in the
same response as the domain, you show them to your own customer in your own
words, and nobody ever sees a Pidgeon screen.

## Connect one

```bash
curl https://pidgeon.ai/api/v1/domains \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "domain": "acme.com" }'
```

```ts
const { data: domain } = await domainsCreate({
body: { domain: 'acme.com' },
throwOnError: true,
});

for (const record of domain.data.dns_records) {
console.log(record.record_type, record.host_relative, record.value);
}
```

```python
domain = httpx.post(
  "https://pidgeon.ai/api/v1/domains",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={"domain": "acme.com"},
).json()["data"]

for record in domain["dns_records"]:
  print(record["record_type"], record["host_relative"], record["value"])
```

Five kinds of record come back. `ownership` is a TXT record proving the domain
is yours; `mx` routes inbound mail to us; `spf` and `dkim` are what stop your
outgoing mail being treated as spam; `dmarc` asks other providers to report on
anything claiming to be you. A DMARC policy the domain already has counts as
done; publish ours only where there is none, because two policies at `_dmarc`
are read as none. Each carries a one-line `description` written for
the person who has to publish it, so you do not have to write those sentences
yourself.

**Check `dns_complete` before you show the list.** The DKIM records are derived
from what our mail provider issued, and until that has happened the list looks
complete and is missing three CNAMEs. Publishing it in that state gives you a
domain that verifies and cannot sign mail — which fails in a way nobody
debugs quickly.

**Check `existing_mail` before you publish the MX.** It names who already
receives mail for the domain, read from its MX records: `provider` is one of
`google`, `microsoft`, `zoho`, `fastmail`, `proton`, `icloud`, `godaddy` or
`other`, with `name` to show a person and `hosts` as found. It is null when
nothing does.

```json
"existing_mail": {
"provider": "microsoft",
"name": "Microsoft 365",
"hosts": ["acme-com.mail.protection.outlook.com"]
}
```

When it is set, the `mx` record in the list replaces that provider: once it is
published, mail to the domain stops arriving there, for everyone who reads it.
Pidgeon does not decide this for you, and `inbound_enabled` stays what you sent.
Three ways to keep that mail where it is:

- **Send only.** Connect the domain with `"inbound_enabled": false`. Pidgeon
  signs and sends as it, the `mx` record comes back `not_required`, and
  addresses on it can send but have no inbox.
- **A subdomain for agents.** Connect `agents.acme.com` instead. It has its
  own MX, so its inboxes receive and `acme.com` mail stays where it is.
- **Forward a copy in.** Keep the mail at the provider and forward it to an
  agent's address ([Keep your Gmail](https://pidgeon.ai/docs/gmail-forwarding.md)).

The dashboard does the same when a person adds a domain: it reads the MX,
defaults to send-only, and asks before it lets them receive here anyway.

## Which name to type

Every record carries its name three ways. `fqdn` is the full name.
`host_relative` is the same name relative to `zone`, the registrable domain
it is published in, read from the Public Suffix List. `name` is relative to
the domain you connected, and is kept as it always was.

For `acme.com` the difference is cosmetic. For a subdomain it is the whole
job:

| Record  | `name`              | `zone`     | `host_relative`            | `fqdn`                              |
| ------- | ------------------- | ---------- | -------------------------- | ----------------------------------- |
| MX, SPF | `@`                 | `acme.com` | `agents`                   | `agents.acme.com`                   |
| DKIM    | `abc123._domainkey` | `acme.com` | `abc123._domainkey.agents` | `abc123._domainkey.agents.acme.com` |
| DMARC   | `_dmarc`            | `acme.com` | `_dmarc.agents`            | `_dmarc.agents.acme.com`            |

Squarespace, GoDaddy and Namecheap want `host_relative` and add the zone
themselves, so a full name pasted there becomes
`agents.acme.com.acme.com`. Route 53 asks for the full name. Cloudflare takes
either. Route 53 and Vercel want the name field empty, not `@`, for a record
at the zone itself.

If the subdomain is a zone of its own at your provider — its own hosted zone
in Route 53, say — names are relative to that instead: `@` for the subdomain,
`_dmarc` for DMARC. `fqdn` is right either way.

## Wait for it

```bash
curl -X POST https://pidgeon.ai/api/v1/domains/$DOMAIN_ID/verify \
-H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
const { data: state } = await domainsVerify({
path: { id: domain.data.id },
throwOnError: true,
});

if (state.data.verified && state.data.mail_configured) {
// Ready to send and receive.
}
```

```python
state = httpx.post(
  f"https://pidgeon.ai/api/v1/domains/{domain_id}/verify",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
).json()["data"]
```

Idempotent and safe to poll. Calling it on a domain that is already verified
re-checks and says so rather than erroring, because a poll loop that has to
special-case success is a poll loop with a bug in it.

Two states, not one: `verified` means we believe the domain is yours,
`mail_configured` means it can actually send and receive. A domain can sit at
the first for a while — somebody publishes the TXT record and stops reading
the instructions.

**Better than polling:** subscribe to `domain.verified` and do not poll at
all. DNS propagation is measured in a zone's TTL and neither of us controls
it, so a webhook costs you one request and a poll loop costs you hundreds.

## When it does not verify

`GET /v1/domains/{id}` returns every record with its own `status` —
`connected`, `waiting`, `incorrect`, or `not_required` — `found`, which is
what we actually read at that name, and `checked_at`.

`state` says why, from our own lookup of public DNS beside the mail
provider's verdict:

| `state`       | Means                                                                                                                         | Do                                                              |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `connected`   | Working.                                                                                                                      | Nothing.                                                        |
| `published`   | Correct in public DNS; Amazon has not confirmed it yet. DKIM only.                                                            | Wait.                                                           |
| `missing`     | Nothing at the name yet. We ask the zone’s own nameservers, so a record you just published shows on the next check.           | Publish it, checking the name.                                  |
| `wrong_value` | Something else is at the name; `found` is what. For DMARC: two policies at `_dmarc`, and `found` is the one that is not ours. | Replace it. For DMARC, delete one, keeping the policy you want. |
| `wrong_name`  | Our value is at `found_at` instead, usually the zone typed twice.                                                             | Change the name to `host_relative`.                             |

**`published` is normal, and can last.** Amazon checks DKIM on its own
schedule: usually within an hour of the records appearing, and it allows up to
72 hours. Pidgeon checks every domain still being set up every 5 minutes for
two weeks after it is added, so `checked_at` moves without you polling.

That last field is the one that matters. A record with the right name and the
wrong value looks exactly like a missing one until you can see what is there
instead, and "your domain is not verified" is not something your customer can
act on. "Your MX record points at `mail.oldhost.com`" is.

## Disconnecting

`DELETE /v1/domains/{id}` takes the domain's addresses, their mail, their
routing rules and their webhooks with it. There is no undo and no soft form of
it. It takes an id rather than a name on purpose: an id comes from a list you
just read, and a name comes from a variable that might be wrong.
