Skip to content

Custom Domains

Endpoints for attaching customer-owned domains to an app's services, and for the optional NS-hosting tier where AppMecca runs the domain's DNS.

All routes are group-scoped: the caller must have access to the app, and a domain may be claimed by exactly one app platform-wide. Domain rows record their owning group, and every lookup on this surface is scoped to the caller's group — a row belonging to another group is a plain 404, even when two groups' apps share one canonical name. Platform operators (no ?group=) resolve any app's domains.

Environment

There is no environment query parameter on any domain route — the API process is bound to a single environment and resolves it server-side. The optional branch parameter selects the app row.


The two-record procedure

Every custom domain needs two DNS records: one that carries traffic and one that proves control of the domain so certificates can be issued and renewed without any further customer action.

Hostname shape Traffic record Validation record
Subdomain (www.example.com) CNAME → platform hostname _acme-challenge.www.example.com CNAME → …acme-delegated.{env}.appmecca.net
Apex, ALIAS-capable DNS ALIAS/ANAME → platform hostname same shape
Apex, any DNS provider A → the env's static-ingress IPs same shape

The validation record is identical in shape everywhere; only the traffic record forks at the apex, because DNS forbids a CNAME at a zone apex.

The API returns this guidance as a records block on add, list, verify and apex-policy changes, so clients never construct record names themselves.

The records block

{
  "records": {
    "traffic": {
      "name": "www.example.com",
      "type": "CNAME",
      "target": "gallery-web-acme.dev.appmecca.net",
      "resolves": true
    },
    "acme_challenge": {
      "name": "_acme-challenge.www.example.com",
      "type": "CNAME",
      "target": "_acme-challenge.www.example.com.acme-delegated.dev.appmecca.net",
      "resolves": false,
      "reason": "missing_record"
    }
  },
  "is_apex": false,
  "redirect_to": null
}

traffic takes one of four mutually exclusive shapes:

Condition Shape
Not an apex type: "CNAME", target
Apex, served from the edge cache type: "ALIAS", alias_only: true, hint
Apex, static ingress enabled type: "A", values: [ip, …], target: null
Apex, no static ingress type: "ALIAS", alias_only: true, hint

resolves is true/false when a live DNS probe ran, and null when it did not. Only POST /verify probes DNS — list and add return null so that polling clients (the dashboard) never trigger resolver traffic.

reason appears only when a lookup failed:

reason Meaning
missing_record The record is genuinely absent.
negative_cache The authoritative nameservers do have the record, but a resolver is still caching an older NXDOMAIN. resolves is reported as true — the configuration is correct and issuance proceeds.
misnamed_record A record with the right value exists under a nearly-identical, mispasted name (a leading/trailing space, an invisible character, or the zone appended twice). The record additionally carries near_miss (the escaped found name, e.g. \032_acme-challenge.example.com.), near_miss_kind (leading_whitespace | trailing_whitespace | invisible_character | zone_appended) and a hint sentence with the fix: delete that record and recreate it, copying the name exactly. Probed only after both the recursive and authoritative lookups missed, over a fixed candidate list, value-matched — a wrong-valued mispaste still reports missing_record.

Apex rows additionally carry an apex object (omitted entirely for subdomains):

{
  "apex": {
    "is_apex": true,
    "mode": "redirect",
    "redirect_to": "www.example.com",
    "static_ingress_enabled": true,
    "a_record": {"name": "example.com", "type": "A", "values": ["1.2.3.4"]},
    "alias_hint": "…"
  }
}

Endpoints

Method Path Scope Description
GET /api/domains/apex-check?domain= read Whether a name is a zone apex
GET /api/domains/claim?domain= read Whether a domain is already claimed
GET /api/domains/{app_name}?branch= read List an app's domains (with records)
POST /api/domains/{app_name}?branch= deploy Add a domain → 201
DELETE /api/domains/{app_name}?domain=&branch= deploy Remove a domain → 204
GET /api/domains/{app_name}/{domain} read Single row (no records block)
PATCH /api/domains/{app_name}/{domain}/apex?branch= deploy Change apex policy
PATCH /api/domains/{app_name}/status deploy Update validation status
POST /api/domains/{app_name}/verify?domain= read Live-resolve both records

Claim check

Is a domain already taken anywhere on the platform? Domains are globally unique, so this answers "can I add this one?" before you try.

