Custom domains

One CNAME per hostname, apex and www, how a hostname reaches its store, and how an agent creates the DNS records with the merchant's own credentials.

Written 2026-09-14. How your own hostname (shop.example.com, or the bare example.com plus www) reaches your store, how an agent can do the DNS work for you, and what every state means along the way.

Two domains

Written 2026-09-23. Formahand answers on two registrable domains, and which one a request arrives on decides everything about it.

DomainServes
Platform**formahand.com**the dashboard, the marketing site, /docs, /api, /mcp, /hooks, every link in an email
Storefronts**store-on-formahand.app**<slug>.store-on-formahand.app, <slug>-test.store-on-formahand.app for a sandbox
Yoursanything you attachunchanged; see the rest of this page

Free storefronts used to live at <slug>.formahand.com. They moved on 2026-09-23, for the same reason Shopify keeps shops on myshopify.com: you write code that runs on your storefront — custom HTML, CSS and embeds today, store functions later — and that code must not share an origin family with the dashboard. While a store was a subdomain of formahand.com it was same-site with the dashboard: a cookie scoped to .formahand.com, document.domain, a certificate on the parent name, and every browser and registrar heuristic that reads a registrable domain as one trust boundary. A separate registrable domain removes all of that in one move, and it cannot be undone by a mistake in one page's escaping.

store-on-formahand.app is also the shared fallback sender address for store email (email marketing), which is why mail, _dmarc, dkim*, bounce and formahand-mail are reserved slugs: no store may claim a name that Formahand's own mail depends on. Those names carry their own DNS records, so the wildcard never sees them.

The .app TLD is on the HSTS preload list. Every address Formahand composes is https://, always; there is no plaintext fallback to get wrong.

Which hostname gets what

The hostname decides the answer, before anything else looks at the request:

HostnameAnswer
formahand.com, www, and the reserved names (app, api, admin, dashboard, mail, mailservice, customers, mcp, status, spf, …)Formahand itself
anything deeper under formahand.com (<handle>.dkim.formahand.com, <handle>.bounce.formahand.com)Formahand itself; never a store slug
<slug>.store-on-formahand.app, <slug>-test.store-on-formahand.appthat store
your own domainyour store
store-on-formahand.app, www.…, and reserved names under it301 to https://formahand.com/ — the apex renders neither a store nor the platform
<slug>.formahand.com (a pre-move store address)301 to https://<slug>.store-on-formahand.app + the same path and query
localhost, 127.0.0.1Formahand itself (local dev)

The last rule is permanent, not a migration window. Sitemaps, Google's index, your own links and years of order emails point at <slug>.formahand.com; the redirect stays. It is decided from the shape of the hostname rather than a lookup — a label under formahand.com that is not reserved is a store slug or nothing at all, and the new address answers 404 for nothing at all exactly as the old one did. A GET or HEAD gets 301 (what a browser remembers and what updates an index); anything with a body gets 308, so an old page's cart form keeps its method.

Custom domains were untouched by the move: your own hostname never changed, and nothing you had already set up had to be redone.

The CNAME target did not change

Your own hostnames still point at **customers.formahand.com**, exactly as before the move, and they are not going to move:

  • The target is customers.formahand.com. Changing it would mean asking every merchant to edit DNS they have already got right, and re-issuing every certificate.
  • The origin-isolation argument does not apply to them. A storefront on shop.example.com is already a different registrable domain from formahand.com; the thing that needed separating was the free shared address, which used to be a subdomain of the dashboard.

Cookies and the origin boundary

Storefront cookies are set on the store's own host and carry **no Domain= attribute**: the cart, the discount code, the shipping destination, the shopper session and the launch-mode preview cookie are all host-only, and each one is named with a prefix a browser refuses to accept with a Domain= or a Path other than / — so the guarantee is the browser's, not ours. Dashboard cookies are set on formahand.com and were never sent to a storefront.

Before the move that was a property of how the cookies were written. It is now a property of the domains: a cookie set on <slug>.store-on-formahand.app cannot be widened to reach formahand.com even by writing Domain= wrongly, because the two are different registrable domains. That is the boundary your own storefront code runs inside, and it is why store functions can be considered at all.

The one record

