# 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.
