Custom Domains for Stripe-Powered Storefronts: From Checkout to CNAME
If your SaaS lets sellers run a storefront and you take payments through Stripe, every seller eventually asks the same question: "How do I get my own domain on this?" They want their customers to see shop.janesbakery.com, not yourplatform.com/janes.
This is two integrations standing next to each other:
- Custom domains — terminate TLS for the seller's hostname, route to your origin.
- Stripe — collect payment, send the buyer through Checkout, settle into the seller's connected account.
Each is well-trodden. Wiring them together is where the docs stop. This post is the wiring.
What we're building
[ buyer's browser: https://shop.janesbakery.com/products/sourdough ]
|
v
[ Domainee edge: TLS + proxy ]
|
hostname → tenantId in your DB
|
v
[ your origin: app.yourplatform.com ]
|
start Stripe Checkout Session
|
success_url: https://shop.janesbakery.com/orders/.../thanks
|
v
[ Stripe collects, settles to seller's Connect account ]
|
webhook: checkout.session.completed
|
v
[ your origin fulfills as Jane ]
The two pieces stay independent. Domainee handles everything from the buyer's TLS handshake to your origin. Stripe handles the money. You own the seller → tenant → Stripe-account mapping.
Step 1 — Two paths: bring-your-own or buy-in-app
Your seller arrives in one of two states:
- They already own a domain. Use the Connect API. They add a CNAME, you do nothing else.
- They don't have one yet. Use the Buy a Domain API. You register one on their behalf, they're the legal owner, you connect it on the same call.
For Connect API:
$ curl https://api.domainee.dev/v1/domains \
-H "Authorization: Bearer sk_live_…" \
-d '{
"hostname": "shop.janesbakery.com",
"originUrl": "https://app.yourplatform.com",
"mode": "proxy",
"metadata": { "tenantId": "jane-42" }
}'
For Buy a Domain (registers AND connects on one call):
$ curl https://api.domainee.dev/v1/domain-purchases \
-H "Authorization: Bearer sk_live_…" \
-d '{
"hostname": "janesbakery.com",
"registrant": { "name": "Jane Doe", "email": "jane@…" },
"autoConnect": {
"originUrl": "https://app.yourplatform.com",
"metadata": { "tenantId": "jane-42" }
}
}'
The metadata.tenantId field is your hook — we store it on the domain, surface it on every webhook, and you use it to look up which seller (and which Stripe Connect account) this hostname belongs to.
Step 2 — Map the incoming hostname back to a tenant
When a buyer hits shop.janesbakery.com/products/sourdough, Domainee's edge proxies the request to your origin with two headers:
X-Domainee-Original-Host: shop.janesbakery.comX-Forwarded-Host: shop.janesbakery.com
Your origin reads the hostname, looks up the tenant, and continues:
// middleware.ts
export async function tenantFromRequest(req: Request) {
const host =
req.headers.get("x-domainee-original-host") ??
req.headers.get("host")!;
const tenant = await db.tenants.findOne({ hostnames: host });
if (!tenant) throw new Error(`Unknown hostname: ${host}`);
return tenant; // { id, stripeAccountId, primaryHostname, … }
}
Cache the lookup. Hostnames don't change often; cache for an hour and invalidate when you receive a domain.created or domain.deleted webhook.
Step 3 — Stripe Checkout, with the seller's URL on both ends
The buyer clicks "Buy." You create a Checkout Session with the seller's connected account as the destination, and you point success/cancel URLs at the seller's hostname:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET!);
export async function startCheckout(opts: {
tenant: Tenant;
buyerEmail: string;
lineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
}) {
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer_email: opts.buyerEmail,
line_items: opts.lineItems,
payment_intent_data: {
application_fee_amount: 100, // your platform's cut
transfer_data: { destination: opts.tenant.stripeAccountId },
},
success_url: `https://${opts.tenant.primaryHostname}/orders/{CHECKOUT_SESSION_ID}/thanks`,
cancel_url: `https://${opts.tenant.primaryHostname}/cart?canceled=1`,
});
return session.url!;
}
Three things worth flagging.
transfer_data.destination is what routes the money to Jane's Connect account. application_fee_amount is your platform's cut.
success_url uses the seller's hostname, not yours. Buyers complete checkout on Stripe's hosted page (checkout.stripe.com), then bounce back to shop.janesbakery.com/orders/.../thanks. That return URL is the moment that breaks if you forget to wire custom domains — without it, buyers land on yourplatform.com/orders/... and immediately wonder if they got phished.
The {CHECKOUT_SESSION_ID} placeholder is Stripe's, not yours. They substitute it server-side before redirecting.
Step 4 — Webhook: domain.verified → mark the seller "live"
You don't know the moment Jane's CNAME landed until DNS catches up. Don't poll. Subscribe to the webhook once, handle it server-side:
// /webhooks/domainee
app.post("/webhooks/domainee", verifyDomaineeSignature, async (req, res) => {
const event = req.body;
if (event.type === "domain.verified") {
const tenantId = event.data.domain.metadata.tenantId;
await db.tenants.updateOne(
{ _id: tenantId },
{
$addToSet: { hostnames: event.data.domain.hostname },
$set: { domainLive: true },
},
);
}
res.json({ ok: true });
});
That tenant document drives your storefront. The hostnames array is what tenantFromRequest reads. The domainLive: true flag is what your seller-side dashboard reads to switch them from "Pending DNS" to "Live."
Apex vs subdomain — the seller will ask
Most sellers add a subdomain: shop.janesbakery.com. A CNAME at the subdomain is normal DNS, easy.
A few sellers want the apex: janesbakery.com itself. Apex CNAMEs are technically against spec; their registrar may support ALIAS / ANAME / Cloudflare CNAME-flattening, or they may need A records pointing at our edge IPs.
The Domainee API returns both options in dnsRecords so your UI can show whichever the registrar supports. Tell the seller: "Use the CNAME if your registrar accepts it at the root; otherwise use the A records." Don't try to detect their registrar — they know.
Stripe-hosted Checkout on the seller's domain (the paid option)
By default, Stripe Checkout lives at checkout.stripe.com. Some buyers notice. If you want Checkout to load at pay.janesbakery.com instead, that's a Stripe paid feature (Custom Domains for Checkout, Stripe Tax tier or higher).
For most platforms this is overkill. The success/cancel URLs landing on the seller's domain is enough to maintain the seller's brand. Pick this only if you've had buyer-trust complaints specifically about the Stripe checkout subdomain.
Embedded Checkout: an alternative pattern
If you don't want any cross-domain bounce, Stripe also offers Embedded Checkout — render the payment form inside the seller's domain. The buyer never leaves shop.janesbakery.com.
Tradeoff: more JS, more PCI scope to think about, harder to debug. Use it when the bounce to checkout.stripe.com is genuinely costing you conversion. For most platforms, hosted Checkout with seller-domain return URLs is the right balance.
Ship it
Sign up at domainee.dev/sign-up — 50 customer hostnames free, no card. Mint a key, register your first seller's domain, point the Stripe success_url at it, and the full loop is wired in about 80 lines of code.
For the Connect API quickstart see /docs/quickstart. For the Buy a Domain API see /buy-domain-api. For when to mask vs forward in an e-commerce flow see /domain-masking-api.