Every hostname you attach is a single CNAME to **customers.formahand.com** (DNS only — at a DNS host that can proxy records the record must stay unproxied). Ownership is proven by an HTTP challenge Formahand answers for you, and the certificate is issued by HTTP validation, so there are no TXT records to add. A domain goes pending → active a few minutes after the record resolves; failed means the certificate authority reported a terminal state (the name is blocked, has moved, or timed out) and the domain should be removed and added again.

1 custom domain is included on Free (10 on Growth, 20 on Pro); beyond the allowance each domain is $2 a month on the statement and needs a card on file (plans and walls). A bare domain and its www count as one.

Apex domains and www

example.com (the zone apex) is allowed. It works directly wherever the DNS provider flattens a CNAME at the root — Route 53 (alias), DNSimple, Vercel, Netlify, Porkbun, Gandi, Hover, Squarespace.

Where the provider refuses a CNAME on the bare name (GoDaddy, Namecheap, IONOS, OVH, Google Cloud DNS, Hetzner, DigitalOcean, Name.com, Azure DNS, and most registrars), attach **www.example.com**, make it primary, and use the registrar's domain forwarding to send example.com to https://www.example.com. The Domains tab and describe_domain_setup say which of the two cases applies, based on the domain's nameservers.

When you add an apex, Formahand adds www.<apex> alongside it (a checkbox in the tab; www: false in the API) and removes it with the apex. Once both are active the non-primary one answers 308 to the primary, so www.example.com → example.com or the other way round, whichever you chose.

Internationalised names must be entered in punycode (xn--…), which is what DNS uses.

What each state serves

  1. active, and the store is ready — the hostname serves your storefront. A hostname that is not the primary one answers 308 to the primary.
  2. pending — a small branded holding page (200, Cache-Control: no-store, noindex, refreshing every 30 s): Connecting shop.example.com to <store>… certificate pending. Nothing is cached, so the real storefront appears as soon as the certificate is issued.
  3. failed, or a hostname Formahand does not know — 404 No store at this address.

How a change reaches your storefront

Every change to your domains — adding one, a check that flips it to active or failed, make-primary, remove — is pushed to your store immediately, to the live store and to its sandbox. GET /api/domains and list_domains push the current list too. If a push does not land, it is retried within ten minutes, so a store always catches up on its own; you never have to repeat the change.

Every domain response carries sync, which tells you what happened:

syncMeans
syncedthe new list reached your store
unchangedyour store already had this list
no-runtimethe store is still being set up; it will pick the list up when it is ready
failedthe push did not land — it is retried automatically within ten minutes

Routes and scopes

