Published September 2, 2026
Setting up a custom domain for short links
Ownership is proved with a TXT record before a certificate is issued, routing is checked separately, and a link's domain is fixed at the moment the link is created.
A short link on a custom domain reads go.example.com/spring-catalogue rather than uhm.co/x7Kp2mQ. The redirect, the click record and the QR scan path all behave exactly as they do on the root domain. What changes is who owns the name in front of the slug, and that difference has consequences at setup and at removal that are worth understanding before the first link is created.
Setup is two separate steps, usually taken at different moments: proving control of the domain, and pointing it at us. The dashboard treats them as separate because they fail for different reasons and have different fixes.
| Link | Created | Status |
|---|---|---|
| go.example.com/spring-catalogue https://example.com/collections/spring | Aug 29, 2026 | Live |
| go.example.com/pricing https://example.com/pricing | Aug 16, 2026 | Live |
| go.example.com/webinar-may https://example.com/events/may-webinar | Jul 22, 2026 | Paused |
Ownership is proved before anything is issued
Adding a domain generates a 32 character token. Verification looks for it in a TXT record on a dedicated subdomain: for a domain named go.example.com, that record lives at _uhm-verify.go.example.com.
A TXT record rather than a CNAME, for two reasons. A CNAME cannot exist at a zone apex, so requiring one would make a bare domain such as example.com impossible to verify at all. And a record at a dedicated name keeps "prove you own this" separate from "route traffic here", which are the two steps above and are taken at different times.
Any TXT record at that name whose value matches counts, not only the first one found. A DNS provider may already hold an unrelated TXT record there, and a stale token left behind by an abandoned earlier attempt is common enough that failing on it would generate support tickets for nothing.
Everything that is not a match reads as "not verified yet" rather than as an error: no record, no TXT records at all, a resolver failure, or a lookup that passed the five second timeout. Propagation takes minutes, so verification is retryable by design. The one case reported differently is a record that exists and does not carry the token. That is almost always a paste error, so the expected value and the value actually found are shown side by side instead of asking for another attempt against the same mistake.
go.example.com
VerifiedOwnership record
_uhm-verify.go.example.com TXT 4f1c9a2be7d05836ac41e9b7d2065f3a
- Routing
- Points here
- Certificate
- Issued on the first request
- Links on this domain
- 3
Routing is checked separately
Verification proves ownership. On its own it does not make a single link resolve. DNS for the host still has to point here: a CNAME to uhm.co for a subdomain, or A and AAAA records for a bare domain, which cannot carry a CNAME at its apex.
That is detected rather than assumed, and it reports one of three answers: routed, not routed, or unknown. The address comparison is made against whatever uhm.co itself currently resolves to, not against a hardcoded address. The server's address is not a constant, and a stale literal would eventually tell a customer that correct DNS is wrong. A resolver failure on our side is reported as unknown for the same reason, never as a fault in the customer's zone.
The reason both steps are surfaced this way is that a green badge the moment ownership is proved reads as finished when it is not. At that point the domain is claimed and nothing on it resolves.
A certificate is issued only for a verified host
Certificates are issued on demand, during the first TLS handshake for a hostname. Caddy asks an internal endpoint before issuing anything and proceeds only on a 200.
That endpoint is the one place in this codebase that deliberately fails closed. Everywhere else a degraded dependency fails open: the link cache falls through to Postgres, the rate limiter allows the request, the safety scanner reports "unknown" rather than blocking. Here the asymmetry runs the other way, because on demand issuance is triggered by any hostname whose DNS currently points at our address. A wrongly granted 200 during a database outage would let a stranger order certificates until Let's Encrypt's limit of 300 new orders per three hours is exhausted, after which nothing can be issued or renewed, uhm.co included. A wrongly refused 403 during the same outage delays one customer's own issuance until the database is back.
The answer is cached for sixty seconds. Short enough that a domain verified a minute ago can still get its certificate, long enough to keep the check off the handshake path for a hostname already serving traffic.
Who can add one, and how many
Adding a domain is restricted to a workspace owner or admin rather than every member. Pointing a company's DNS somewhere and taking on the verification step is a workspace level commitment, closer to billing than to day to day link creation.
The number of domains comes from the plan: none on the free plan, one on Pro, ten on Business. The limit is read from the plan record at the moment of the add rather than being hardcoded against a plan name, so a change to what a plan includes takes effect with no deployment. Current figures are on the pricing page.
A downgrade is never applied retroactively. The limit is consulted only when a new domain is added, so a workspace dropping from Business to Pro keeps every domain it already verified, serving exactly as before. What it loses is the ability to add another. This is the same rule the rest of the product follows for billing states: block creation, never break what already works.
A link's domain is decided when the link is created
The domain is set at creation and cannot be changed afterwards. Editing a link accepts a new destination, a new slug, a title, an expiry date, a password and a QR configuration. It does not accept a different domain.
That is a decision rather than an omission. A link's public address is its host plus its slug, and moving a link between hosts would change an address that is already in circulation, which is the one thing a short link exists not to do. Putting an existing destination on a new domain means creating a new link there.
The practical consequence shows up in print. A QR code carries the scan path on its own link's domain, so the domain has to be settled before anything goes to a printer. A code exported before that decision points at the root domain permanently, because the link behind it cannot be moved later.
A domain with links on it cannot be removed
Removing a domain that still has links pointing at it fails, and the dashboard reports that the domain is in use. The foreign key is declared ON DELETE RESTRICT specifically to produce that failure.
The alternative would be to clear the domain reference and let those links fall back to the root namespace, and both outcomes of that are worse than a refusal. The slug may already be taken on uhm.co, in which case the fallback collides with somebody else's link. If it is not taken, the links move silently onto our namespace, and every printed code and shared address for them now resolves somewhere the customer never chose. Removing a domain means moving or deleting its links first, as an explicit act.
Nothing else about the link changes
Every rule that applies on uhm.co applies identically on a custom domain. Reserved slugs stay reserved. Destinations are still scanned. A reported destination still shows the interstitial before the redirect, a blocked link still stops, and a suspended account still stops every link it owns. A custom domain is a name, not a separate policy.
Creating a link on a domain that has not finished verification is refused rather than queued. Through the API that is a domain_not_verified response from the link creation endpoint when the domain id names a domain with no verification date. A domain id belonging to another workspace returns a plain not_found instead, which is the same answer a link belonging to another workspace gets. Both are listed on the developer documentation page.