How to Let Users Connect a Custom Domain to Your SaaS in 2026
Letting your users connect their own domain (shop.acme.com -> your-saas.com) is one of those features that sounds like an afternoon and turns into a quarter. SSL certs, CNAME validation, edge routing, retry logic for slow DNS propagation, the support tickets that arrive at 2am.
This tutorial skips the platform engineering. You'll use Domainee's REST API to ship the entire feature in roughly 80 lines: a form, a status check, and a webhook handler. By the end, your customers can paste a hostname, see the DNS record they need to add, and watch their domain go live in about a minute.
We'll use Node.js for the server snippets and plain fetch. The same flow works in any language: it's a REST API and a webhook receiver.
What you're building
[your customer's browser]
|
v
[shop.acme.com]
|
v
[Domainee edge: TLS + routing]
|
v
[your origin: app.your-saas.com]
Your customer's request hits Domainee's edge first. We terminate TLS (auto-issued cert from Let's Encrypt), then proxy to whatever origin URL you registered. You don't run any new infrastructure.
Architecture: who owns what
| Piece | Who owns it |
|---|---|
The hostname (shop.acme.com) | Your customer |
| The CNAME at their DNS provider | Your customer |
| TLS certificate | Domainee (issued and renewed automatically) |
| Edge routing + traffic | Domainee |
| Origin app | You |
| The "Add a custom domain" UI | You |
| Mapping a domain back to a tenant in your DB | You |
You handle the user-facing surface and the data model. Everything between TCP-on-the-internet and your origin is on us.
Step 1. Get an API key
Sign up. It's free, 50 domains, 100 GB bandwidth, no credit card. Open the Developers page in your dashboard, click New key, copy it, and store it as a secret in your app:
# .env
DOMAINEE_API_KEY=sk_live_...
The free tier is real. You don't add a card until you cross 50 customer domains OR 100 GB of bandwidth in a month. That's enough runway for most pilots and well into your early paid phase.
Step 2. Register the domain when your customer adds one
When a customer enters shop.acme.com in your UI, send a single POST to register it:
// server/domains.ts
const DOMAINEE_API = "https://api.domainee.dev/v1";
export async function registerCustomerDomain(opts: {
hostname: string;
workspaceId: string;
}) {
const res = await fetch(`${DOMAINEE_API}/domains`, {
method: "POST",
headers: {
"authorization": `Bearer ${process.env.DOMAINEE_API_KEY}`,
"content-type": "application/json",
"idempotency-key": `domain:${opts.workspaceId}:${opts.hostname}`,
},
body: JSON.stringify({
hostname: opts.hostname,
originUrl: "https://app.your-saas.com",
mode: "proxy",
redirectWww: true,
metadata: { workspaceId: opts.workspaceId },
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`${err.code}: ${err.message}`);
}
const { domain, warnings } = await res.json();
return { domain, warnings };
}
Three things worth calling out.
The idempotency-key header makes the call safe to retry. If the network drops mid-request, calling again with the same key gives you the existing record back instead of a 409 conflict.
The metadata field is yours. Stash the workspace id, the customer's user id, whatever you need later to map a domain back to a tenant in your own DB.
warnings is always returned, possibly empty. It surfaces non-fatal preflight findings, like "your customer's hostname doesn't resolve yet" or "your origin returned a 5xx during reachability check." These don't block creation; they're hints to show in your UI.
Step 3. Show your customer their CNAME
The response includes a dnsRecords array. For most setups, it's exactly one CNAME:
{
"domain": {
"id": "8f09b47c-b42f-4d14-8395-2989db76e6f8",
"hostname": "shop.acme.com",
"status": "pending",
"dnsRecords": [
{
"type": "CNAME",
"name": "shop.acme.com",
"value": "edge.domainee.dev",
"purpose": "Traffic Routing"
}
]
}
}
Render it in your UI as a copyable instruction:
function DnsInstructions({ records }: { records: DnsRecord[] }) {
return (
<div>
<h3>Add this to your DNS provider</h3>
{records.map((r) => (
<pre key={r.name}>
{`Type: ${r.type}
Name: ${r.name}
Value: ${r.value}`}
</pre>
))}
<p>Once you save this record, the domain goes live in about a minute.</p>
</div>
);
}
If your customer is on Cloudflare, tell them to set the proxy status to DNS only (the gray cloud). Otherwise Cloudflare will wrap our edge inside their edge and the TLS handshake will fail.
Step 4. Detect when the domain goes live
You have two options. We recommend webhooks.
Option A: Webhooks (production)
Register a webhook endpoint once, on app boot or via a one-time setup script:
curl -X POST https://api.domainee.dev/v1/webhook-endpoints \
-H "Authorization: Bearer $DOMAINEE_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://app.your-saas.com/webhooks/domainee",
"events": []
}'
(An empty events array subscribes to everything. You can filter by type once you know what you want to handle.)
Then handle deliveries server-side. Every webhook is HMAC-signed; you MUST verify the signature before trusting the payload, or anyone can forge events to your endpoint:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/domainee",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("x-domainee-signature");
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.DOMAINEE_WEBHOOK_SECRET!)
.update(req.body)
.digest("hex");
if (
!sig ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString());
switch (event.type) {
case "domain.verified":
// mark live in your DB; email the customer
await markDomainLive(event.data.id);
break;
case "domain.failed":
// DNS pointed at the wrong place, or origin SSRF flagged it
await markDomainBroken(event.data.id, event.data.failureReason);
break;
}
res.status(200).end();
},
);
req.body MUST be raw bytes. If you parse JSON before verifying, the bytes change on re-serialization and the signature won't match. Use express.raw({ type: "application/json" }) (or your framework's equivalent) on this single route.
The full event list (domain.created, domain.verified, domain.failed, domain.monitor_updated, domain.expired, domain.deleted) is in the webhook events docs.
Option B: Polling (dev mode, firewalled environments)
If you can't accept inbound webhooks, poll every 30 seconds for the first few minutes after registration:
async function getDomain(id: string) {
const res = await fetch(`${DOMAINEE_API}/domains/${id}`, {
headers: {
"authorization": `Bearer ${process.env.DOMAINEE_API_KEY}`,
},
});
return res.json();
}
const { domain } = await getDomain(domainId);
if (domain.status === "verified") {
// it's live
}
Webhooks beat polling at scale. Use polling for first-launch UX (so the customer sees status update without a page refresh), and webhooks for the canonical state in your DB.
Step 5. Listing and disconnecting per workspace
Because you stuffed workspaceId into metadata at create time, you can map any Domainee domain back to a tenant. When a customer wants to disconnect:
export async function deleteCustomerDomain(domainId: string) {
const res = await fetch(`${DOMAINEE_API}/domains/${domainId}`, {
method: "DELETE",
headers: {
"authorization": `Bearer ${process.env.DOMAINEE_API_KEY}`,
},
});
if (!res.ok) throw new Error(await res.text());
}
For listing, you can query our API or just keep your own row per domain in your DB and read from there. We recommend keeping your own row anyway: it's where you store who owns it, when they added it, and any product-specific metadata.
Going to production: the three things to harden
Free-tier exhaustion. Once you cross 50 domains or 100 GB of bandwidth in a billing month, the API returns 402 billing_required until you add a card. Catch that error code and alert your team, not your customer.
Idempotency on every write. We covered the idempotency-key header in step 2. Send a stable key (like workspaceId:hostname) on every POST, so retries don't double-create.
Rate limits and errors. The API returns standard 429s with a Retry-After header. Wrap your calls in an exponential backoff helper. Use the code field in error bodies (bad_request, preflight_failed, conflict, billing_required) for branching, not the human-readable message.
That's the whole feature. Roughly 80 lines of glue, no certificate manager, no edge proxy, no DNS monitor of your own. Your customers see "Add a custom domain" in your UI, paste their hostname, copy the CNAME, and 60 seconds later their site is live on their own domain at HTTPS.
The free tier covers your first 50 customer domains and 100 GB of bandwidth a month. Most teams ship and validate the entire feature without paying anything.
Grab an API key and head to the quickstart when you're ready to wire this up for real.