GET /api/domains/claim?domain=www.example.com
{ "claimed": true, "domain": "www.example.com" }

The domain is normalised (trimmed, lower-cased, trailing dot removed) before lookup, so Example.COM. and example.com give the same answer.

The owner is not disclosed

A tenant caller is told only whether a domain is claimed — never by whom. Naming the owning app would reveal another tenant's group and app name, which is exactly what the add-domain path withholds when it refuses with "already claimed by another app on this platform". Platform operators additionally receive app_name, service_name and status.

To see your own domains with full detail, use GET /api/domains/{app_name}, which is group-scoped.

Apex check

GET /api/domains/apex-check?domain=example.co.za
{
  "domain": "example.co.za",
  "is_apex": true,
  "suggested_redirect_target": "www.example.co.za"
}

Apex detection is server-side and multi-part-TLD aware (co.uk, co.za, com.au, …), so example.co.za is correctly treated as an apex.

Add a domain

POST /api/domains/{app_name}?branch=main
{
  "domain": "www.example.com",
  "service_name": "web",
  "apex_mode": null,
  "redirect_target": null
}
Field Required Notes
domain yes Lower-cased and trailing-dot-stripped server-side
service_name yes Which service of the app serves this domain
apex_mode apex only "redirect" (default) or "serve"
redirect_target optional Only with apex_mode: "redirect"; must be inside the apex's own zone

Responds 201 with the inserted row plus the records block, is_apex, redirect_to, an apex object for apex rows, and — when an apex sibling was auto-created — a sibling object. New rows are created status: "pending_dns" with challenge_type: "dns-01".

acme_challenge_cname

The response also carries a legacy top-level acme_challenge_cname ({name, target}) for older CLI builds. New clients should read records.acme_challenge instead.

Errors:

Status Cause
409 The app is private, the domain is already claimed by another app, or the branch is not the production branch
422 apex_mode/redirect_target on a subdomain, redirect_target with apex_mode: "serve", or a redirect target outside the apex's zone

Remove a domain

DELETE /api/domains/{app_name}?domain=www.example.com

domain may be supplied in the query or in the body ({"domain": "…"}); supplying both with different values is a 422.

Responds 204 with an empty body. A platform-managed name (see Platform domains) is refused with 409: only DELETE /api/admin/domains/grants/{domain} removes one. The same 409 comes from PATCH /api/domains/{app_name}/status, from PATCH /api/apps/{app} when a visibility flip would tear one down, whatever force_remove_domains says, and from DELETE /api/apps/{app} when the delete would remove the app's last branch. A row that still holds live platform state (certificate_arn, certificate_arn_global, distribution_id or grant_zone_id) is soft-deleted — its status flips to removing and the agent detaches the certificate, the router rule and the edge-cache alias within about a minute. A row holding no such state is deleted immediately.

Verify

POST /api/domains/{app_name}/verify?domain=www.example.com

The only route that performs live DNS resolution (up to a few seconds). Returns domain, status, certificate_arn, validation_cname, validation_target, challenge_type, certificate_target, plus the records block with resolves/reason populated for both records.

Apex policy

PATCH /api/domains/{app_name}/{domain}/apex
{"apex_mode": "serve"}

Returns changed (false when the policy already matched), the new and previous apex_mode, a warning describing the cutover, the records block, and a sibling object when a www. sibling was created. 422 when the domain is not an apex.


NS hosting

The optional tier where the customer delegates their domain's nameservers to AppMecca. Before any nameserver change is requested, the platform scans the domain's current DNS and imports every record it finds into a new platform-managed zone — so MX, SPF and DKIM survive the migration. Once the delegation is live, certificate validation records are written internally and the customer never needs to touch DNS again.

Method Path Scope Description
POST /api/domains/ns/{domain}/setup deploy Scan + import + create the zone, or release it
GET /api/domains/ns/{domain} read Current NS-hosting state

Setup / release

POST /api/domains/ns/{domain}/setup
{"op": "setup"}

op is "setup" (default) or "release" — there is no separate release endpoint.

Setup responds with the assigned nameservers and what was imported:

{
  "domain": "example.com",
  "name_servers": ["ns-1.awsdns-00.org", "…"],
  "imported": ["MX example.com", "TXT example.com", "…"],
  "skipped": [],
  "instructions": "Set these nameservers at your registrar…"
}

