# MailDNS documentation (all pages) > MailDNS is one API for all your registrars. Registrar accounts are connected once; a MailDNS key then manages A, AAAA, CNAME, MX and TXT records on the domains and hosts it was issued for, in any of six API styles copied from real registrars: Dynadot (https://api.maildns.io/api3.json), Spaceship (https://api.maildns.io/api/v1), Cloudflare (https://api.maildns.io/client/v4), GoDaddy (https://api.maildns.io/v1), Porkbun (https://api.maildns.io/api/json/v3) and Namecheap (https://api.maildns.io/xml.response). Existing code for any of them works by changing only the base URL and the key. Keys never change nameservers. Every page of https://maildns.io/docs in one file, in reading order. Each page is also served on its own as markdown (for example https://maildns.io/docs/dynadot.md). Last built October 18, 2026. --- # MailDNS docs MailDNS is one API for all your registrars. Your registrar accounts are connected once; a MailDNS key then manages DNS records on the domains it was issued for, in the API style of any of six registrars. Code written for one of them works after you change the base URL and the key. ## API styles | Style | Base URL | Auth | |---|---|---| | [Dynadot](https://maildns.io/docs/dynadot.md) | `https://api.maildns.io/api3.json` | `key=KEY` parameter | | [Spaceship](https://maildns.io/docs/spaceship.md) | `https://api.maildns.io/api/v1` | `X-API-Key: KEY`, `X-API-Secret: SECRET` | | [Cloudflare](https://maildns.io/docs/cloudflare.md) | `https://api.maildns.io/client/v4` | `Authorization: Bearer KEY` | | [GoDaddy](https://maildns.io/docs/godaddy.md) | `https://api.maildns.io/v1` | `Authorization: sso-key KEY:SECRET` | | [Porkbun](https://maildns.io/docs/porkbun.md) | `https://api.maildns.io/api/json/v3` | `apikey` and `secretapikey` in the JSON body | | [Namecheap](https://maildns.io/docs/namecheap.md) | `https://api.maildns.io/xml.response` | `ApiKey=KEY` parameter | - All six take the same key and reach the same records. Mix them freely. - The style doesn't have to match the registrar that holds a domain: a GoDaddy-style script can manage a domain held at Porkbun. - The live check, `dns_status`, is a [Dynadot-style](https://maildns.io/docs/propagation.md) call. It takes the same key whichever style you write with. ## Registrars you can connect Dynadot, Spaceship, Cloudflare, GoDaddy, Porkbun and Namecheap. Accounts are by invitation: when yours is set up, you connect your registrar accounts through a private link. Each registrar key is checked when you add it and is never shown again. Then MailDNS issues keys for the domains you choose. ## What a key can do - List the domains and hosts it was issued for, and read their records. - Add, change and delete `A`, `AAAA`, `CNAME`, `MX` and `TXT` records on them, at a host and under it (`_dmarc.advisor.example.com`, `google._domainkey.advisor.example.com`). - Read the current nameservers. - Ask the authoritative nameservers whether a record is live, before you ask Google or Microsoft to verify it. ## What a key can't do - **Change nameservers, ever.** No key can change nameservers or add NS records, in any style. The answer is always `This API key doesn't work for nameservers.` - See or change anything it wasn't issued for: other domains in your accounts, or records of a root domain outside its hosts. - Add domains or hosts to itself. MailDNS sets them when the key is issued. - Register, renew, transfer or delete domains, or change contacts or forwarding. - Use record types other than the five above (no `NS`, `SRV`, `CAA`, forwarding records). ## Words - **Host**: a name a key manages, such as `advisor.example.com`, or a whole domain such as `example.com`. You manage records **at** the host and **under** it. - **Root domain**: the registered domain a host lives in (`example.com`). When a key is issued for a host inside it, the root's other records are not yours. - **Main record** (Dynadot) or **`@`** (most other styles): a record at the name you addressed, not under it. - **Subhost** or **name**: the part of a record's name in front of the name you addressed, such as `_dmarc` or `google._domainkey`. ## Where to start - [Quickstart](https://maildns.io/docs/quickstart.md): from a new key to a live record in five calls. - [Hosts](https://maildns.io/docs/hosts.md): the two ways to name a host, in each style, and which calls replace records. - [Recipe: Google Workspace on a subdomain](https://maildns.io/docs/google-workspace.md): the whole flow, with a complete script, and step 1 in all six styles. - [For AI agents](https://maildns.io/docs/agents.md): `llms.txt`, `llms-full.txt`, and a briefing to paste into your agent. --- # Quickstart From a new key to a live TXT record in five calls. The examples are Dynadot style, the simplest to read; every [API style](https://maildns.io/docs.md#api-styles) has the same calls in its own shape, and the same key works in all of them. They use the host `advisor.example.com`: use one of yours, and replace `KEY` (and `SECRET` where a style needs one) with yours. ## Before you start - You have a MailDNS key. For Spaceship-style calls you also have its secret. - If your key is locked to IP addresses, run these from one of them. From anywhere else the key reads as `invalid key`. - Any HTTP client works. The examples use `curl`; the [Google Workspace recipe](https://maildns.io/docs/google-workspace.md) has the same flow in Python. ## 1. Check the key ```bash curl 'https://api.maildns.io/api3.json?key=KEY&command=account_info' ``` ```json { "AccountInfoResponse": { "ResponseCode": 0, "Status": "success", "AccountInfo": {"Username": "acme-inboxes", "Hosts": "3"} } } ``` `Username` is the name your key was issued under; `Hosts` is how many hosts it reaches. If you get `{"Response": {"ResponseCode": "-1", "Error": "invalid key"}}`, check the key and the IP address you are calling from. ## 2. List your hosts ```bash curl 'https://api.maildns.io/api3.json?key=KEY&command=list_domain' ``` ```json { "ListDomainInfoResponse": { "ResponseCode": 0, "Status": "success", "MainDomains": [ { "Name": "advisor.example.com", "RootDomain": "example.com", "Expiration": "1798761600000", "Registration": "1735689600000", "NameServerSettings": {"Type": "Dynadot DNS"}, "Status": "active" } ] } } ``` One entry per host. `Name` is the host; `RootDomain` is the registered domain it lives in. ## 3. Read a host's records ```bash curl 'https://api.maildns.io/api3.json?key=KEY&command=get_dns&domain=advisor.example.com' ``` ```json { "GetDnsResponse": { "ResponseCode": 0, "Status": "success", "GetDns": { "NameServerSettings": { "Type": "Dynadot DNS", "MainDomains": [], "SubDomains": [], "TTL": "3600" } } } } ``` A new host has no records yet. `MainDomains` are records at the host; `SubDomains` are records under it. ## 4. Add a TXT record Writes go as a form-encoded `POST`, so long values and your key stay out of the URL. `--data-urlencode` encodes each value for you. ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=set_dns2 \ --data-urlencode domain=advisor.example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode main_record_type0=txt \ --data-urlencode main_record0=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` ```json {"SetDnsResponse": {"ResponseCode": 0, "Status": "success"}} ``` > **Always send `add_dns_to_current_setting=1`** unless you mean to replace every record on the host. Without it, `set_dns2` replaces the host's records with exactly the ones in the call. See [replace calls](https://maildns.io/docs/hosts.md#replace-calls-work-per-host). ## 5. Wait until it is live The write returned once the record was saved at the authoritative nameservers. Before you ask Google to verify, check that every one of them serves it: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=dns_status \ --data-urlencode domain=advisor.example.com \ --data-urlencode subdomain=@ \ --data-urlencode record_type=txt \ --data-urlencode value=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` ```json { "DnsStatusResponse": { "ResponseCode": 0, "Status": "success", "Records": [ { "Name": "advisor.example.com", "RecordType": "TXT", "Value": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ", "Live": "yes", "Servers": {"ns1.example.net": "yes", "ns2.example.net": "yes"} } ] } } ``` `Live` is `yes` once every nameserver answers with the record. Until then, ask again every 15 seconds or so. Usually it is `yes` on the first or second try. More in [Checking propagation](https://maildns.io/docs/propagation.md). ## The same five calls, Spaceship style ```bash # 1 + 2. Your hosts (a wrong key or secret is HTTP 401) curl 'https://api.maildns.io/api/v1/domains?take=100' \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' # 3. A host's records curl 'https://api.maildns.io/api/v1/dns/records/advisor.example.com?take=500' \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' # 4. Add a TXT record (HTTP 204, no body) curl -X PUT https://api.maildns.io/api/v1/dns/records/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' -H 'Content-Type: application/json' \ -d '{"force": false, "items": [{"type": "TXT", "name": "@", "ttl": 3600, "value": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ"}]}' # 5. Is it live? curl -G https://api.maildns.io/api/v1/dns/status/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' \ --data-urlencode name=@ --data-urlencode type=TXT \ --data-urlencode value=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` ## In the other styles The add call from step 4, for each style. Full examples are on each style's page. | Style | Add a record | |---|---| | [Cloudflare](https://maildns.io/docs/cloudflare.md#create-a-record) | `POST /client/v4/zones/{zone_id}/dns_records` with `{"type": "TXT", "name": "@", "content": "..."}` | | [GoDaddy](https://maildns.io/docs/godaddy.md#add-records) | `PATCH /v1/domains/advisor.example.com/records` with `[{"type": "TXT", "name": "@", "data": "..."}]` | | [Porkbun](https://maildns.io/docs/porkbun.md#create-a-record) | `POST /api/json/v3/dns/create/advisor.example.com` with `{"apikey": "KEY", "secretapikey": "SECRET", "name": "", "type": "TXT", "content": "..."}` | | [Namecheap](https://maildns.io/docs/namecheap.md#write-records) | `namecheap.domains.dns.setHosts` with `SLD=advisor.example`, `TLD=com`, `HostName1=@`, `RecordType1=TXT`, `Address1=...` (replaces: send the host's other records too) | ## Next - [Hosts](https://maildns.io/docs/hosts.md): addressing a host as its own domain or as root domain plus subhost. - [Keys and limits](https://maildns.io/docs/keys.md): rate limits, IP locks and record limits. - [Google Workspace on a subdomain](https://maildns.io/docs/google-workspace.md): verification, MX, SPF, DKIM and DMARC end to end. --- # Keys and limits What a key is, what it can reach and read, and the limits every call runs under. ## Your key - One key per vendor or team. It carries a name, which `account_info` returns as `Username`. - One key works in all six [API styles](https://maildns.io/docs.md#api-styles). Some styles also send the key's secret: | Style | How the key is sent | |---|---| | Dynadot | `key=KEY` parameter | | Spaceship | `X-API-Key: KEY` and `X-API-Secret: SECRET` headers | | Cloudflare | `Authorization: Bearer KEY` (or `X-Auth-Key: KEY` with any `X-Auth-Email`) | | GoDaddy | `Authorization: sso-key KEY:SECRET` | | Porkbun | `"apikey": "KEY", "secretapikey": "SECRET"` in the JSON body | | Namecheap | `ApiKey=KEY` parameter (`ApiUser`, `UserName` and `ClientIp` can be anything) | - A key's domains and hosts are attached when it is issued. They can't be added or removed through the API; ask whoever issued your key. - Prefer `POST` for Dynadot-style calls: the key goes in the body instead of a URL that might end up in a log. - Give the key to scripts and agents through an environment variable (for example `MAILDNS_KEY`), never inside a prompt or a file you commit. ## What a key can read Only what it was issued for. Every list call (`list_domain`, `GET /zones`, `GET /v1/domains`, `/domain/listAll`, `getList`, ...) returns just the key's own domains and hosts, and every read returns just their records. Other domains in your registrar accounts don't show up anywhere: asking for one by name answers as if it didn't exist. ## Nameservers: never No key can change nameservers, add NS records, or touch registration. In every style the answer is the same text, in that style's error shape: ```text This API key doesn't work for nameservers. ``` | Style | Refused calls include | |---|---| | Dynadot | `set_ns`, `add_ns`, `register_ns`, NS records in `set_dns2` | | Spaceship | Nameserver updates, NS items in `PUT` | | Cloudflare | NS records; changing a zone's nameservers (HTTP 403, code 1004) | | GoDaddy | `nameServers` in `PATCH /v1/domains/{domain}`, NS records (HTTP 403, `ACCESS_DENIED`) | | Porkbun | `/domain/updateNs/{domain}`, NS records | | Namecheap | `namecheap.domains.dns.setCustom`, `setDefault`, `namecheap.domains.ns.*`, NS records in `setHosts` | Reading the current nameservers is fine (`get_ns`, zone `name_servers`, `nameServers`, `/domain/getNs`, `namecheap.domains.dns.getList`). Don't retry a refused call: it will never succeed. ## IP lock A key can be locked to the caller IP addresses (single addresses or ranges) you give us, the way Dynadot's API IP list works. A call from any other address is treated as a wrong key: | Style | What you get | |---|---| | Dynadot | HTTP 200, `{"Response": {"ResponseCode": "-1", "Error": "invalid key"}}` | | Spaceship | HTTP 401, `{"detail": "Unauthorized", "data": []}` | | Cloudflare | HTTP 403, error code 10000 | | GoDaddy | HTTP 401, `UNABLE_TO_AUTHENTICATE` | | Porkbun | HTTP 403, `{"status": "ERROR", "message": "invalid key"}` | | Namecheap | HTTP 200, `Status="ERROR"`, error number 1011102 | To change the list, ask whoever issued your key. ## Rate limit **10 requests per second per key**, with bursts of up to 20. Over the limit: | Style | What you get | |---|---| | Dynadot | HTTP 200, `{"Response": {"ResponseCode": "-1", "Error": "too many requests; retry in 0.4 seconds"}}` and a `Retry-After` header | | Spaceship | HTTP 429, `{"detail": "Too many requests", "data": []}` and a `Retry-After` header | | Cloudflare, GoDaddy, Porkbun | HTTP 429 in the style's error shape, with a `Retry-After` header | | Namecheap | HTTP 200, `Status="ERROR"`, error number 500000 | Wait the number of seconds in `Retry-After`, then send the same call again. Nothing was changed by the refused call. ## One change at a time per root domain Changes to the same root domain are applied one at a time, in the order they arrive. If an earlier change to that root is still running, your call waits for it, for up to 60 seconds. - Set your HTTP client's timeout to at least **90 seconds** for writes. - If the wait runs out, the call fails with `another change to this domain is still running; retry in a few seconds` (HTTP 429 in the styles that use status codes). Nothing was changed; send it again. - To change many hosts in the same root, one call with all of them (root form, see [Hosts](https://maildns.io/docs/hosts.md#which-form-to-use)) is faster than one call per host. ## Record rules | Rule | Limit | |---|---| | Record types | `A`, `AAAA`, `CNAME`, `MX`, `TXT`. Anything else is refused. | | Records per host | 50, counting the records at the host and under it | | TXT value | Up to 2,048 characters of printable ASCII (a 2048-bit DKIM key fits) | | MX priority | 0 to 65535 | | TTL | Records are served with the domain's TTL (usually 3600 seconds). See below. | | CNAME | Can't share a name with any other record, and only one CNAME per name | | Records per `set_dns2` call | Up to 100 main records and 100 subdomain records | **TTL.** MailDNS never changes a domain's TTL: it is shared with records that aren't yours. The Dynadot-style `ttl` parameter is accepted, so existing code keeps working, but your records get the domain's TTL. A Spaceship-style item's `ttl` (300 to 86400) is used where the domain supports a TTL per record; otherwise the domain's TTL applies. `get_dns` and `GET /api/v1/dns/records` show the TTL actually served. **TXT values** go as one string, without surrounding quotes, even when they are longer than 255 characters. Don't split a DKIM key into quoted pieces yourself. ## What a key can't do - Reach names outside its domains and hosts. When a key is issued for a host such as `advisor.example.com`, the root domain's own records (`example.com`, `www.example.com`) are not yours: they are never shown to you and can't be changed. - Change nameservers: see [Nameservers: never](#nameservers-never). - Change registration, forwarding, contacts, transfers or renewals. Dynadot commands such as `register`, `renew`, `delete`, `push`, `set_forwarding`, `set_stealth`, `set_email_forward` and `set_whois` reply `{"Response": {"ResponseCode": "-1", "Error": "command not available on this account"}}`; the other styles refuse them the same way. --- # Hosts A key manages a list of hosts. A host is a whole domain (`example.com`) or a name inside one (`advisor.example.com`). You manage records **at** each host and **under** it (`_dmarc.advisor.example.com`, `google._domainkey.advisor.example.com`). When a key's host is a name inside a domain, everything else in that root domain belongs to someone else: you never see it and can't change it. The rest of this page is about that case; for a whole-domain host, the host and the root are the same name. ## Two ways to address a host Every API style accepts either form, call by call. ### 1. The host as its own domain (simplest) Put the host in `domain` (Dynadot style) or in the path (Spaceship style): - Records **at** the host are main records (Dynadot) or name `@` (Spaceship). - Records **under** it use the part in front of the host as the subhost or name: `_dmarc`, `google._domainkey`. ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY --data-urlencode command=set_dns2 \ --data-urlencode domain=advisor.example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode subdomain0=_dmarc \ --data-urlencode sub_record_type0=txt \ --data-urlencode 'sub_record0=v=DMARC1; p=quarantine' ``` ### 2. Root domain plus subhost Put the root domain in `domain` and the full name under it as the subhost, the way registrars normally store subdomain records. Scripts written against a registrar often work this way already. ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY --data-urlencode command=set_dns2 \ --data-urlencode domain=example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode subdomain0=_dmarc.advisor \ --data-urlencode sub_record_type0=txt \ --data-urlencode 'sub_record0=v=DMARC1; p=quarantine' ``` In root form, `get_dns` lists only your hosts' records, all as subhosts. Main records (Dynadot) and `@` (the other styles) mean the root domain itself, which is not yours: sending one is an error. ### Both forms in each style | Style | Host form | Root form | |---|---|---| | Dynadot | `domain=advisor.example.com`; main records, or `subdomain0=_dmarc` | `domain=example.com`; `subdomain0=_dmarc.advisor` | | Spaceship | `/dns/records/advisor.example.com`; names `@`, `_dmarc` | `/dns/records/example.com`; names `advisor`, `_dmarc.advisor` | | Cloudflare | the host's zone (`/zones?name=advisor.example.com`); names `@`, `_dmarc` or full | the root's zone (`/zones?name=example.com`); full names | | GoDaddy | `/v1/domains/advisor.example.com/records`; names `@`, `_dmarc` | `/v1/domains/example.com/records`; names `advisor`, `_dmarc.advisor` | | Porkbun | `/dns/create/advisor.example.com`; `name` empty or `_dmarc` | `/dns/create/example.com`; `name` `advisor` or `_dmarc.advisor` | | Namecheap | `SLD=advisor.example&TLD=com`; `HostName1=@` or `_dmarc` | `SLD=example&TLD=com`; `HostName1=advisor` or `_dmarc.advisor` | Replies name records the way each style does: Cloudflare and Porkbun give full names, the others give names relative to what you addressed. ## The same records in both forms | Full name | Type | Host form: `domain=advisor.example.com` | Root form: `domain=example.com` | |---|---|---|---| | `advisor.example.com` | TXT, MX | main record, or name `@` | subhost `advisor` | | `google._domainkey.advisor.example.com` | TXT | `google._domainkey` | `google._domainkey.advisor` | | `_dmarc.advisor.example.com` | TXT | `_dmarc` | `_dmarc.advisor` | | `mail.advisor.example.com` | CNAME | `mail` | `mail.advisor` | | `example.com` | any | not reachable | not yours: error | | `www.example.com` | any | not reachable | not yours: error | ## Which form to use - **One host at a time:** host form. Names are shorter and there is only one way to get them right. - **Several hosts in the same root domain in one call:** root form. One call (`set_dns2`, `PUT`, `PATCH`, `setHosts`) can carry records for every host your key has in that root, and it is applied as one change. - **Reading:** either. In root form, reads return the records of all your hosts in that root, and nothing else. ## Replace calls work per host Some calls **replace** instead of adding: every host the call mentions ends up with exactly the records in the call, including the records under it. Hosts the call does not mention keep all their records. | Style | Replaces | Adds | |---|---|---| | Dynadot | `set_dns2` without `add_dns_to_current_setting=1` | `set_dns2` with `add_dns_to_current_setting=1` | | GoDaddy | `PUT /records`; `PUT /records/{type}` (that type only) | `PATCH /records` | | Namecheap | `setHosts`, always | none: read with `getHosts` and send everything back | | Spaceship | never | `PUT` | | Cloudflare | never (`PUT` replaces one record by id) | `POST` | | Porkbun | never (`editByNameType` changes one type at one name) | `/dns/create` | - In host form, the call mentions the host in `domain`. - In root form, it mentions each host that one of its subhosts belongs to: `_dmarc.advisor` mentions `advisor.example.com`. > **Careful:** a replace that sends only the DMARC record (`subdomain0=_dmarc.advisor`, or `HostName1=_dmarc`) leaves `advisor.example.com` with that one record. Its MX, SPF, verification and DKIM records are removed. Use the add call unless you mean to replace. On Dynadot, GoDaddy and Namecheap themselves a replace rewrites the whole domain. MailDNS limits it to the hosts you name, so a record forgotten for one host can't take another host down. ## Listing hosts Every style lists your key's hosts and nothing else: `list_domain` (Dynadot), `GET /api/v1/domains` (Spaceship), `GET /zones` (Cloudflare), `GET /v1/domains` (GoDaddy), `/domain/listAll` (Porkbun), `namecheap.domains.getList` (Namecheap). Hosts can't be added through the API. ## Names that are not yours | You sent | Reply (`Error` or `detail`) | |---|---| | A domain that is neither one of your hosts nor the root of one | `could not find domain in your account` (404 in the styles that use status codes) | | A name in the root that is not at or under one of your hosts | `www.example.com is not one of your hosts` (403) | | A main record with `domain` set to the root | `main records of example.com are not on your account; set records on your hosts with subdomain0 / sub_record_type0 / sub_record0` (403) | --- # Dynadot-style API `https://api.maildns.io/api3.json` takes the same commands, parameters and JSON replies as Dynadot's api3 for DNS records. Code written for Dynadot works after you change the base URL and the key. This style also has MailDNS's live check, [`dns_status`](#dns_status), which every other style uses with the same key. ## Requests - `GET` with a query string, or `POST` with the same parameters as a form-encoded body (`application/x-www-form-urlencoded`). Prefer `POST` for writes: long DKIM values and your key stay out of URLs. - Every call has `key` and `command`. Command names are lowercase. - **URL-encode every value.** A DKIM key contains `+`, `/` and `=`; an unencoded `+` arrives as a space and the record is wrong. With curl use `--data-urlencode`; with Python `requests`, pass a dict as `data=` or `params=`. - Only JSON. `/api3.xml` replies `XML is not supported: call /api3.json`. ## Replies Every reply is **HTTP 200** with a JSON object, exactly like Dynadot. The object has one key, named after the command (`SetDnsResponse`, `GetDnsResponse`, ...): ```json {"SetDnsResponse": {"ResponseCode": 0, "Status": "success"}} ``` Errors have `"ResponseCode": "-1"`, `"Status": "error"` and an `Error` text: ```json {"SetDnsResponse": {"ResponseCode": "-1", "Status": "error", "Error": "record type SRV is not allowed (allowed: A, AAAA, CNAME, MX, TXT)"}} ``` A wrong key, an unknown command and the rate limit use a plain `Response` object instead: ```json {"Response": {"ResponseCode": "-1", "Error": "invalid key"}} ``` Test for success with `str(ResponseCode) == "0"`: as on Dynadot, success is the number `0` and errors are the string `"-1"`. Every error text is listed in [Errors](https://maildns.io/docs/errors.md). ## Commands | Command | Parameters | Does | |---|---|---| | [`account_info`](#account_info) | none | The key's name and how many hosts it reaches | | [`list_domain`](#list_domain) | none | Your hosts | | [`domain_info`](#domain_info) | `domain` | One host, with its records | | [`get_dns`](#get_dns) | `domain` | The records of a host, or of all your hosts in a root domain | | [`set_dns2`](#set_dns2) | `domain`, records, `ttl`, `add_dns_to_current_setting` | Add or replace records | | [`get_ns`](#get_ns) | `domain` | The authoritative nameservers | | [`dns_status`](#dns_status) | `domain`, `subdomain`, `record_type`, `value` | Whether a record is live on every nameserver (MailDNS only) | `domain` is always either one of your hosts (`advisor.example.com`) or the root domain they live in (`example.com`). See [Hosts](https://maildns.io/docs/hosts.md). ### account_info ```bash curl 'https://api.maildns.io/api3.json?key=KEY&command=account_info' ``` ```json { "AccountInfoResponse": { "ResponseCode": 0, "Status": "success", "AccountInfo": {"Username": "acme-inboxes", "Hosts": "3"} } } ``` ### list_domain One entry per host on your key. ```bash curl 'https://api.maildns.io/api3.json?key=KEY&command=list_domain' ``` ```json { "ListDomainInfoResponse": { "ResponseCode": 0, "Status": "success", "MainDomains": [ { "Name": "advisor.example.com", "RootDomain": "example.com", "Expiration": "1798761600000", "Registration": "1735689600000", "NameServerSettings": {"Type": "Dynadot DNS"}, "Locked": "yes", "Disabled": "no", "Hold": "no", "Privacy": "full", "RenewOption": "auto renew", "Status": "active" }, { "Name": "billing.example.com", "RootDomain": "example.com", "Expiration": "1798761600000", "Registration": "1735689600000", "NameServerSettings": {"Type": "Dynadot DNS"}, "Locked": "yes", "Disabled": "no", "Hold": "no", "Privacy": "full", "RenewOption": "auto renew", "Status": "active" } ] } } ``` - `Name`: the host. `RootDomain`: the registered domain it lives in. - `Expiration` and `Registration` are the root domain's dates, as milliseconds since 1970 in a string. - `NameServerSettings.Type` is always `"Dynadot DNS"` and `Status` is always `"active"`. The other fields are there so Dynadot parsers don't break; don't rely on them. ### domain_info The `list_domain` entry for one host, with its records in `NameServerSettings` (same shape as [`get_dns`](#get_dns)). ```bash curl 'https://api.maildns.io/api3.json?key=KEY&command=domain_info&domain=advisor.example.com' ``` ```json { "DomainInfoResponse": { "ResponseCode": 0, "Status": "success", "DomainInfo": { "Name": "advisor.example.com", "RootDomain": "example.com", "Expiration": "1798761600000", "Registration": "1735689600000", "NameServerSettings": { "Type": "Dynadot DNS", "MainDomains": [ {"RecordType": "MX", "Value": "smtp.google.com", "Value2": "1"}, {"RecordType": "TXT", "Value": "v=spf1 include:_spf.google.com ~all"} ], "SubDomains": [ {"Subhost": "_dmarc", "RecordType": "TXT", "Value": "v=DMARC1; p=quarantine"} ], "TTL": "3600" }, "Locked": "yes", "Disabled": "no", "Hold": "no", "Privacy": "full", "RenewOption": "auto renew", "Status": "active" } } } ``` ### get_dns The records of a host (host form) or of all your hosts in a root domain (root form). ```bash curl 'https://api.maildns.io/api3.json?key=KEY&command=get_dns&domain=advisor.example.com' ``` ```json { "GetDnsResponse": { "ResponseCode": 0, "Status": "success", "GetDns": { "NameServerSettings": { "Type": "Dynadot DNS", "MainDomains": [ {"RecordType": "MX", "Value": "smtp.google.com", "Value2": "1"}, {"RecordType": "TXT", "Value": "v=spf1 include:_spf.google.com ~all"}, {"RecordType": "TXT", "Value": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ"} ], "SubDomains": [ {"Subhost": "_dmarc", "RecordType": "TXT", "Value": "v=DMARC1; p=quarantine"}, {"Subhost": "google._domainkey", "RecordType": "TXT", "Value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB"} ], "TTL": "3600" } } } } ``` | Field | Meaning | |---|---| | `MainDomains[]` | Records at `domain` itself. Empty in root form. | | `SubDomains[]` | Records under `domain`. `Subhost` is the name in front of `domain`. | | `RecordType` | `A`, `AAAA`, `CNAME`, `MX` or `TXT` (uppercase in replies) | | `Value` | The record's value. TXT values come back whole, without quotes. | | `Value2` | MX priority. Only on MX records. | | `TTL` | The TTL in seconds, as a string | The same host in root form (`domain=example.com`) comes back with every record under `SubDomains`: ```json { "GetDnsResponse": { "ResponseCode": 0, "Status": "success", "GetDns": { "NameServerSettings": { "Type": "Dynadot DNS", "MainDomains": [], "SubDomains": [ {"Subhost": "advisor", "RecordType": "MX", "Value": "smtp.google.com", "Value2": "1"}, {"Subhost": "advisor", "RecordType": "TXT", "Value": "v=spf1 include:_spf.google.com ~all"}, {"Subhost": "_dmarc.advisor", "RecordType": "TXT", "Value": "v=DMARC1; p=quarantine"} ], "TTL": "3600" } } } } ``` ### set_dns2 Adds or replaces records. Returns once the change is saved at the authoritative nameservers. | Parameter | Value | |---|---| | `domain` | A host (`advisor.example.com`) or the root of your hosts (`example.com`) | | `main_record_type{n}` | `a`, `aaaa`, `cname`, `mx` or `txt`: a record at `domain` itself. Host form only. | | `main_record{n}` | Its value | | `main_recordx{n}` | MX priority, 0 to 65535. MX only. | | `subdomain{n}` | A name under `domain`: `_dmarc`, `google._domainkey` (host form) or `_dmarc.advisor` (root form) | | `sub_record_type{n}` | `a`, `aaaa`, `cname`, `mx` or `txt` | | `sub_record{n}` | Its value | | `sub_recordx{n}` | MX priority. MX only. | | `ttl` | Accepted for compatibility. Records are served with the domain's TTL, which MailDNS never changes ([why](https://maildns.io/docs/keys.md#record-rules)). | | `add_dns_to_current_setting` | `1` adds the records to what is there. Leave it out to replace (below). | `n` counts from 0, separately for main and subdomain records, up to 100 of each per call. Record types are lowercase like Dynadot's (uppercase works too). **Add mode** (`add_dns_to_current_setting=1`): the records are added to what is there. A record that is already there (same name, type and value) is skipped, so repeating a call is harmless. **Replace mode** (no add flag): every host the call mentions ends up with **exactly** the records in the call, including the records under it. Hosts the call does not mention keep their records. On Dynadot itself a replace wipes the whole domain; MailDNS limits it to the hosts you name. See [Replace calls work per host](https://maildns.io/docs/hosts.md#replace-calls-work-per-host). The success reply is always the same: ```json {"SetDnsResponse": {"ResponseCode": 0, "Status": "success"}} ``` #### Add a verification TXT at the host As a URL (values encoded by hand): ```text https://api.maildns.io/api3.json?key=KEY&command=set_dns2&domain=advisor.example.com&add_dns_to_current_setting=1&main_record_type0=txt&main_record0=google-site-verification%3D3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` The same call as a `POST`: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=set_dns2 \ --data-urlencode domain=advisor.example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode main_record_type0=txt \ --data-urlencode main_record0=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` #### Add DKIM in root form ```text https://api.maildns.io/api3.json?key=KEY&command=set_dns2&domain=example.com&add_dns_to_current_setting=1&subdomain0=google._domainkey.advisor&sub_record_type0=txt&sub_record0=v%3DDKIM1%3B%20k%3Drsa%3B%20p%3DMIIBIjANBgkqh... ``` #### Mail records in one call MX, SPF and DMARC for one host: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=set_dns2 \ --data-urlencode domain=advisor.example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode main_record_type0=mx \ --data-urlencode main_record0=smtp.google.com \ --data-urlencode main_recordx0=1 \ --data-urlencode main_record_type1=txt \ --data-urlencode 'main_record1=v=spf1 include:_spf.google.com ~all' \ --data-urlencode subdomain0=_dmarc \ --data-urlencode sub_record_type0=txt \ --data-urlencode 'sub_record0=v=DMARC1; p=quarantine' ``` #### Two hosts in one call (root form) ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=set_dns2 \ --data-urlencode domain=example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode subdomain0=advisor \ --data-urlencode sub_record_type0=mx \ --data-urlencode sub_record0=smtp.google.com \ --data-urlencode sub_recordx0=1 \ --data-urlencode subdomain1=billing \ --data-urlencode sub_record_type1=mx \ --data-urlencode sub_record1=smtp.google.com \ --data-urlencode sub_recordx1=1 ``` #### Delete one record There is no delete command, as on Dynadot. Read the host with `get_dns`, drop the record, and send the rest back in replace mode (no add flag). Every record of the host you leave out is removed, so send all the others: ```python import os import requests API = "https://api.maildns.io/api3.json" KEY = os.environ["MAILDNS_KEY"] def call(**params): reply = requests.post(API, data={"key": KEY, **params}, timeout=90).json() inner = next(iter(reply.values())) if str(inner.get("ResponseCode")) != "0": raise RuntimeError(inner.get("Error")) return inner def delete_record(host, subhost, rtype, value): """Remove one record at host ("" = the host itself) or under it; keep all the others.""" ns = call(command="get_dns", domain=host)["GetDns"]["NameServerSettings"] keep = [("", r) for r in ns["MainDomains"]] + [(r["Subhost"], r) for r in ns["SubDomains"]] keep = [(sub, r) for sub, r in keep if not (sub == subhost and r["RecordType"].upper() == rtype.upper() and r["Value"] == value)] params = {"command": "set_dns2", "domain": host} # no add flag: replace this host's records m = s = 0 for sub, r in keep: if sub == "": params[f"main_record_type{m}"] = r["RecordType"].lower() params[f"main_record{m}"] = r["Value"] if r.get("Value2"): params[f"main_recordx{m}"] = r["Value2"] m += 1 else: params[f"subdomain{s}"] = sub params[f"sub_record_type{s}"] = r["RecordType"].lower() params[f"sub_record{s}"] = r["Value"] if r.get("Value2"): params[f"sub_recordx{s}"] = r["Value2"] s += 1 if m + s == 0: raise RuntimeError("that was the host's last record: delete it Spaceship style instead") call(**params) delete_record("advisor.example.com", "_dmarc", "TXT", "v=DMARC1; p=none") ``` A replace has to carry at least one record. To remove a host's last record, use the Spaceship-style [`DELETE`](https://maildns.io/docs/spaceship.md#delete-records), which removes exactly the records you list. ### get_ns The authoritative nameservers of the root domain the host lives in. Read-only: [nameservers can't be changed](#nameservers). ```bash curl 'https://api.maildns.io/api3.json?key=KEY&command=get_ns&domain=advisor.example.com' ``` ```json { "GetNsResponse": { "ResponseCode": 0, "Status": "success", "NsContent": {"Host0": "ns1.example.net", "Host1": "ns2.example.net", "NsName": "MailDNS"} } } ``` ### dns_status MailDNS's own command: asks every authoritative nameserver directly whether it serves a record. Use it after a write and before asking Google or Microsoft to verify. Read-only. | Parameter | Value | |---|---| | `domain` | A host or the root of your hosts | | `subdomain` | The record's name relative to `domain`. `@` means `domain` itself. | | `record_type` | `txt`, `mx`, `cname`, `a` or `aaaa` | | `value` | The record's value (for MX, the mail server) | - **With `subdomain`, `record_type` and `value`**, it checks exactly that record and always returns it, with `Live: "no"` until every nameserver serves it. Use this form when waiting for a record you just wrote. - **With fewer parameters**, it checks every record of `domain` that matches the ones you gave, and returns an empty list when none match. ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=dns_status \ --data-urlencode domain=advisor.example.com \ --data-urlencode subdomain=google._domainkey \ --data-urlencode record_type=txt \ --data-urlencode 'value=v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB' ``` ```json { "DnsStatusResponse": { "ResponseCode": 0, "Status": "success", "Records": [ { "Name": "google._domainkey.advisor.example.com", "RecordType": "TXT", "Value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB", "Live": "no", "Servers": {"ns1.example.net": "yes", "ns2.example.net": "no"} } ] } } ``` | Field | Meaning | |---|---| | `Name` | The record's full name | | `Live` | `yes` when every nameserver answers with the record, otherwise `no` | | `Servers` | Each authoritative nameserver and its answer, `yes` or `no` | How to poll it: [Checking propagation](https://maildns.io/docs/propagation.md). ## Nameservers No key can change nameservers. `set_ns`, `add_ns`, `register_ns`, `delete_ns` and NS records in `set_dns2` reply: ```json {"Response": {"ResponseCode": "-1", "Error": "This API key doesn't work for nameservers."}} ``` (An NS record inside `set_dns2` comes back in `SetDnsResponse`, with `"Status": "error"`.) Reading them with [`get_ns`](#get_ns) is fine. ## Commands that are not available MailDNS manages DNS records only. Commands that change registration, forwarding, contacts, transfers, renewals or anything else outside DNS records reply: ```json {"Response": {"ResponseCode": "-1", "Error": "command not available on this account"}} ``` This covers `register`, `renew`, `delete`, `push`, `set_forwarding`, `set_stealth`, `set_email_forward`, `set_whois`, `set_dns` (use `set_dns2`), `set_dnssec` and the rest of Dynadot's account and registration commands. An unknown command replies `incorrect command name`. --- # Spaceship-style API `https://api.maildns.io/api/v1` takes the same paths, headers and JSON bodies as Spaceship's DNS records API. Code written for Spaceship works after you change the base URL, the key and the secret. ## Requests and replies Every call sends three headers: ```http X-API-Key: KEY X-API-Secret: SECRET Content-Type: application/json ``` - Reads reply **200** with JSON. Writes reply **204** with no body once the change is saved at the authoritative nameservers. - Errors reply **4xx** or **5xx** with a JSON body in Spaceship's shape: ```json {"detail": "www.example.com is not one of your hosts", "data": []} ``` | Status | Meaning | |---|---| | 400 | The body is not valid JSON | | 401 | Wrong key or secret, or a call from an IP address the key is not locked to | | 403 | A name that is not at or under one of your hosts, or a nameserver change | | 404 | A domain that is not yours, or an unknown path | | 422 | An invalid record or request: type, value, TTL, CNAME clash, too many records | | 429 | Too many requests, or an earlier change to the same root domain is still running. Wait `Retry-After` seconds (or a few seconds if there is none) and retry. | | 503 | The change could not be made right now. Nothing was changed. | More in [Errors](https://maildns.io/docs/errors.md). `{domain}` in a path is one of your hosts (`advisor.example.com`) or the root domain they live in (`example.com`), except in `/domains/{domain}`, which takes a host. See [Hosts](https://maildns.io/docs/hosts.md). ## Endpoints | Method and path | Does | |---|---| | [`GET /api/v1/domains`](#list-hosts) | Your hosts | | [`GET /api/v1/domains/{domain}`](#get-one-host) | One host | | [`GET /api/v1/dns/records/{domain}`](#list-records) | Records | | [`PUT /api/v1/dns/records/{domain}`](#add-records) | Add records | | [`DELETE /api/v1/dns/records/{domain}`](#delete-records) | Remove records | | [`GET /api/v1/dns/status/{domain}`](#check-records) | Whether a record is live on every nameserver (MailDNS only) | ## Record items Names are relative to `{domain}`; `@` is `{domain}` itself. `ttl` is optional (300 to 86400, default 3600); it is used where the domain supports a TTL per record, otherwise the record gets the domain's TTL ([more](https://maildns.io/docs/keys.md#record-rules)). ```json {"type": "TXT", "name": "@", "value": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ", "ttl": 3600} {"type": "MX", "name": "@", "exchange": "smtp.google.com", "preference": 1, "ttl": 3600} {"type": "CNAME", "name": "mail", "cname": "ghs.googlehosted.com", "ttl": 3600} {"type": "A", "name": "@", "address": "203.0.113.10", "ttl": 3600} {"type": "TXT", "name": "google._domainkey", "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh...", "ttl": 3600} ``` | Type | Value field | Also | |---|---|---| | `TXT` | `value`: the text, without quotes, up to 2,048 characters | | | `MX` | `exchange`: the mail server | `preference`: 0 to 65535 | | `CNAME` | `cname`: the target name | | | `A` | `address`: an IPv4 address | | | `AAAA` | `address`: an IPv6 address | | Replies also carry `"group": {"type": "custom"}` on each item, as Spaceship does. You don't need to send it. ## List hosts `GET /api/v1/domains?take=100&skip=0`: one item per host on your key. `take` is 1 to 100. ```bash curl 'https://api.maildns.io/api/v1/domains?take=100&skip=0' \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' ``` ```json { "items": [ { "name": "advisor.example.com", "unicodeName": "advisor.example.com", "isPremium": false, "autoRenew": true, "registrationDate": "2025-01-01T00:00:00.000Z", "expirationDate": "2027-01-01T00:00:00.000Z", "lifecycleStatus": "registered", "verificationStatus": null, "eppStatuses": ["clientTransferProhibited"], "suspensions": [], "privacyProtection": {"level": "high", "contactForm": true}, "nameservers": {"provider": "basic", "hosts": ["ns1.example.net", "ns2.example.net"]} } ], "total": 1 } ``` The dates are the root domain's. Fields other than `name`, `unicodeName`, the dates, `lifecycleStatus` and `nameservers` are there so Spaceship parsers don't break; don't rely on them. ## Get one host `GET /api/v1/domains/{domain}` with a host: the same item, on its own. Anything else is 404. ```bash curl https://api.maildns.io/api/v1/domains/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' ``` ## List records `GET /api/v1/dns/records/{domain}?take=500&skip=0`. `take` is 1 to 500. ```bash curl 'https://api.maildns.io/api/v1/dns/records/advisor.example.com?take=500&skip=0' \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' ``` ```json { "items": [ {"type": "MX", "name": "@", "ttl": 3600, "exchange": "smtp.google.com", "preference": 1, "group": {"type": "custom"}}, {"type": "TXT", "name": "@", "ttl": 3600, "value": "v=spf1 include:_spf.google.com ~all", "group": {"type": "custom"}}, {"type": "TXT", "name": "_dmarc", "ttl": 3600, "value": "v=DMARC1; p=quarantine", "group": {"type": "custom"}} ], "total": 3 } ``` With the root domain in the path, you get every record of your hosts in that root, with names like `advisor` and `_dmarc.advisor`. ## Add records `PUT /api/v1/dns/records/{domain}` with `{"force": false, "items": [...]}`. Replies **204**. ```bash curl -X PUT https://api.maildns.io/api/v1/dns/records/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' -H 'Content-Type: application/json' \ -d '{ "force": false, "items": [ {"type": "MX", "name": "@", "exchange": "smtp.google.com", "preference": 1, "ttl": 3600}, {"type": "TXT", "name": "@", "value": "v=spf1 include:_spf.google.com ~all", "ttl": 3600}, {"type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=quarantine", "ttl": 3600} ] }' ``` - The items are **added** to what is there. Items that are already there (same type, name and value) are skipped, so repeating a `PUT` is harmless. - `PUT` never removes anything, except with `force` (next point). - A CNAME can't share its name with other records. If an item would break that rule, the call is refused with 422, unless `"force": true`: then the records already at that name are replaced by the new ones. - An empty `items` list is 422. ## Delete records `DELETE /api/v1/dns/records/{domain}` with a JSON array of items. Replies **204**. ```bash curl -X DELETE https://api.maildns.io/api/v1/dns/records/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' -H 'Content-Type: application/json' \ -d '[{"type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=none"}]' ``` - Removes exactly the records you list, matched on type, name and value (the `exchange` for MX, `cname` for CNAME, `address` for A and AAAA). `ttl` is ignored. - Items that don't exist are ignored, so repeating a `DELETE` is harmless. - To change a record, `DELETE` the old one, then `PUT` the new one. ## Nameservers No key can change nameservers. A nameserver update, or an `NS` item in `PUT`, replies HTTP 403: ```json {"detail": "This API key doesn't work for nameservers.", "data": []} ``` `nameservers` in the domain items is there to read. ## Check records `GET /api/v1/dns/status/{domain}?name=&type=&value=` is MailDNS's own endpoint: it asks every authoritative nameserver directly whether it serves a record. Use it after a write and before asking Google or Microsoft to verify. - **With `name`, `type` and `value`**, it checks exactly that record and always returns it, with `"live": false` until every nameserver serves it. - **With fewer parameters**, it checks every record of `{domain}` that matches the ones you gave. ```bash curl -G https://api.maildns.io/api/v1/dns/status/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' \ --data-urlencode name=@ \ --data-urlencode type=TXT \ --data-urlencode value=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` ```json { "items": [ { "name": "advisor.example.com", "type": "TXT", "value": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ", "live": true, "servers": {"ns1.example.net": true, "ns2.example.net": true} } ] } ``` `name` in the reply is the record's full name. How to poll it: [Checking propagation](https://maildns.io/docs/propagation.md). --- # Cloudflare-style API `https://api.maildns.io/client/v4` takes the same paths, headers and JSON envelope as Cloudflare's API v4 for DNS records. Point a Cloudflare client at that base URL and give it your MailDNS key as the API token. ## Requests and replies Send the key as a bearer token: ```http Authorization: Bearer KEY Content-Type: application/json ``` Clients that use Cloudflare's older key-and-email pair work too. Any email is accepted: ```http X-Auth-Key: KEY X-Auth-Email: you@example.com ``` Every reply is Cloudflare's envelope. Lists add `result_info`: ```json { "success": true, "errors": [], "messages": [], "result": [], "result_info": {"page": 1, "per_page": 20, "count": 0, "total_count": 0, "total_pages": 0} } ``` Errors have `"success": false` and a code in `errors`: ```json {"success": false, "errors": [{"code": 1004, "message": "This API key doesn't work for nameservers."}], "messages": [], "result": null} ``` | HTTP | Code | Meaning | |---|---|---| | 403 | 10000 | Wrong key, or a call from an IP address the key is not locked to | | 404 | 7003 | Unknown zone id, or a zone that is not yours | | 404 | 81044 | Unknown record id | | 400 | 1004 | Invalid record: type, content, TTL, CNAME clash, too many records. `message` says which. | | 403 | 1004 | A name outside your hosts, an NS record, or a nameserver change | | 429 | 10013 | Too many requests, or an earlier change to the same root domain is still running. Wait `Retry-After` seconds and retry. | | 503 | 1000 | The change could not be made right now. Nothing was changed. | The `message` texts are the ones listed in [Errors](https://maildns.io/docs/errors.md). ## Endpoints | Method and path | Does | |---|---| | [`GET /user/tokens/verify`](#check-the-key) | Check the key | | [`GET /zones`](#zones) | Your hosts, as zones. `?name=` finds one by name. | | [`GET /zones/{zone_id}`](#zones) | One zone | | [`GET /zones/{zone_id}/dns_records`](#list-records) | Records, filtered by `type`, `name`, `content` | | [`POST /zones/{zone_id}/dns_records`](#create-a-record) | Create a record | | [`PUT /zones/{zone_id}/dns_records/{id}`](#replace-or-change-a-record) | Replace one record | | [`PATCH /zones/{zone_id}/dns_records/{id}`](#replace-or-change-a-record) | Change some fields of one record | | [`DELETE /zones/{zone_id}/dns_records/{id}`](#delete-a-record) | Delete one record | ## Check the key ```bash curl https://api.maildns.io/client/v4/user/tokens/verify \ -H 'Authorization: Bearer KEY' ``` ```json {"success": true, "errors": [], "messages": [], "result": {"id": "c0a8f3e1d2b4a6978e5f4d3c2b1a0f9e", "status": "active"}} ``` ## Zones Each of your hosts is a zone, and the zone's `name` is the host. Find a zone's id by name: ```bash curl 'https://api.maildns.io/client/v4/zones?name=advisor.example.com' \ -H 'Authorization: Bearer KEY' ``` ```json { "success": true, "errors": [], "messages": [], "result": [ { "id": "3f1a6c2e9b7d4e08a5c1f2b3d4e5f607", "name": "advisor.example.com", "status": "active", "paused": false, "type": "full", "name_servers": ["ns1.example.net", "ns2.example.net"], "created_on": "2025-01-01T00:00:00Z", "modified_on": "2026-10-18T09:30:00Z" } ], "result_info": {"page": 1, "per_page": 20, "count": 1, "total_count": 1, "total_pages": 1} } ``` - Without `name`, you get every host on your key, `per_page` at a time (up to 50, default 20; `page` from 1). - Zone ids never change, so you can store them. - The root domain holding your hosts can be looked up by name too (`?name=example.com`). Its zone shows only your hosts' records. - `name_servers` is there to read. It can't be changed. - `GET /zones/{zone_id}` returns one zone, as the `result` object. ## List records `GET /zones/{zone_id}/dns_records` with optional filters `type`, `name` (the full name), `content`, `page` and `per_page`: ```bash curl 'https://api.maildns.io/client/v4/zones/3f1a6c2e9b7d4e08a5c1f2b3d4e5f607/dns_records?type=TXT' \ -H 'Authorization: Bearer KEY' ``` ```json { "success": true, "errors": [], "messages": [], "result": [ { "id": "8b2e5d1f0a9c4b7e6d3f2a1c0b9e8d7f", "zone_id": "3f1a6c2e9b7d4e08a5c1f2b3d4e5f607", "zone_name": "advisor.example.com", "name": "advisor.example.com", "type": "TXT", "content": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ", "proxiable": false, "proxied": false, "ttl": 3600, "locked": false, "created_on": "2026-10-18T09:30:00Z", "modified_on": "2026-10-18T09:30:00Z" }, { "id": "51c0e4a7d2b98f3e6a1d0c5b4e7f2a93", "zone_id": "3f1a6c2e9b7d4e08a5c1f2b3d4e5f607", "zone_name": "advisor.example.com", "name": "_dmarc.advisor.example.com", "type": "TXT", "content": "v=DMARC1; p=quarantine", "proxiable": false, "proxied": false, "ttl": 3600, "locked": false, "created_on": "2026-10-18T09:31:00Z", "modified_on": "2026-10-18T09:31:00Z" } ], "result_info": {"page": 1, "per_page": 100, "count": 2, "total_count": 2, "total_pages": 1} } ``` `name` in replies is always the full name. MX records also carry `priority`. `proxied` is always `false`: MailDNS serves DNS records only. ## Create a record `POST /zones/{zone_id}/dns_records` with `type`, `name`, `content`, `ttl`, and `priority` for MX. `name` can be the full name, the part in front of the zone (`_dmarc`), or `@` for the zone itself. ```bash curl -X POST https://api.maildns.io/client/v4/zones/3f1a6c2e9b7d4e08a5c1f2b3d4e5f607/dns_records \ -H 'Authorization: Bearer KEY' -H 'Content-Type: application/json' \ -d '{"type": "TXT", "name": "@", "ttl": 3600, "content": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ"}' ``` The reply's `result` is the new record, with its `id`. More shapes: ```json {"type": "MX", "name": "@", "content": "smtp.google.com", "priority": 1, "ttl": 3600} {"type": "TXT", "name": "google._domainkey", "content": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh...", "ttl": 3600} {"type": "CNAME", "name": "mail", "content": "ghs.googlehosted.com", "ttl": 3600} {"type": "A", "name": "@", "content": "203.0.113.10", "ttl": 3600} ``` - If the same record (type, name, content) is already there, you get that record back instead of an error, so retrying a create is harmless. - TXT `content` goes without surrounding quotes, in one piece, even past 255 characters. - `ttl` follows [the TTL rule](https://maildns.io/docs/keys.md#record-rules): `1` (automatic) and any value are accepted; the record is served with the domain's TTL unless the domain supports one per record. ## Replace or change a record `PUT` replaces the whole record; `PATCH` changes only the fields you send. ```bash curl -X PATCH https://api.maildns.io/client/v4/zones/3f1a6c2e9b7d4e08a5c1f2b3d4e5f607/dns_records/51c0e4a7d2b98f3e6a1d0c5b4e7f2a93 \ -H 'Authorization: Bearer KEY' -H 'Content-Type: application/json' \ -d '{"content": "v=DMARC1; p=reject"}' ``` Record ids come from the record itself, so a changed record comes back with a **new id**. Use the `id` in the reply from then on. ## Delete a record ```bash curl -X DELETE https://api.maildns.io/client/v4/zones/3f1a6c2e9b7d4e08a5c1f2b3d4e5f607/dns_records/51c0e4a7d2b98f3e6a1d0c5b4e7f2a93 \ -H 'Authorization: Bearer KEY' ``` ```json {"success": true, "errors": [], "messages": [], "result": {"id": "51c0e4a7d2b98f3e6a1d0c5b4e7f2a93"}} ``` ## Nameservers NS records and anything that would change a zone's nameservers are refused with HTTP 403, code 1004, `This API key doesn't work for nameservers.` Zones can't be created or deleted either. Reading `name_servers` is fine. ## Check that a record is live The live check is a [Dynadot-style call](https://maildns.io/docs/propagation.md), and it takes the same key: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY --data-urlencode command=dns_status \ --data-urlencode domain=advisor.example.com --data-urlencode subdomain=@ \ --data-urlencode record_type=txt \ --data-urlencode value=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` Wait for `"Live": "yes"` before asking Google or Microsoft to verify. --- # GoDaddy-style API `https://api.maildns.io/v1` takes the same paths, header and JSON bodies as GoDaddy's Domains API (v1) for DNS records. Point a GoDaddy client at that base URL and give it your MailDNS key and secret. ## Requests and replies ```http Authorization: sso-key KEY:SECRET Content-Type: application/json ``` - Reads reply **200** with JSON. Adds and replaces reply **200** with an empty body once the change is saved at the authoritative nameservers. `DELETE` replies **204**. - `{domain}` is one of your hosts (`advisor.example.com`) or the root domain they live in (`example.com`). Record names are relative to it, and `@` is `{domain}` itself. See [Hosts](https://maildns.io/docs/hosts.md). - Errors are GoDaddy's `{"code", "message"}`: ```json {"code": "ACCESS_DENIED", "message": "This API key doesn't work for nameservers."} ``` | HTTP | `code` | Meaning | |---|---|---| | 401 | `UNABLE_TO_AUTHENTICATE` | Wrong key or secret, or a call from an IP address the key is not locked to | | 403 | `ACCESS_DENIED` | A name outside your hosts, an NS record, a nameserver change, or anything outside DNS records | | 404 | `NOT_FOUND` | A domain that is not yours, or an unknown path | | 422 | `INVALID_BODY` | An invalid record: type, data, TTL, CNAME clash, too many records. `message` says which. | | 429 | `TOO_MANY_REQUESTS` | Too many requests, or an earlier change to the same root domain is still running. Wait `Retry-After` seconds and retry. | | 503 | `UNAVAILABLE` | The change could not be made right now. Nothing was changed. | ## Endpoints | Method and path | Body | Does | |---|---|---| | `GET /v1/domains` | | Your hosts | | `GET /v1/domains/{domain}` | | One host, with `nameServers` | | `GET /v1/domains/{domain}/records` | | All records | | `GET /v1/domains/{domain}/records/{type}` | | Records of one type | | `GET /v1/domains/{domain}/records/{type}/{name}` | | Records of one type at one name | | `PATCH /v1/domains/{domain}/records` | `[records]` | **Add** records | | `PUT /v1/domains/{domain}/records` | `[records]` | **Replace** all records of each host the body mentions | | `PUT /v1/domains/{domain}/records/{type}` | `[records]` | **Replace** all records of that type on each host the body mentions | | `PUT /v1/domains/{domain}/records/{type}/{name}` | `[{data, ttl, priority}]` | **Replace** the records of that type at that name | | `DELETE /v1/domains/{domain}/records/{type}/{name}` | | Delete the records of that type at that name | ## Records ```json {"type": "TXT", "name": "@", "data": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ", "ttl": 3600} {"type": "MX", "name": "@", "data": "smtp.google.com", "priority": 1, "ttl": 3600} {"type": "TXT", "name": "google._domainkey", "data": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh...", "ttl": 3600} {"type": "CNAME", "name": "mail", "data": "ghs.googlehosted.com", "ttl": 3600} {"type": "A", "name": "@", "data": "203.0.113.10", "ttl": 3600} ``` `priority` is for MX only. `ttl` follows [the TTL rule](https://maildns.io/docs/keys.md#record-rules). TXT `data` goes without surrounding quotes, in one piece. ## List hosts ```bash curl https://api.maildns.io/v1/domains \ -H 'Authorization: sso-key KEY:SECRET' ``` ```json [ {"domain": "advisor.example.com", "status": "ACTIVE", "expires": "2027-01-01T00:00:00.000Z", "renewAuto": true}, {"domain": "billing.example.com", "status": "ACTIVE", "expires": "2027-01-01T00:00:00.000Z", "renewAuto": true} ] ``` `expires` and `renewAuto` describe the root domain. One host, with its nameservers (read-only): ```bash curl https://api.maildns.io/v1/domains/advisor.example.com \ -H 'Authorization: sso-key KEY:SECRET' ``` ```json {"domain": "advisor.example.com", "status": "ACTIVE", "expires": "2027-01-01T00:00:00.000Z", "renewAuto": true, "nameServers": ["ns1.example.net", "ns2.example.net"]} ``` ## Read records ```bash curl https://api.maildns.io/v1/domains/advisor.example.com/records/TXT \ -H 'Authorization: sso-key KEY:SECRET' ``` ```json [ {"type": "TXT", "name": "@", "data": "v=spf1 include:_spf.google.com ~all", "ttl": 3600}, {"type": "TXT", "name": "_dmarc", "data": "v=DMARC1; p=quarantine", "ttl": 3600} ] ``` ## Add records `PATCH` adds the records to what is there. Records already there (same type, name and data) are skipped, so repeating it is harmless. ```bash curl -X PATCH https://api.maildns.io/v1/domains/advisor.example.com/records \ -H 'Authorization: sso-key KEY:SECRET' -H 'Content-Type: application/json' \ -d '[ {"type": "MX", "name": "@", "data": "smtp.google.com", "priority": 1, "ttl": 3600}, {"type": "TXT", "name": "@", "data": "v=spf1 include:_spf.google.com ~all", "ttl": 3600}, {"type": "TXT", "name": "_dmarc", "data": "v=DMARC1; p=quarantine", "ttl": 3600} ]' ``` ## Replace records The three `PUT` calls replace, so use them with care: - `PUT /records` leaves each host the body mentions with **exactly** the records in the body, including the records under it. On GoDaddy itself this call replaces the whole domain; MailDNS limits it to the hosts you name. - `PUT /records/{type}` replaces every record of that type on those hosts. `PUT /records/TXT` with only an SPF record removes the host's verification, DKIM and DMARC records too. - `PUT /records/{type}/{name}` touches one type at one name. It is the safe way to change a single record, such as DMARC: ```bash curl -X PUT https://api.maildns.io/v1/domains/advisor.example.com/records/TXT/_dmarc \ -H 'Authorization: sso-key KEY:SECRET' -H 'Content-Type: application/json' \ -d '[{"data": "v=DMARC1; p=reject", "ttl": 3600}]' ``` ## Delete records Deletes every record of that type at that name: ```bash curl -X DELETE https://api.maildns.io/v1/domains/advisor.example.com/records/TXT/_dmarc \ -H 'Authorization: sso-key KEY:SECRET' ``` To delete one of several TXT records at the same name, `PUT /records/TXT/@` with the ones to keep. ## Nameservers Changing nameservers (`PATCH /v1/domains/{domain}` with `nameServers`, or any NS record) is refused with HTTP 403, `ACCESS_DENIED`, `This API key doesn't work for nameservers.` Registration calls (purchase, renew, transfer, contacts) are not available either. Reading `nameServers` is fine. ## Check that a record is live The live check is a [Dynadot-style call](https://maildns.io/docs/propagation.md), and it takes the same key: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY --data-urlencode command=dns_status \ --data-urlencode domain=advisor.example.com --data-urlencode subdomain=_dmarc \ --data-urlencode record_type=txt --data-urlencode 'value=v=DMARC1; p=quarantine' ``` Wait for `"Live": "yes"` before asking Google or Microsoft to verify. --- # Porkbun-style API `https://api.maildns.io/api/json/v3` takes the same paths and JSON bodies as Porkbun's API v3 for DNS records. Point a Porkbun client at that base URL and give it your MailDNS key and secret. ## Requests and replies Every call is a `POST` with a JSON body that carries the key and secret, plus the call's own fields: ```json {"apikey": "KEY", "secretapikey": "SECRET"} ``` - `{domain}` in a path is one of your hosts (`advisor.example.com`) or the root domain they live in (`example.com`). See [Hosts](https://maildns.io/docs/hosts.md). - In requests, `name` (and `{subdomain}` in paths) is the part in front of `{domain}`: `_dmarc`, `google._domainkey`. Leave it empty for `{domain}` itself. Replies give full names, as Porkbun does. - Record ids are numeric strings. - Success: `{"status": "SUCCESS", ...}`. Errors: `{"status": "ERROR", "message": "..."}` with a 4xx HTTP status (429 when you should wait and retry, 503 when the change can't be made right now). Check `status`; the `message` texts are listed in [Errors](https://maildns.io/docs/errors.md). ```json {"status": "ERROR", "message": "This API key doesn't work for nameservers."} ``` ## Endpoints | Path (all `POST`) | Does | |---|---| | `/ping` | Check the key; returns the IP address you called from | | `/domain/listAll` | Your hosts | | `/domain/getNs/{domain}` | The nameservers (read-only) | | `/domain/updateNs/{domain}` | Refused: nameservers | | `/dns/retrieve/{domain}` | All records | | `/dns/retrieve/{domain}/{id}` | One record | | `/dns/retrieveByNameType/{domain}/{type}[/{subdomain}]` | Records of one type at one name | | `/dns/create/{domain}` | Create a record; returns its `id` | | `/dns/edit/{domain}/{id}` | Change one record | | `/dns/editByNameType/{domain}/{type}[/{subdomain}]` | Change every record of that type at that name | | `/dns/delete/{domain}/{id}` | Delete one record | | `/dns/deleteByNameType/{domain}/{type}[/{subdomain}]` | Delete every record of that type at that name | ## Check the key ```bash curl https://api.maildns.io/api/json/v3/ping \ -H 'Content-Type: application/json' \ -d '{"apikey": "KEY", "secretapikey": "SECRET"}' ``` ```json {"status": "SUCCESS", "yourIp": "203.0.113.7"} ``` ## List hosts ```bash curl https://api.maildns.io/api/json/v3/domain/listAll \ -H 'Content-Type: application/json' \ -d '{"apikey": "KEY", "secretapikey": "SECRET"}' ``` ```json { "status": "SUCCESS", "domains": [ { "domain": "advisor.example.com", "status": "ACTIVE", "tld": "com", "createDate": "2025-01-01 00:00:00", "expireDate": "2027-01-01 00:00:00", "securityLock": "1", "whoisPrivacy": "1", "autoRenew": 1, "notLocal": 0 } ] } ``` The dates describe the root domain. `/domain/getNs/advisor.example.com` replies `{"status": "SUCCESS", "ns": ["ns1.example.net", "ns2.example.net"]}`. ## Read records ```bash curl https://api.maildns.io/api/json/v3/dns/retrieve/advisor.example.com \ -H 'Content-Type: application/json' \ -d '{"apikey": "KEY", "secretapikey": "SECRET"}' ``` ```json { "status": "SUCCESS", "records": [ {"id": "106926652", "name": "advisor.example.com", "type": "MX", "content": "smtp.google.com", "ttl": "3600", "prio": "1", "notes": ""}, {"id": "106926653", "name": "advisor.example.com", "type": "TXT", "content": "v=spf1 include:_spf.google.com ~all", "ttl": "3600", "prio": "0", "notes": ""}, {"id": "106926654", "name": "_dmarc.advisor.example.com", "type": "TXT", "content": "v=DMARC1; p=quarantine", "ttl": "3600", "prio": "0", "notes": ""} ] } ``` One type at one name: `/dns/retrieveByNameType/advisor.example.com/TXT/_dmarc`. For `{domain}` itself, leave the subdomain off: `/dns/retrieveByNameType/advisor.example.com/TXT`. ## Create a record ```bash curl https://api.maildns.io/api/json/v3/dns/create/advisor.example.com \ -H 'Content-Type: application/json' \ -d '{"apikey": "KEY", "secretapikey": "SECRET", "name": "", "type": "TXT", "ttl": "3600", "content": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ"}' ``` ```json {"status": "SUCCESS", "id": "106926659"} ``` | Field | Value | |---|---| | `name` | The part in front of `{domain}` (`_dmarc`, `google._domainkey`), or empty for `{domain}` itself | | `type` | `A`, `AAAA`, `CNAME`, `MX` or `TXT` | | `content` | The value. TXT without surrounding quotes, in one piece. | | `ttl` | Optional; see [the TTL rule](https://maildns.io/docs/keys.md#record-rules) | | `prio` | MX priority, 0 to 65535 | If the same record (type, name, content) is already there, you get its `id` back instead of a second copy, so retrying a create is harmless. ## Change records By id, sending the whole record: ```bash curl https://api.maildns.io/api/json/v3/dns/edit/advisor.example.com/106926654 \ -H 'Content-Type: application/json' \ -d '{"apikey": "KEY", "secretapikey": "SECRET", "name": "_dmarc", "type": "TXT", "content": "v=DMARC1; p=reject"}' ``` Or every record of a type at a name, sending only the new values: ```bash curl https://api.maildns.io/api/json/v3/dns/editByNameType/advisor.example.com/TXT/_dmarc \ -H 'Content-Type: application/json' \ -d '{"apikey": "KEY", "secretapikey": "SECRET", "content": "v=DMARC1; p=reject"}' ``` Record ids come from the record itself, so an edited record gets a new id. Retrieve the records again before using an id after an edit. ## Delete records ```bash curl https://api.maildns.io/api/json/v3/dns/delete/advisor.example.com/106926654 \ -H 'Content-Type: application/json' \ -d '{"apikey": "KEY", "secretapikey": "SECRET"}' ``` `/dns/deleteByNameType/advisor.example.com/TXT/_dmarc` deletes every TXT record at `_dmarc.advisor.example.com`. ## Nameservers `/domain/updateNs/{domain}`, and creating an NS record, reply `{"status": "ERROR", "message": "This API key doesn't work for nameservers."}`. Reading them with `/domain/getNs/{domain}` is fine. ## Check that a record is live The live check is a [Dynadot-style call](https://maildns.io/docs/propagation.md), and it takes the same key: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY --data-urlencode command=dns_status \ --data-urlencode domain=advisor.example.com --data-urlencode subdomain=@ \ --data-urlencode record_type=txt \ --data-urlencode value=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` Wait for `"Live": "yes"` before asking Google or Microsoft to verify. --- # Namecheap-style API `https://api.maildns.io/xml.response` takes the same commands, parameters and XML replies as Namecheap's API for DNS records. Point a Namecheap client at that URL and give it your MailDNS key as the API key. ## Requests and replies `GET` with a query string, or `POST` form-encoded, with Namecheap's global parameters on every call: | Parameter | Value | |---|---| | `ApiKey` | Your MailDNS key | | `ApiUser`, `UserName` | Any value. Kept so existing clients work. | | `ClientIp` | Any value. Your key's own IP lock (if it has one) does this job. | | `Command` | The command, for example `namecheap.domains.dns.getHosts` | A domain is sent as `SLD` plus `TLD`, so a host works too: `advisor.example.com` is `SLD=advisor.example` and `TLD=com`; the root `example.com` is `SLD=example` and `TLD=com`. See [Hosts](https://maildns.io/docs/hosts.md). URL-encode every value. Every reply is HTTP 200 with Namecheap's envelope. Check `Status`: ```xml namecheap.domains.dns.sethosts MailDNS --0:00 0.412 ``` Errors have `Status="ERROR"` and one `Error` with a number and a text: ```xml This API key doesn't work for nameservers. namecheap.domains.dns.setcustom MailDNS --0:00 0.003 ``` | `Number` | Meaning | |---|---| | 1011102 | Wrong key, or a call from an IP address the key is not locked to | | 2019166 | A domain that is not yours | | 2016166 | A name outside your hosts | | 2011170 | An invalid record: type, address, TTL, CNAME clash, too many records. The text says which. | | 2011280 | Nameservers | | 500000 | Too many requests, or an earlier change to the same root domain is still running. Wait and retry. | | 5050900 | The change could not be made right now. Nothing was changed. | The texts are the ones listed in [Errors](https://maildns.io/docs/errors.md). ## Commands | Command | Parameters | Does | |---|---|---| | `namecheap.domains.getList` | | Your hosts | | `namecheap.domains.dns.getList` | `SLD`, `TLD` | The nameservers (read-only) | | `namecheap.domains.dns.getHosts` | `SLD`, `TLD` | The records | | `namecheap.domains.dns.setHosts` | `SLD`, `TLD`, `HostName{n}`, `RecordType{n}`, `Address{n}`, `MXPref{n}`, `TTL{n}`, `EmailType` | **Replace** the records of each host the call mentions | | `namecheap.domains.dns.setCustom`, `namecheap.domains.dns.setDefault` | | Refused: nameservers | ## List hosts ```bash curl 'https://api.maildns.io/xml.response?ApiUser=any&UserName=any&ApiKey=KEY&ClientIp=any&Command=namecheap.domains.getList' ``` ```xml namecheap.domains.getlist 2 1 20 MailDNS --0:00 0.051 ``` The dates describe the root domain. ## Read records ```bash curl 'https://api.maildns.io/xml.response?ApiUser=any&UserName=any&ApiKey=KEY&ClientIp=any&Command=namecheap.domains.dns.getHosts&SLD=advisor.example&TLD=com' ``` ```xml namecheap.domains.dns.gethosts MailDNS --0:00 0.233 ``` `Name` is relative to the domain you sent; `@` is the domain itself. With the root (`SLD=example`), you get only your hosts' records, with names like `advisor` and `_dmarc.advisor`. ## Write records `setHosts` **replaces**: every host the call mentions ends up with exactly the records in the call, including the records under it. On Namecheap itself it replaces the whole domain; MailDNS limits it to the hosts you name. To add a record, read the host with `getHosts` and send everything back, plus the new record. A complete Google Workspace setup for a new host, in one call: ```bash curl https://api.maildns.io/xml.response \ --data-urlencode ApiUser=any --data-urlencode UserName=any \ --data-urlencode ApiKey=KEY --data-urlencode ClientIp=any \ --data-urlencode Command=namecheap.domains.dns.setHosts \ --data-urlencode SLD=advisor.example --data-urlencode TLD=com \ --data-urlencode EmailType=MX \ --data-urlencode HostName1=@ --data-urlencode RecordType1=TXT \ --data-urlencode Address1=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ \ --data-urlencode HostName2=@ --data-urlencode RecordType2=MX \ --data-urlencode Address2=smtp.google.com --data-urlencode MXPref2=1 \ --data-urlencode HostName3=@ --data-urlencode RecordType3=TXT \ --data-urlencode 'Address3=v=spf1 include:_spf.google.com ~all' \ --data-urlencode HostName4=_dmarc --data-urlencode RecordType4=TXT \ --data-urlencode 'Address4=v=DMARC1; p=quarantine' ``` | Parameter | Value | |---|---| | `HostName{n}` | The name relative to the domain: `@`, `_dmarc`, `google._domainkey` | | `RecordType{n}` | `A`, `AAAA`, `CNAME`, `MX` or `TXT` | | `Address{n}` | The value. TXT without surrounding quotes, in one piece. | | `MXPref{n}` | MX priority, 0 to 65535 | | `TTL{n}` | Optional; see [the TTL rule](https://maildns.io/docs/keys.md#record-rules) | | `EmailType` | Namecheap needs `MX` when the call has MX records. MailDNS accepts it and doesn't need it. | `n` counts from 1. A call must carry at least one record. ### Add one record from Python ```python import os import xml.etree.ElementTree as ET import requests API = "https://api.maildns.io/xml.response" NS = {"nc": "http://api.namecheap.com/xml.response"} BASE = {"ApiUser": "any", "UserName": "any", "ClientIp": "any", "ApiKey": os.environ["MAILDNS_KEY"]} def call(command, **params): r = requests.post(API, data={**BASE, "Command": command, **params}, timeout=90) root = ET.fromstring(r.content) if root.get("Status") != "OK": raise RuntimeError(root.find("nc:Errors/nc:Error", NS).text) return root def add_record(sld, tld, name, rtype, address, mxpref=10, ttl=3600): """setHosts replaces, so send every record the host already has, plus the new one.""" hosts = call("namecheap.domains.dns.getHosts", SLD=sld, TLD=tld).findall(".//nc:host", NS) rows = [(h.get("Name"), h.get("Type"), h.get("Address"), h.get("MXPref"), h.get("TTL")) for h in hosts] if (name, rtype, address) not in [(n, t, a) for n, t, a, _, _ in rows]: rows.append((name, rtype, address, str(mxpref), str(ttl))) params = {"SLD": sld, "TLD": tld} if any(t == "MX" for _, t, _, _, _ in rows): params["EmailType"] = "MX" for i, (n, t, a, pref, row_ttl) in enumerate(rows, start=1): params.update({f"HostName{i}": n, f"RecordType{i}": t, f"Address{i}": a, f"MXPref{i}": pref, f"TTL{i}": row_ttl}) call("namecheap.domains.dns.setHosts", **params) add_record("advisor.example", "com", "google._domainkey", "TXT", "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB") ``` ## Nameservers `namecheap.domains.dns.setCustom`, `namecheap.domains.dns.setDefault`, the `namecheap.domains.ns.*` commands and NS records in `setHosts` are refused with `Error Number="2011280"`: `This API key doesn't work for nameservers.` Reading them with `namecheap.domains.dns.getList` is fine. Registration commands (create, renew, transfer, contacts) are not available. ## Check that a record is live The live check is a [Dynadot-style call](https://maildns.io/docs/propagation.md), and it takes the same key: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY --data-urlencode command=dns_status \ --data-urlencode domain=advisor.example.com --data-urlencode subdomain=@ \ --data-urlencode record_type=txt \ --data-urlencode value=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` Wait for `"Live": "yes"` before asking Google or Microsoft to verify. --- # Checking propagation When a change is live, how to know, and what to do before you press verify. ## What a successful write means A write (`set_dns2`, `PUT`, `DELETE`) returns only after the change is saved at the authoritative nameservers: the servers the rest of the internet asks about your host. - **Public resolvers** (the ones Google, Microsoft and everybody else use) usually see a new record within one to two minutes. - If you **replaced** a record, a resolver that cached the old one can keep serving it until that record's TTL runs out (usually 3600 seconds; `get_dns` shows it). - If a resolver asked for the name **before** the record existed, it may remember "no such record" for a while. That is why you don't verify until the record is live. ## Ask before you verify 1. Write the record. 2. Poll `dns_status` (Dynadot style) or `GET /api/v1/dns/status/{domain}` (Spaceship style) with the record's name, type and value, every 10 to 15 seconds. 3. When it says live, ask Google or Microsoft to verify. 4. If verification still fails, wait a few minutes and try again. Don't write the record again: it is already there. `dns_status` asks each authoritative nameserver directly, without any cache in between, and says `yes` only when **every one** of them answers with the record. It exists in two styles, Dynadot and Spaceship. Writing in Cloudflare, GoDaddy, Porkbun or Namecheap style? Call the Dynadot-style `dns_status` below with the same key: it only needs `key`. ## Dynadot style ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=dns_status \ --data-urlencode domain=advisor.example.com \ --data-urlencode subdomain=@ \ --data-urlencode record_type=txt \ --data-urlencode value=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` ```json { "DnsStatusResponse": { "ResponseCode": 0, "Status": "success", "Records": [ { "Name": "advisor.example.com", "RecordType": "TXT", "Value": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ", "Live": "yes", "Servers": {"ns1.example.net": "yes", "ns2.example.net": "yes"} } ] } } ``` `subdomain=@` is the host itself; for DKIM use `subdomain=google._domainkey`, for DMARC `subdomain=_dmarc`. Full parameters: [`dns_status`](https://maildns.io/docs/dynadot.md#dns_status). ## Spaceship style ```bash curl -G https://api.maildns.io/api/v1/dns/status/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' \ --data-urlencode name=_dmarc \ --data-urlencode type=TXT \ --data-urlencode 'value=v=DMARC1; p=quarantine' ``` ```json { "items": [ { "name": "_dmarc.advisor.example.com", "type": "TXT", "value": "v=DMARC1; p=quarantine", "live": false, "servers": {"ns1.example.net": true, "ns2.example.net": false} } ] } ``` Full parameters: [Check records](https://maildns.io/docs/spaceship.md#check-records). ## A polling loop ```python import os import time import requests API = "https://api.maildns.io/api3.json" KEY = os.environ["MAILDNS_KEY"] def is_live(host, name, rtype, value): """name is relative to host: "@" for the host itself, "_dmarc", "google._domainkey".""" reply = requests.post(API, timeout=90, data={ "key": KEY, "command": "dns_status", "domain": host, "subdomain": name, "record_type": rtype, "value": value, }).json() inner = next(iter(reply.values())) if str(inner.get("ResponseCode")) != "0": raise RuntimeError(inner.get("Error")) return bool(inner["Records"]) and all(r["Live"] == "yes" for r in inner["Records"]) def wait_live(host, name, rtype, value, timeout=900, every=15): deadline = time.time() + timeout while time.time() < deadline: if is_live(host, name, rtype, value): return True time.sleep(every) return False if wait_live("advisor.example.com", "@", "txt", "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ"): print("live: verify it now") else: print("not live after 15 minutes: check the record with get_dns") ``` If a record is not live after 15 minutes, something else is wrong: read the host back with `get_dns` and compare the value character by character (a `+` that arrived as a space is the usual cause). ## Check it yourself `get_ns` returns the authoritative nameservers. Ask one of them directly with `dig`: ```bash dig +short TXT advisor.example.com @ns1.example.net dig +short TXT _dmarc.advisor.example.com @ns1.example.net ``` Without the `@server` part, `dig` asks your own resolver, which shows what the public sees (and may still have an old answer cached). --- # Errors Every error says what went wrong in plain words. The texts are the same in all six styles; only the shape around them changes. This page shows each shape, lists the texts, and says what to do. ## How errors look in each style | Style | HTTP status | Where the text is | |---|---|---| | [Dynadot](https://maildns.io/docs/dynadot.md#replies) | Always 200 | `"ResponseCode": "-1"` and `Error` | | [Spaceship](https://maildns.io/docs/spaceship.md#requests-and-replies) | 4xx / 5xx | `{"detail": "...", "data": []}` | | [Cloudflare](https://maildns.io/docs/cloudflare.md#requests-and-replies) | 4xx / 5xx | `"success": false`, `errors[0].message` (and `errors[0].code`) | | [GoDaddy](https://maildns.io/docs/godaddy.md#requests-and-replies) | 4xx / 5xx | `{"code": "...", "message": "..."}` | | [Porkbun](https://maildns.io/docs/porkbun.md#requests-and-replies) | 4xx / 5xx | `{"status": "ERROR", "message": "..."}` | | [Namecheap](https://maildns.io/docs/namecheap.md#requests-and-replies) | Always 200 | `Status="ERROR"` and `` | The same refusal in each: ```json {"SetDnsResponse": {"ResponseCode": "-1", "Status": "error", "Error": "This API key doesn't work for nameservers."}} {"detail": "This API key doesn't work for nameservers.", "data": []} {"success": false, "errors": [{"code": 1004, "message": "This API key doesn't work for nameservers."}], "messages": [], "result": null} {"code": "ACCESS_DENIED", "message": "This API key doesn't work for nameservers."} {"status": "ERROR", "message": "This API key doesn't work for nameservers."} ``` ```xml This API key doesn't work for nameservers. ``` In Dynadot style, errors before a command runs (key, command name, rate limit) use a plain `Response` object: `{"Response": {"ResponseCode": "-1", "Error": "invalid key"}}`. Test `str(ResponseCode) == "0"` for success. When you match on texts in code, match on the start: the part after a semicolon or colon (a name, a number, a hint) varies. The **HTTP** column below is for the four styles that use status codes. Cloudflare's numeric codes and Namecheap's error numbers are on their pages. ## Key, command and limits | Error | HTTP | Cause and fix | |---|---|---| | `invalid key` | 401 (Cloudflare and Porkbun: 403) | Wrong key or secret, a revoked key, or a call from an IP address the key is not locked to. Check the key and where you call from. | | `incorrect command name` | 404 | Unknown command or path. Check the spelling against the style's reference. | | `command not available on this account` | 403 | A call outside DNS records (`register`, `renew`, contacts, transfers, ...). Not possible with MailDNS. | | `XML is not supported: call /api3.json` | | Dynadot style only: you called `/api3.xml`. | | `too many requests; retry in 0.4 seconds` | 429 | Over 10 requests per second. Wait `Retry-After` seconds and send the same call again. | | `another change to this domain is still running; retry in a few seconds` | 429 | An earlier change to the same root domain took more than 60 seconds. Nothing was changed. Retry. | ## Nameservers | Error | HTTP | Cause and fix | |---|---|---| | `This API key doesn't work for nameservers.` | 403 | A nameserver change or an NS record, in any style. No key can do this. Don't retry. | ## Domains and names | Error | HTTP | Cause and fix | |---|---|---| | `domain parameter not entered` | 422 | The call names no domain. | | `could not find domain in your account` | 404 | The domain is neither one of your key's hosts nor the root of one. List your hosts to check. | | `domain not found` | 404 | A host lookup (`/api/v1/domains/{domain}`, `/v1/domains/{domain}`) with a name that is not one of your hosts. | | `www.example.com is not one of your hosts` | 403 | A record name outside your hosts. Records go at or under a host. | | `main records of example.com are not on your account; ...` | 403 | A record at the root domain (a main record, or `@`) when your host is a name inside it. Address the host, or use a subhost. See [Hosts](https://maildns.io/docs/hosts.md). | | `subdomain0 is empty` | | Dynadot style: a `subdomain{n}` that is empty or `@`. For a record at the domain, use `main_record{n}`. | ## Records | Error | HTTP | Cause and fix | |---|---|---| | `no dns record entered` | 422 | A write with no records in it. | | `record type SRV is not allowed (allowed: A, AAAA, CNAME, MX, TXT)` | 422 | Only these five types. (NS gets the nameserver answer above.) | | `empty value for TXT record` | 422 | A record with no value. | | `TXT value is 2310 characters (max 2048)` | 422 | TXT values are limited to 2,048 characters. | | `TXT value must be printable ASCII` | 422 | No line breaks, tabs or non-ASCII characters in TXT values. | | `invalid MX priority: 'ten'`, `MX priority out of range: 70000` | 422 | MX priority is a whole number from 0 to 65535. | | `invalid ttl: 'one hour'` | 422 | TTL is a whole number of seconds. | | `invalid IPv4 address: ...`, `invalid IPv6 address: ...` | 422 | `A` takes an IPv4 address, `AAAA` an IPv6 address. | | `invalid name: ...` | 422 | A name with characters DNS does not allow. Letters, digits, `-` and `_`, separated by dots. | | `51 records under one host (max 50)` | 422 | A host holds at most 50 records, at and under it. | | `a CNAME cannot share a name with other records (mail)`, `only one CNAME per name` | 422 | Move the other records first. (Spaceship style: `"force": true` replaces them.) | Cloudflare style answers record errors with HTTP 400, code 1004, as Cloudflare does. ## When MailDNS can't make the change | Error | HTTP | Cause and fix | |---|---|---| | `example.com has expired; records can't be changed` | 503 | The root domain has expired. Its records can't be changed until it is renewed. Tell whoever issued your key. | | `example.com is not active at its registry (...); records can't be changed` | 503 | The root domain is on hold or otherwise not active. Tell whoever issued your key. | | `this domain is temporarily unavailable; ...` | 503 | The domain can't be changed right now. Retry in a few minutes. | | `changes to this domain are paused; ...` | 503 | Changes to this domain are on hold. Tell whoever issued your key. | | `the change could not be saved and nothing was changed; ...` | 503 | The change was rolled back as a whole. Retry later. | | `DNS for this domain is not served by MailDNS, so records can't be changed here` | 503 | This domain's DNS is hosted somewhere MailDNS doesn't write to. Tell whoever issued your key. | | `internal error` | 500 | Unexpected. Retry once; if it repeats, tell whoever issued your key. | The texts ending in `...` carry a short note after the semicolon. It never changes what you should do. ## Retrying safely Every write is safe to send twice: - **Adds** (Dynadot `set_dns2` with `add_dns_to_current_setting=1`, Spaceship `PUT`, Cloudflare `POST`, GoDaddy `PATCH`, Porkbun `/dns/create`) skip records that are already there. - **Replaces** (Dynadot `set_dns2` without the add flag, GoDaddy `PUT`, Namecheap `setHosts`) end in the same state however often you send them. - **Deletes**: Spaceship's `DELETE` ignores records that are already gone. Deletes by id or by name (Cloudflare, GoDaddy, Porkbun) answer "not found" for a record that is already gone: treat that as done. So after a timeout or a dropped connection, send the same call again. Retry automatically only the rate limit, the busy error and 503s, with a pause. Errors about the key, a name, a record or nameservers will fail the same way every time: fix the call instead. --- # Recipe: Google Workspace on a subdomain Set up Google Workspace mail on a host such as `advisor.example.com`: verification, MX, SPF, DKIM and DMARC. Everything goes on the host, in host form, so names are relative to it. > **Never put Workspace records on the root domain** (`example.com`). Its records belong to someone else, and MailDNS refuses them. ## The records | Step | Record (host form) | Then | |---|---|---| | 1. Verify the domain | `TXT @ google-site-verification=...` | Wait for `dns_status` = yes, then verify | | 2. Mail | `MX @ smtp.google.com`, priority 1 | | | 3. SPF | `TXT @ v=spf1 include:_spf.google.com ~all` | | | 4. DKIM (after step 1) | `TXT google._domainkey v=DKIM1; k=rsa; p=...` | Wait for yes, then "Start authentication" | | 5. DMARC | `TXT _dmarc v=DMARC1; p=quarantine` | | Steps 2, 3 and 5 can go in one call, before or after verification. Step 4 needs a DKIM key, and Google only generates one after the domain is verified. ## Step by step ### 1. Verify the domain Get the verification token for the host, either: - from the **Google Admin console**, when you add the domain and choose to verify with a TXT record, or - from the **Site Verification API**: `webResource.getToken` with `verificationMethod` `DNS_TXT` and `site` `{"type": "INET_DOMAIN", "identifier": "advisor.example.com"}`. The token it returns is the whole value, `google-site-verification=...`. Add it at the host: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=set_dns2 \ --data-urlencode domain=advisor.example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode main_record_type0=txt \ --data-urlencode main_record0=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` Wait until it is live on every nameserver: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=dns_status \ --data-urlencode domain=advisor.example.com \ --data-urlencode subdomain=@ \ --data-urlencode record_type=txt \ --data-urlencode value=google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` When `Live` is `yes`, press **Verify** in the Admin console, or call the Site Verification API's `webResource.insert` with `verificationMethod=DNS_TXT` and the same site. If Google says it can't find the record, wait a few minutes and try again: don't add it a second time. ### 2, 3 and 5. MX, SPF and DMARC ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=set_dns2 \ --data-urlencode domain=advisor.example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode main_record_type0=mx \ --data-urlencode main_record0=smtp.google.com \ --data-urlencode main_recordx0=1 \ --data-urlencode main_record_type1=txt \ --data-urlencode 'main_record1=v=spf1 include:_spf.google.com ~all' \ --data-urlencode subdomain0=_dmarc \ --data-urlencode sub_record_type0=txt \ --data-urlencode 'sub_record0=v=DMARC1; p=quarantine' ``` A name can have only **one SPF record** and **one DMARC record**. If the host already has a different one, don't add a second: read it with `get_dns`, merge the two into one value, and write it back (replace mode, with all the host's other records). ### 4. DKIM In the Admin console, go to **Apps > Google Workspace > Gmail > Authenticate email**, pick `advisor.example.com`, and generate a new record (2048-bit, selector `google`). Copy the TXT value Google shows and add it under `google._domainkey`: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=set_dns2 \ --data-urlencode domain=advisor.example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode subdomain0=google._domainkey \ --data-urlencode sub_record_type0=txt \ --data-urlencode 'sub_record0=v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB' ``` Send the value as Google shows it, in one piece, without quotes. When `dns_status` (with `subdomain=google._domainkey`) says `yes`, press **Start authentication**. ## Step 1 in each style The same verification record on `advisor.example.com`, in all six styles. Use whichever your code already speaks; the rest of the flow works the same way. | Style | Request | Record | |---|---|---| | [Dynadot](https://maildns.io/docs/dynadot.md#set_dns2) | `POST /api3.json` with `command=set_dns2`, `domain=advisor.example.com`, `add_dns_to_current_setting=1` | `main_record_type0=txt`, `main_record0=google-site-verification=...` | | [Spaceship](https://maildns.io/docs/spaceship.md#add-records) | `PUT /api/v1/dns/records/advisor.example.com` | `{"force": false, "items": [{"type": "TXT", "name": "@", "value": "google-site-verification=..."}]}` | | [Cloudflare](https://maildns.io/docs/cloudflare.md#create-a-record) | `POST /client/v4/zones/{zone_id}/dns_records` | `{"type": "TXT", "name": "@", "content": "google-site-verification=..."}` | | [GoDaddy](https://maildns.io/docs/godaddy.md#add-records) | `PATCH /v1/domains/advisor.example.com/records` | `[{"type": "TXT", "name": "@", "data": "google-site-verification=..."}]` | | [Porkbun](https://maildns.io/docs/porkbun.md#create-a-record) | `POST /api/json/v3/dns/create/advisor.example.com` | `{"apikey": "KEY", "secretapikey": "SECRET", "name": "", "type": "TXT", "content": "google-site-verification=..."}` | | [Namecheap](https://maildns.io/docs/namecheap.md#write-records) | `namecheap.domains.dns.setHosts` with `SLD=advisor.example`, `TLD=com` | `HostName1=@`, `RecordType1=TXT`, `Address1=google-site-verification=...`. Replaces: send the host's other records too. | Then wait for the Dynadot-style `dns_status` (same key) to say `yes` before you verify. ## Many hosts: a complete script `workspace_dns.py` runs the whole flow for a list of hosts with the Dynadot-style API. Each step only adds records, so running it again is harmless: records that are already there are skipped. Each step waits until every new record is live and then tells you what to press. ```bash pip install requests export MAILDNS_KEY='KEY' python3 workspace_dns.py verify tokens.csv # host,token -> TXT @ google-site-verification=... # ... press Verify for each host in Google ... python3 workspace_dns.py mail hosts.txt # one host per line -> MX, SPF, DMARC python3 workspace_dns.py dkim dkim.csv # host,dkim_value -> TXT google._domainkey # ... press Start authentication for each host ... ``` ```csv advisor.example.com,google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ billing.example.com,google-site-verification=Qm2pX7rT0vW4yA9cE1gH3jK5lN8oS6uB2dF4hJ6kL ``` ```python #!/usr/bin/env python3 """Google Workspace DNS for many hosts, through MailDNS (Dynadot-style API). export MAILDNS_KEY=KEY python3 workspace_dns.py verify tokens.csv # host,token -> TXT @ google-site-verification=... python3 workspace_dns.py mail hosts.txt # one host a line -> MX @, SPF @, DMARC _dmarc python3 workspace_dns.py dkim dkim.csv # host,dkim_value -> TXT google._domainkey Every step only adds records (records already there are skipped), so re-running is safe. Every step waits until each new record is live on every authoritative nameserver. """ import csv import os import sys import time import requests API = "https://api.maildns.io/api3.json" KEY = os.environ["MAILDNS_KEY"] MX = "smtp.google.com" SPF = "v=spf1 include:_spf.google.com ~all" DMARC = "v=DMARC1; p=quarantine" # add "; rua=mailto:..." if you collect reports POLL_EVERY, POLL_FOR = 15, 900 # seconds session = requests.Session() def call(command, **params): """One Dynadot-style call. Returns the inner reply, retries rate limits and busy roots, raises on errors.""" params.update(key=KEY, command=command) for attempt in range(8): r = session.post(API, data=params, timeout=90) r.raise_for_status() reply = next(iter(r.json().values())) if str(reply.get("ResponseCode")) == "0": return reply error = reply.get("Error", "unknown error") if error.startswith(("too many requests", "another change", "this domain is temporarily")): time.sleep(float(r.headers.get("Retry-After", 0)) + 2 * (attempt + 1)) continue raise RuntimeError(f"{command} {params.get('domain', '')}: {error}") raise RuntimeError(f"{command} {params.get('domain', '')}: still refused after 8 tries") def add(host, records): """Add records at or under one host. records: [(name, type, value, mx_priority)], name "@" = the host.""" params = {"domain": host, "add_dns_to_current_setting": "1"} m = s = 0 for name, rtype, value, prio in records: if name == "@": params[f"main_record_type{m}"], params[f"main_record{m}"] = rtype, value if prio is not None: params[f"main_recordx{m}"] = str(prio) m += 1 else: params[f"subdomain{s}"], params[f"sub_record_type{s}"], params[f"sub_record{s}"] = name, rtype, value if prio is not None: params[f"sub_recordx{s}"] = str(prio) s += 1 call("set_dns2", **params) def records_of(host): """[(name, TYPE, value)] for everything at ("@") and under the host.""" ns = call("get_dns", domain=host)["GetDns"]["NameServerSettings"] return ([("@", r["RecordType"].upper(), r["Value"]) for r in ns.get("MainDomains", [])] + [(r["Subhost"], r["RecordType"].upper(), r["Value"]) for r in ns.get("SubDomains", [])]) def is_live(host, name, rtype, value): reply = call("dns_status", domain=host, subdomain=name, record_type=rtype, value=value) return bool(reply["Records"]) and all(rec["Live"] == "yes" for rec in reply["Records"]) def wait_live(checks): """checks: [(host, name, type, value)]. Polls until all are live; returns the ones that are not.""" pending, deadline = list(checks), time.time() + POLL_FOR while pending: pending = [c for c in pending if not is_live(*c)] if not pending or time.time() > deadline: break print(f" {len(pending)} record(s) not live yet, checking again in {POLL_EVERY}s") time.sleep(POLL_EVERY) return pending def finish(pending, next_step): if pending: print("Not live yet (run the same command again later; nothing will be added twice):") for host, name, rtype, _ in pending: print(f" {host} {name} {rtype}") sys.exit(1) print(f"All live. {next_step}") def rows(path): """CSV rows (host, value), skipping blank lines and a header row.""" with open(path, newline="") as f: for row in csv.reader(f): if len(row) >= 2 and row[0].strip() and row[0].strip().lower() != "host": yield row[0].strip().lower().rstrip("."), row[1].strip() def verify(path): checks = [] for host, token in rows(path): value = token if token.startswith("google-site-verification=") else f"google-site-verification={token}" add(host, [("@", "txt", value, None)]) checks.append((host, "@", "txt", value)) print(f"added {host} TXT @ {value}") finish(wait_live(checks), "Press Verify for each host in Google, then run: mail") def mail(path): checks = [] with open(path) as f: hosts = [h.strip().lower().rstrip(".") for h in f if h.strip()] for host in hosts: have = records_of(host) spf = [v for n, t, v in have if n == "@" and t == "TXT" and v.startswith("v=spf1")] dmarc = [v for n, t, v in have if n == "_dmarc" and t == "TXT" and v.startswith("v=DMARC1")] wanted = [("@", "mx", MX, 1)] if spf and spf != [SPF]: print(f"skip SPF on {host}: it already has {spf[0]!r} (merge the two by hand)") else: wanted.append(("@", "txt", SPF, None)) if dmarc and dmarc != [DMARC]: print(f"skip DMARC on {host}: it already has {dmarc[0]!r}") else: wanted.append(("_dmarc", "txt", DMARC, None)) add(host, wanted) checks += [(host, name, rtype, value) for name, rtype, value, _ in wanted] print(f"added {host} " + ", ".join(f"{t.upper()} {n}" for n, t, _, _ in wanted)) finish(wait_live(checks), "Mail records are live. Generate DKIM keys in Google, then run: dkim") def dkim(path): checks = [] for host, value in rows(path): add(host, [("google._domainkey", "txt", value, None)]) checks.append((host, "google._domainkey", "txt", value)) print(f"added {host} TXT google._domainkey") finish(wait_live(checks), "Press Start authentication for each host in Google.") if __name__ == "__main__": steps = {"verify": verify, "mail": mail, "dkim": dkim} if len(sys.argv) != 3 or sys.argv[1] not in steps: sys.exit(__doc__) steps[sys.argv[1]](sys.argv[2]) ``` What it does on errors: rate limits and a busy root domain are retried with a pause; anything else (a host that is not yours, a bad value) stops the run with the error text, before any later host is touched. ## Spaceship style The same records with the Spaceship-style API, one host: ```bash # 1. Verification TXT curl -X PUT https://api.maildns.io/api/v1/dns/records/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' -H 'Content-Type: application/json' \ -d '{"force": false, "items": [{"type": "TXT", "name": "@", "ttl": 3600, "value": "google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ"}]}' # 2, 3, 5. MX, SPF, DMARC curl -X PUT https://api.maildns.io/api/v1/dns/records/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' -H 'Content-Type: application/json' \ -d '{"force": false, "items": [ {"type": "MX", "name": "@", "exchange": "smtp.google.com", "preference": 1}, {"type": "TXT", "name": "@", "value": "v=spf1 include:_spf.google.com ~all"}, {"type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=quarantine"}]}' # 4. DKIM curl -X PUT https://api.maildns.io/api/v1/dns/records/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' -H 'Content-Type: application/json' \ -d '{"force": false, "items": [{"type": "TXT", "name": "google._domainkey", "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB"}]}' # Is a record live? curl -G https://api.maildns.io/api/v1/dns/status/advisor.example.com \ -H 'X-API-Key: KEY' -H 'X-API-Secret: SECRET' \ --data-urlencode name=google._domainkey --data-urlencode type=TXT \ --data-urlencode 'value=v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB' ``` --- # Recipe: Microsoft 365 on a subdomain Set up Microsoft 365 mail on a host such as `advisor.example.com`. The flow is the same as [Google Workspace](https://maildns.io/docs/google-workspace.md): add the verification record, wait until it is live, verify, then add the mail records. Everything goes on the host, never on the root domain. The examples are Dynadot style; any of the six [API styles](https://maildns.io/docs.md#api-styles) works the same way. ## The records Microsoft shows the exact values for your tenant in the **Microsoft 365 admin center** under **Settings > Domains** (and DKIM in the Microsoft Defender portal, under email authentication settings). Copy them from there; the values below only show their shape. | Step | Record (host form) | Value | |---|---|---| | 1. Verify the domain | `TXT @` | `MS=ms12345678` | | 2. Mail | `MX @`, priority 0 | The value Microsoft shows, ending in `mail.protection.outlook.com` (for example `advisor-example-com.mail.protection.outlook.com`) | | 3. SPF | `TXT @` | `v=spf1 include:spf.protection.outlook.com -all` | | 4. Outlook setup | `CNAME autodiscover` | `autodiscover.outlook.com` | | 5. DKIM | `CNAME selector1._domainkey` and `CNAME selector2._domainkey` | The two targets Microsoft shows | | 6. DMARC | `TXT _dmarc` | `v=DMARC1; p=quarantine` | ## 1. Verify ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=set_dns2 \ --data-urlencode domain=advisor.example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode main_record_type0=txt \ --data-urlencode main_record0=MS=ms12345678 ``` Poll [`dns_status`](https://maildns.io/docs/propagation.md) with `subdomain=@`, `record_type=txt`, `value=MS=ms12345678` until `Live` is `yes`, then press **Verify** in the admin center. ## 2 to 4, and 6. Mail records ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=set_dns2 \ --data-urlencode domain=advisor.example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode main_record_type0=mx \ --data-urlencode main_record0=advisor-example-com.mail.protection.outlook.com \ --data-urlencode main_recordx0=0 \ --data-urlencode main_record_type1=txt \ --data-urlencode 'main_record1=v=spf1 include:spf.protection.outlook.com -all' \ --data-urlencode subdomain0=autodiscover \ --data-urlencode sub_record_type0=cname \ --data-urlencode sub_record0=autodiscover.outlook.com \ --data-urlencode subdomain1=_dmarc \ --data-urlencode sub_record_type1=txt \ --data-urlencode 'sub_record1=v=DMARC1; p=quarantine' ``` ## 5. DKIM Add the two CNAMEs with the targets Microsoft shows (`selector1-...` and `selector2-...`), wait until both are live, then turn DKIM signing on in the Defender portal: ```bash curl https://api.maildns.io/api3.json \ --data-urlencode key=KEY \ --data-urlencode command=set_dns2 \ --data-urlencode domain=advisor.example.com \ --data-urlencode add_dns_to_current_setting=1 \ --data-urlencode subdomain0=selector1._domainkey \ --data-urlencode sub_record_type0=cname \ --data-urlencode sub_record0=SELECTOR1_TARGET \ --data-urlencode subdomain1=selector2._domainkey \ --data-urlencode sub_record_type1=cname \ --data-urlencode sub_record1=SELECTOR2_TARGET ``` Replace `SELECTOR1_TARGET` and `SELECTOR2_TARGET` with the two values from the Defender portal. ## Things to watch - One SPF record and one DMARC record per name. If the host already has one, merge instead of adding a second. - A CNAME can't share its name with other records, so `autodiscover` and the two `_domainkey` names must have nothing else on them. - The [Python script](https://maildns.io/docs/google-workspace.md#many-hosts-a-complete-script) in the Google recipe adapts easily: change the values in `verify` and `mail`, and make `dkim` add the two CNAMEs. --- # For AI agents These docs are written so an agent can do the job from them alone. Give it the docs, the key, and a clear request. ## Pick a style If your agent (or the code it maintains) already knows one registrar's API, tell it to use that style: Dynadot, Spaceship, Cloudflare, GoDaddy, Porkbun or Namecheap. Otherwise use the Dynadot style, as the briefing below does: one URL, form parameters, and the live check built in. ## Give your agent the docs | URL | What it is | |---|---| | `https://maildns.io/llms.txt` | A short index in the [llms.txt](https://maildns.io/llms.txt) format: what MailDNS is, the rules that matter, and a link to every page | | `https://maildns.io/llms-full.txt` | Every docs page in one markdown file, in reading order. The best single thing to hand an agent. | | `https://maildns.io/docs/.md` | Any docs page as plain markdown: add `.md` to its address, for example `https://maildns.io/docs/dynadot.md` | Each docs page also links its markdown in the page header (``), and the markdown links point at other markdown pages, so an agent that follows links stays in clean text. ## Give it the key safely - Put the key in an environment variable the agent's tools can read, such as `MAILDNS_KEY` (and `MAILDNS_SECRET` for Spaceship style). - Don't paste the key into the conversation: chat logs and traces are kept in places keys should not be. - If the agent runs on a fixed machine, ask for the key to be locked to that machine's IP address. Then a leaked key is useless anywhere else. ## A briefing to paste Paste this into the agent's instructions (system prompt, project rules, or the top of the task). It holds the rules that keep an agent from doing damage. ```text You manage DNS records through MailDNS, a Dynadot-compatible DNS API. Docs: https://maildns.io/llms-full.txt (read it before your first call). 1. The key is in the MAILDNS_KEY environment variable. Never print it, log it or show it in a command you report back. 2. Call POST https://api.maildns.io/api3.json, form-encoded, with key and command. URL-encode every value (DKIM keys contain + / =). 3. Address each host as its own domain: domain=advisor.example.com. Records at the host are main records (main_record_type0, main_record0); records under it use subdomain0 / sub_record_type0 / sub_record0 (google._domainkey, _dmarc). Record types are lowercase: a, aaaa, cname, mx, txt. 4. Always send add_dns_to_current_setting=1. Without it, set_dns2 replaces every record on the host. Only replace when asked to change or delete a record, and only after reading the host with get_dns and re-sending every record that should stay. 5. A call succeeded when str(ResponseCode) == "0". Otherwise read Error. Retry only "too many requests" and "another change ... still running", after a pause. Any other error: stop and report it word for word. 6. Never write records for the root domain (example.com) or for any name that is not at or under a host that list_domain returns. Never try to change nameservers or add NS records. The answer is always "This API key doesn't work for nameservers." Report it; don't retry. 7. TXT values go in one piece, without quotes. One SPF record and one DMARC record per name: read get_dns first and merge rather than add a second. 8. After a write, call dns_status with domain, subdomain (@ for the host itself), record_type and value every 15 seconds until Live is "yes". Give up after 15 minutes and report. Only ask Google or Microsoft to verify once it is "yes". 9. When done, report every record you changed: host, name, type, value, and whether dns_status says it is live. ``` ## Ask it like this Concrete requests work best: the host, the record, and what "done" means. ```text Add the Google verification record below to advisor.example.com, wait until dns_status says it is live, then tell me so I can press Verify. google-site-verification=3J9tq0iV8pTt1yW5zKc2mE7aR4bN6hD0sF1gL8xQ ``` ```text For every host in hosts.txt, add the Google Workspace MX, SPF and DMARC records from https://maildns.io/docs/google-workspace.md. Skip any host that already has a different SPF or DMARC record and list those at the end. ``` ```text List my MailDNS hosts and, for each one, which of these are live: Google verification TXT, MX smtp.google.com, SPF, DKIM at google._domainkey, DMARC. Change nothing. ``` ## If the agent writes code Agents that write their own scripts get the right shape from the [Google Workspace recipe](https://maildns.io/docs/google-workspace.md#many-hosts-a-complete-script): one `call()` helper that checks `ResponseCode` and retries only the rate limit and busy errors, `add_dns_to_current_setting=1` on every write, and a `dns_status` loop before anything is verified. ## Agents that know another registrar An agent that already knows one of the other styles can use it unchanged; give it the style's page as well as the briefing. | Style | Base URL | Watch out for | |---|---|---| | [Spaceship](https://maildns.io/docs/spaceship.md) | `https://api.maildns.io/api/v1` | Nothing replaces: `PUT` adds, `DELETE` removes exactly what is sent. Has its own status check. | | [Cloudflare](https://maildns.io/docs/cloudflare.md) | `https://api.maildns.io/client/v4` | Look up the zone id first. A changed record gets a new id. | | [GoDaddy](https://maildns.io/docs/godaddy.md) | `https://api.maildns.io/v1` | `PATCH` adds; `PUT /records` and `PUT /records/{type}` replace. | | [Porkbun](https://maildns.io/docs/porkbun.md) | `https://api.maildns.io/api/json/v3` | Every call is a `POST` with the key in the body. | | [Namecheap](https://maildns.io/docs/namecheap.md) | `https://api.maildns.io/xml.response` | `setHosts` always replaces: read with `getHosts` first. | The live check is Dynadot style in every case: `dns_status` with the same key.