/api/domains accepts browser sessions and API tokens with the domains:read / domains:write scopes (the token's store only). The Domain Connect return (/api/domains/return) is a public redirect and takes no token.

CallScopeDoes
GET /api/domainsdomains:read{ domains, configured, cnameTarget, maxCustomDomains }; pending domains are re-checked at most every 30 s; your store is updated when something changed
GET /api/domains?describe=<hostname>[&www=0]domains:readthe setup description below, for a hostname that may or may not be on the store yet
GET /api/domains?connect=<id>domains:readthe Domain Connect offer for a pending domain plus its setup
POST /api/domains { hostname, www? }domains:writeregisters the hostname (and www.<apex> unless www: false) → 201 { domain, www, wwwError?, setup, cnameTarget, sync }
POST /api/domains { action: "check", id }domains:writere-checks one domain now → { domain, domains, sync }
POST /api/domains { action: "make-primary", id }domains:writemakes an active domain canonical → { domains, sync }
DELETE /api/domains?id=domains:writeremoves the domain (an apex takes its www along; a removed primary hands primary back to the free address) → { ok, sync }

Agents: let Claude Code or Codex set up the DNS

Your own agent can do the whole DNS setup for you. The MCP tools (scopes domains:read / domains:write) mirror the route:

ToolScopeDoes
list_domainsdomains:readthe store's domains with status, cnameTarget, maxCustomDomains, configured
describe_domain_setup { hostname, www? }domains:read{ hostname, zone, apex, records, provider, commands, verification, notes } (below)
add_domain { hostname, www? }domains:writeregisters the hostname (+ www for an apex) → { domain, www, wwwError?, setup, cnameTarget, sync }
check_domain { id }domains:writeforces a re-check now → { domain, sync }; poll every 30–60 s until status: "active"
make_primary_domain { id }domains:writecanonical address → { domains, sync }
remove_domain { id }domains:writeremoves it from the store and releases its certificate → { ok, sync }

describe_domain_setup returns, for example.com at a DNS host that flattens apex CNAMEs:

{
  "hostname": "example.com", "zone": "example.com", "apex": true,
  "records": [
    { "type": "CNAME", "name": "example.com", "label": "@", "target": "customers.formahand.com", "proxied": false, "ttl": 300 },
    { "type": "CNAME", "name": "www.example.com", "label": "www", "target": "customers.formahand.com", "proxied": false, "ttl": 300 }
  ],
  "provider": { "id": "cloudflare", "name": "Your DNS host", "detectedBy": "nameservers", "supportsApexCname": true,
                "consoleUrl": "https://dash.cloudflare.com/", "nameservers": ["ada.ns.cloudflare.com", "bob.ns.cloudflare.com"] },
  "commands": { "cloudflare_api": "curl … $DNS_ZONE_ID … Bearer $DNS_API_TOKEN …",
                "route53": "aws route53 change-resource-record-sets --hosted-zone-id $HOSTED_ZONE_ID …",
                "google_cloud_dns": "gcloud dns record-sets create … --zone=$GCLOUD_DNS_ZONE …",
                "namecheap": "# Namecheap has no official CLI: … Advanced DNS → Add New Record …",
                "generic": "Add these records in your DNS panel for example.com: …" },
  "verification": "Add the domain with add_domain …, then call check_domain { id } every 30-60 seconds until status is active …",
  "notes": ["Every hostname is one CNAME to customers.formahand.com …", "Keep the record DNS only …", "…"]
}

Your credentials never go through Formahand. The commands carry placeholders ($HOSTED_ZONE_ID, an authenticated gcloud, your own DNS API token); the agent runs them on your machine with your own login, or hands you the generic record for the DNS panel. The tool descriptions, the notes and the dashboard prompt all say so. provider.domainConnectUrl is present when your DNS provider carries Formahand's Domain Connect template and the domain is already pending on the store: opening it in your browser adds the record after you approve it there.

Provider detection. Your zone's NS records are resolved over DNS-over-HTTPS (with a 10 s timeout) and matched against nameserver suffixes — ns.cloudflare.com, ns-*.awsdns-* (Route 53), googledomains.com/googlehosted.com (Google Cloud DNS), domaincontrol.com (GoDaddy), registrar-servers.com (Namecheap), ui-dns.* (IONOS), ovh.net/anycast.me (OVH), hetzner.com/.de (Hetzner), digitalocean.com, vercel-dns.com, nsone.net (Netlify), hover.com, name.com, porkbun.com, gandi.net, squarespacedns.com, he.net (Hurricane Electric), hostinger.com/dns-parking.com, wixdns.net, worldnic.com (Network Solutions), dnsimple.com, azure-dns.*, wordpress.com — each with supportsApexCname. Unknown nameservers fall back to the Domain Connect _domainconnect TXT discovery for a name (detectedBy: "domain-connect"), else unknown with supportsApexCname: false — an apex CNAME is never promised blind.

The walkthrough

  1. Mint a token with domains:read + domains:write in Settings → Agents & API and connect the MCP server (claude mcp add formahand --transport http https://formahand.com/mcp --header "Authorization: Bearer <token>").
  2. describe_domain_setup { hostname: "shop.example.com" } — read provider, records, commands, notes.
  3. add_domain { hostname: "shop.example.com" } — note domain.id; the response already carries setup.
  4. Create the records with the provider's command (your credentials, your machine) or paste the generic record into the DNS panel; for a bare domain on a provider without apex CNAME, create www and set up forwarding.
  5. check_domain { id } every 30–60 s until domain.status === "active" (domain.errors quotes the certificate authority meanwhile, e.g. "custom hostname does not CNAME to this zone").
  6. make_primary_domain { id }: a connected domain is the store's main address (the agent prompts say so and do it once the domain is active; the free store-on-formahand.app address then 308s to it); list_domains to confirm.

The Domains tab offers the same flow after Add domain: the records card (provider-aware wording), the Domain Connect button when available, and Let Claude Code or Codex add the record — Open in Claude Code / Open in Codex / Copy prompt — with a ready-made prompt: Connect the domain shop.example.com to my Formahand store "…". Use the Formahand MCP tool describe_domain_setup … create those records at my DNS provider using my own credentials … then call check_domain until it is active ….