Release responds {"domain": "…", "released": true, …}.

The request is executed by the agent — the API holds no DNS credentials of its own — and the API blocks on the result, with a 90 second timeout.

Status Cause
409 The domain is already NS-managed by another group. A transient variant of this ("being released…", "setup in progress or recently failed…") clears itself within a tick — retry shortly.
409 group_busy — another change to your group (a rename, a delete, an app registration or a rehome) holds its lock. Retry in a few seconds.
404 Your group was deleted while the zone was being created. No zone row is written.
422 The domain you supplied was refused when creating the zone (malformed or reserved)
502 The agent reported a failure
504 The agent did not answer within the timeout

Status

GET /api/domains/ns/{domain}
{
  "domain": "example.com",
  "status": "pending_ns",
  "name_servers": ["ns-1.awsdns-00.org", "…"],
  "hosted_zone_id": "Z123…",
  "record_count": 14,
  "activated_at": null
}
status Meaning
scanning The agent is reading the domain's current DNS
pending_ns Zone created and populated; waiting for the customer to change nameservers
active Delegation detected — the platform now serves the domain's DNS
releasing Being handed back

A domain owned by another group returns 404, not 403, so the endpoint never confirms the existence of another tenant's domain.


Platform domains

A platform grant binds one label under appmecca.net (for example meerkat.appmecca.net) to a system-group app. These routes are for platform administrators only: an account on the platform-admin list, or platform-root. A legacy admin API key gets 403 — a key carries no account. Responses are never edge-cached.

Method Path Description
POST /api/admin/domains/grants Bind a name → 201 (200 when the same grant already exists)
GET /api/admin/domains/grants?app_name=&domain= List grants with their state
DELETE /api/admin/domains/grants/{domain} Start removing a grant → 202

Bind

POST /api/admin/domains/grants
{
  "app_name": "appmecca-meerkat",
  "domain": "meerkat.appmecca.net",
  "service_name": null,
  "no_parent_write": false
}

There is no source field; a body carrying one is a 422. The grant's evidence (granted_by_user_id, granted_by_email) is taken from the caller.

Status When
422 The name is not exactly one label under appmecca.net, or the label is reserved; or the app is not in the system group
409 The app is gated; or another app holds the name; or this app already has a non-platform row for it; or this app already holds the grant with a different service_name or no_parent_write (bind never edits a grant — unbind first)
503 The reserved-name list could not be read

no_parent_write: true means the platform never writes the parent NS record in appmecca.net; the operator does.

Grant rows

Each row carries domain, app_name, service_name, status, certificate_target, grant_zone_id, grant_name_servers, grant_parent_ns_by_operator, error_message, granted_by_email, granted_at, and a derived grant_state:

grant_state Meaning
zone_pending The zone has not been created yet
parent_ns_pending The parent NS record in appmecca.net is owed
validating Issuing the certificate
active Serving
refused Refused or failed validation; see error_message
removing Unbind in progress
parent_ns_cleanup_pending The operator owes a DELETE of the parent NS record
removal_failed Removal gave up

In the tenant routes, a grant row's records block is empty and the row carries platform_managed: true, its grant_state and a one-line hint.

Unbind

DELETE /api/admin/domains/grants/{domain} finds the row by grant evidence only, so a tenant row of the same name is a 404. The agent then removes the parent NS record, the zone (after the referral has lapsed) and the row, in that order. The response is {"domain", "app_name", "status"} where status is removing, or deleted when nothing had been created yet.

Row fields

Domain rows carry these columns in addition to the guidance block:

Field Notes
status pending_dns → dns_verified → challenge_pending → active; also renewing, removing, and the *_failed states
certificate_target alb (the HTTPS router) or cloudfront (the edge cache) — decides where the certificate is attached
challenge_type dns-01 for all new domains; http-01 on legacy rows, which keep renewing that way
is_apex / redirect_to Apex detection and the canonical host for redirect rows
certificate_arn / certificate_arn_global Identifiers for the router-attached and edge-attached certificates
distribution_id Stamped when an edge-cache alias is attached
listener_attached_at / alias_attached_at When the certificate/alias was actually attached. An active row with the relevant timestamp still NULL is drift — the domain will not serve; appmecca health reports it
last_attempt_at / attempt_count Issuance backoff, which protects against the certificate authority's rate limits