Custom Domains Design¶
Overview¶
Add custom domain support so Pro users can serve shortlinks from their own branded domains (e.g. go.acme.com/abc123 instead of usnp.me/abc123). Uses Cloudflare for SaaS for TLS provisioning and proxying.
Decisions¶
- Infrastructure: Cloudflare for SaaS handles TLS + proxying. No changes to DigitalOcean App Platform.
- ID uniqueness: Globally unique (unchanged). A
short_idmaps to exactly one link regardless of domain. - Verification: CNAME + TXT record. TXT proves ownership, CNAME enables routing.
- Domain limit: Free: 0 (upgrade CTA). Pro: unlimited.
- Webhook payloads: Include the domain the click came through from day one.
Data Model¶
New domains table¶
CREATE TABLE domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
api_key_id UUID NOT NULL REFERENCES api_keys(id),
domain TEXT NOT NULL UNIQUE,
verification_token TEXT NOT NULL,
txt_verified BOOLEAN NOT NULL DEFAULT false,
cname_verified BOOLEAN NOT NULL DEFAULT false,
cf_custom_hostname_id TEXT,
tls_status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_domains_api_key_id ON domains(api_key_id);
CREATE INDEX idx_domains_domain ON domains(domain);
ALTER TABLE domains ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow all operations on domains" ON domains FOR ALL USING (true) WITH CHECK (true);
Changes to existing tables¶
-- Link-to-domain association (nullable; NULL = usnp.me)
ALTER TABLE links ADD COLUMN domain_id UUID REFERENCES domains(id);
-- Track which domain a hit came through
ALTER TABLE hits ADD COLUMN domain TEXT;
No migration needed for existing data. Null domain_id means the link is on usnp.me.
Verification states¶
| txt_verified | cname_verified | tls_status | Meaning |
|---|---|---|---|
| false | false | pending | Just registered, waiting for DNS |
| true | false | pending | Ownership proved, CNAME not found yet |
| true | true | provisioning | Both records verified, Cloudflare issuing cert |
| true | true | active | Live, serving traffic |
| true | true | failed | Cloudflare cert provisioning failed |
Cloudflare for SaaS Integration¶
Setup (one-time)¶
- Cloudflare zone with "SSL for SaaS" enabled
- Fallback origin:
fallback-origin.usnp.mepointed at the DO App Platform app - New env vars:
CLOUDFLARE_API_TOKEN,CLOUDFLARE_ZONE_ID
Domain lifecycle¶
- Register: User calls
POST /domainswith their subdomain. App generates averification_tokenand stores the domain. - Verify: User calls
POST /domains/{id}/verify. App performs DNS lookups: - TXT:
_yousnap-verify.{domain}must contain the token - CNAME:
{domain}must point tofallback-origin.usnp.me - Provision: Both pass -> app calls Cloudflare Create Custom Hostname API. Stores
cf_custom_hostname_id, setstls_status = 'provisioning'. - Active: App polls Cloudflare hostname status (or checks on next verify call) to confirm TLS. Flips
tls_status = 'active'. - Delete: App calls Cloudflare Delete Custom Hostname API, deletes
domainsrow. Links with thatdomain_idfall back tousnp.me(setdomain_id = NULL).
Failure handling¶
If Cloudflare cert provisioning fails, tls_status stays failed. Dashboard shows the error and a retry button that re-triggers verification + provisioning.
API Endpoints¶
New endpoints¶
| Method | Path | Auth | Description |
|---|---|---|---|
POST /domains |
JWT or API key (Pro only) | Register a custom domain | |
GET /domains |
JWT or API key | List user's domains with status | |
POST /domains/{domain_id}/verify |
JWT or API key | Trigger DNS verification | |
DELETE /domains/{domain_id} |
JWT or API key | Remove domain + CF hostname |
POST /domains¶
Request:
Response (201):
{
"id": "uuid",
"domain": "go.acme.com",
"txt_record": "_yousnap-verify.go.acme.com",
"txt_value": "yousnap-verify=abc123...",
"cname_target": "fallback-origin.usnp.me",
"tls_status": "pending"
}
Free-tier users get 403 with upgrade message.
POST /domains/{domain_id}/verify¶
Response (200):
Partial verification returns which records are missing with instructions.
Changes to existing endpoints¶
POST /shorten: Add optional domain_id to LinkCreate. When set:
- Validate domain belongs to caller and tls_status = 'active'
- Build short_url using the custom domain (e.g. https://go.acme.com/abc123)
- Store domain_id on the link
GET /{short_id}: No lookup change (globally unique IDs). Pass Host header to record_hit_and_notify for analytics.
GET /{short_id}/qr: Build QR URL using the link's domain instead of hardcoded usnp.me.
Webhook payloads: Add domain field containing the domain the click came through.
Routing & Middleware¶
Domain cache¶
In-memory set of verified domain strings, refreshed every 60 seconds via background task. Avoids hitting Supabase on every custom-domain request.
Middleware changes¶
known_hosts = {"yousnap.me", "www.yousnap.me", "usnp.me"}
if host in ("yousnap.me", "www.yousnap.me") and method == "GET":
# serve landing page (unchanged)
elif host == "usnp.me" or host in verified_domains_cache:
# pass through to API
else:
return 404
The cache refresh runs as an asyncio background task started on app startup.
Dashboard UI¶
New "Domains" tab¶
- Domain list table: domain, status badge, created date, delete button
- "Add Domain" button opens modal
- Free-tier users see upgrade CTA instead of add button
Add domain modal¶
- User enters subdomain
- On submit, shows DNS instructions with copy buttons:
- TXT record:
_yousnap-verify.go.acme.com-> value - CNAME:
go.acme.com->fallback-origin.usnp.me - "Verify" button triggers verification
- Inline status icons per record (check/x)
- Status flips to active once Cloudflare provisions the cert
Link creation change¶
- Domain dropdown in create modal (defaults to
usnp.me) - Only shows verified/active domains as options
- Link list shows which domain each link is on
Analytics¶
- New
domaincolumn onhitstable stores the Host header value - Analytics endpoints can add a
domainsbreakdown (optional for v1, data is captured either way) - Webhook payloads include
domainfield
New Environment Variables¶
| Variable | Required | Purpose |
|---|---|---|
CLOUDFLARE_API_TOKEN |
Yes (for custom domains) | Cloudflare API token with Custom Hostnames permission |
CLOUDFLARE_ZONE_ID |
Yes (for custom domains) | Cloudflare zone ID |