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.
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¶
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¶
| 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¶
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¶
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¶
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¶
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¶
{
"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 |