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