Docs pages

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:

Shell
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:

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

Shell
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:

Shell
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 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 PUT /api/v1/dns/records/advisor.example.com {"force": false, "items": [{"type": "TXT", "name": "@", "value": "google-site-verification=..."}]}
Cloudflare POST /client/v4/zones/{zone_id}/dns_records {"type": "TXT", "name": "@", "content": "google-site-verification=..."}
GoDaddy PATCH /v1/domains/advisor.example.com/records [{"type": "TXT", "name": "@", "data": "google-site-verification=..."}]
Porkbun POST /api/json/v3/dns/create/advisor.example.com {"apikey": "KEY", "secretapikey": "SECRET", "name": "", "type": "TXT", "content": "google-site-verification=..."}
Namecheap 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.

Shell
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:

Shell
# 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'