Point app.acme.com at one of your tunnels. Four
steps — add it, publish one TXT record, click verify, bind it.
Real DNS ownership proof, not a string field. Works on Pro and
above.
Ownership is proven with a DNS TXT challenge — the same mechanism Let's Encrypt and Google Search Console use. You never hand us credentials to your DNS; you publish one record we can read, and we check it's there.
POST /domains you tell us the domain
│ → we mint a TXT challenge token
▼
add a TXT record _21tunnel-challenge.app.acme.com = <token>
│ (at your DNS provider)
▼
POST /domains/:id/verify we resolve the TXT live via 1.1.1.1
│ → match flips status: pending → active
▼
PUT /domains/:id/bind tie the verified domain to a tunnel
│ → edge starts routing Host: app.acme.com
▼
live
Everything below uses two shell vars to stay copy-pasteable:
$API = https://login.21tunnel.com/api,
$JWT = a dashboard access token (from
POST /auth/login or the dashboard). The dashboard UI
at login.21tunnel.com
drives the exact same API with buttons.
Register the apex or subdomain you want to use. We respond with the exact TXT record to publish — copy it verbatim.
curl -X POST "$API/domains" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{ "domain": "app.acme.com" }' {
"id": "7c3e1a90-...",
"domain": "app.acme.com",
"status": "pending",
"verificationStatus": "pending",
"verifiedAt": null,
"tunnelId": null,
"createdAt": "2026-06-18T10:04:11Z",
"dnsRecords": [
{
"type": "TXT",
"name": "_21tunnel-challenge.app.acme.com",
"value": "x7Qk9...Zp1a",
"ttl": 300
}
]
}
The dnsRecords array is what you publish in the
next step. The value is a fresh 32-byte URL-safe
token — DNS-friendly, no quoting needed. Re-reading the domain
later (GET /domains) returns the same record until
it verifies.
domain must be a valid DNS name: lowercase a–z, 0–9, hyphens, dots; 1–253 chars; at least one dot. Invalid → 400 invalid_domain.409 domain_taken.402 upgrade_required. Custom domains need Pro (a trial counts).402 domain_quota_exceeded. Free = 0, Pro = 1, Team = unlimited. During a trial you get 1.
Add the record from dnsRecords at your DNS provider.
Three fields matter — type, name (host), value. The
per-provider quirks are in the
DNS providers section below; here's the
shape:
| Field | Value |
|---|---|
| Type | TXT |
| Name / Host | _21tunnel-challenge.app.acme.com — or just _21tunnel-challenge.app on providers that auto-append the zone (see below). |
| Value / Content | The value from the API response, exactly. |
| TTL | 300 (5 min) keeps verification fast. Any TTL works. |
The single most common mistake is the host
field. Many providers append the zone for you, so typing the
full _21tunnel-challenge.app.acme.com produces
_21tunnel-challenge.app.acme.com.acme.com. See the
provider table for which ones do this.
Before clicking verify, confirm the record is live and public:
dig +short TXT _21tunnel-challenge.app.acme.com @1.1.1.1 "x7Qk9...Zp1a"
We resolve against Cloudflare's 1.1.1.1
explicitly, so querying the same resolver is the truest
preview. No output = not propagated yet; wait a couple of
minutes (low-TTL records are usually live within seconds to a
minute or two).
We do a live TXT lookup against
_21tunnel-challenge.<domain> via
1.1.1.1. If the value matches, the domain flips to
active.
curl -X POST "$API/domains/7c3e1a90-.../verify" \
-H "Authorization: Bearer $JWT" {
"id": "7c3e1a90-...",
"domain": "app.acme.com",
"status": "active",
"verifiedAt": "2026-06-18T10:09:55Z",
"tunnelId": null,
...
} 412 txt_mismatch — the record resolves but the value is wrong (or stale). Re-check you copied value exactly and that an old TXT isn't shadowing it.412 txt_lookup_failed — no TXT at that host yet. Almost always propagation; wait and retry. Verify is idempotent — call it as many times as you like.We query a public resolver on purpose — not your zone's authoritative server — so a record that only exists in a split-horizon internal view won't pass. It has to be publicly resolvable.
A verified domain isn't routing anything yet — bind it to one of
your tunnels. The edge alias flips synchronously: the next
request with Host: app.acme.com proxies through that
tunnel.
curl -X PUT "$API/domains/7c3e1a90-.../bind" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{ "tunnel_id": "3a1b2c3d-..." }' { "id": "7c3e1a90-...", "domain": "app.acme.com",
"status": "active", "tunnelId": "3a1b2c3d-..." } active first → otherwise 412 not_verified.403 tunnel_org_mismatch / 404 tunnel_not_found.
The TXT proved ownership; now you need traffic to actually
arrive. Add a record sending app.acme.com to the
21tunnel edge:
| Type | Name | Value |
|---|---|---|
CNAME | app | edge.21tunnel.com |
For an apex (acme.com with no subdomain) most
providers can't CNAME the root — use an A record to the edge IP
your dashboard shows, or a provider that supports CNAME
flattening / ALIAS (Cloudflare, Route 53).
# Stop routing this domain (keeps it verified)
curl -X DELETE "$API/domains/7c3e1a90-.../bind" \
-H "Authorization: Bearer $JWT"
# Delete the domain entirely
curl -X DELETE "$API/domains/7c3e1a90-..." \
-H "Authorization: Bearer $JWT" Both evict the edge alias immediately — the domain stops resolving to your tunnel on the next request.
Read this before you go to production. It's the one part that surprises people.
For your free *.21tunnel.com subdomains, TLS is
fully handled — the wildcard cert is ours, HTTPS just works.
For a custom domain, v1 does
not issue a certificate for you yet. Plain
http://app.acme.com routes the moment you bind it.
For https://, you terminate TLS in front of us —
the supported pattern is Cloudflare in front of your
domain:
app.acme.com on Cloudflare (orange-cloud / proxied).edge.21tunnel.com.
Hitting https://app.acme.com without a
proxy in front returns a connection error at our edge (we
don't present a cert for domains we don't own). This is a
deliberate v1 boundary — automatic ACME issuance for bound
custom domains is the next milestone on the roadmap. If you
want HTTPS today, the Cloudflare-Flexible path is the answer.
The TXT challenge is identical everywhere — only the host-field
convention differs. The "Enter as host" column is what to type so
the record lands at
_21tunnel-challenge.app.acme.com for a domain
app.acme.com in the zone acme.com.
| Provider | Enter as host | Notes |
|---|---|---|
| Cloudflare | _21tunnel-challenge.app | Auto-appends the zone. Set the TXT record to DNS-only (grey cloud) — TXT can't be proxied anyway. CNAME flattening on the apex is supported. |
| Namecheap | _21tunnel-challenge.app | "Host" excludes the zone. Advanced DNS → Add New Record → TXT Record. TTL "Automatic" is fine. |
| GoDaddy | _21tunnel-challenge.app | "Name" excludes the zone. GoDaddy can be slow to propagate (minutes, occasionally longer). |
| AWS Route 53 | _21tunnel-challenge.app.acme.com | Wants the fully-qualified name and the value in quotes: "x7Qk9...Zp1a". Supports ALIAS for the apex CNAME. |
| Google Domains / Squarespace | _21tunnel-challenge.app | Host excludes the zone. Custom records UI. |
Unsure which convention yours uses? Enter the short form, save,
then dig +short TXT _21tunnel-challenge.app.acme.com
@1.1.1.1. If it's empty, try the fully-qualified form.
The dig check always tells you the truth.
| Symptom | Cause & fix |
|---|---|
Verify returns txt_lookup_failed | No TXT resolves yet — propagation, or the host field was wrong (zone double-appended). dig … @1.1.1.1 to confirm; fix the host per the provider table. |
Verify returns txt_mismatch | A TXT resolves but the value differs. Usually a stale challenge from an earlier add, or a truncated paste. Delete old TXT records and re-copy value. |
Verified, bound, but http:// hangs or 404s | Your CNAME isn't pointing at edge.21tunnel.com yet, or the bound tunnel is offline. Check the tunnel is up (mytunnel list) and the CNAME resolves. |
https:// fails but http:// works | Expected in v1 — no cert is issued for custom domains. Front the domain with Cloudflare in Flexible mode (see HTTPS). |
402 on add | upgrade_required = needs Pro; domain_quota_exceeded = at your plan's limit (Pro = 1, Team = unlimited). |