DOCS · CUSTOM DOMAINS

Bring your domain.

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.

What happens, in order

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.

1 Add the domain Pro

Register the apex or subdomain you want to use. We respond with the exact TXT record to publish — copy it verbatim.

Request

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.

Rules & errors

  • 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.
  • Already registered (by you or anyone) → 409 domain_taken.
  • Free tier / post-trial → 402 upgrade_required. Custom domains need Pro (a trial counts).
  • Over your plan's quota → 402 domain_quota_exceeded. Free = 0, Pro = 1, Team = unlimited. During a trial you get 1.

2 Publish the TXT record

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:

FieldValue
TypeTXT
Name / Host_21tunnel-challenge.app.acme.com — or just _21tunnel-challenge.app on providers that auto-append the zone (see below).
Value / ContentThe value from the API response, exactly.
TTL300 (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.

Check it yourself first

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).

3 Verify ownership

We do a live TXT lookup against _21tunnel-challenge.<domain> via 1.1.1.1. If the value matches, the domain flips to active.

Request

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,
  ...
}

If it doesn't match

  • 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.

4 Bind it to a tunnel

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.

Point the domain's traffic at a 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-..." }
  • Domain must be active first → otherwise 412 not_verified.
  • The tunnel must belong to your org → else 403 tunnel_org_mismatch / 404 tunnel_not_found.
  • Re-binding to a different tunnel just overwrites — idempotent.

Point your DNS at us too

The TXT proved ownership; now you need traffic to actually arrive. Add a record sending app.acme.com to the 21tunnel edge:

TypeNameValue
CNAMEappedge.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).

Unbind / remove

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

HTTPS on a custom domain

Read this before you go to production. It's the one part that surprises people.

What's automatic, and what isn't

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:

  • Put app.acme.com on Cloudflare (orange-cloud / proxied).
  • Cloudflare issues + renews the public cert and serves HTTPS to your visitors automatically.
  • Set the origin/SSL mode to Flexible so Cloudflare connects to our edge over HTTP. The CNAME still points at 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.

5 DNS provider notes

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.

ProviderEnter as hostNotes
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.

? Troubleshooting

SymptomCause & 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).

Next