← Back to blog

Custom Domains for Stripe-Powered Storefronts: From Checkout to CNAME

Daniel Sternlicht·
stripestripe checkoutcustom domainstutorialsaasstorefrontsconnect
Custom Domains for Stripe-Powered Storefronts: From Checkout to CNAME

To put a seller's own domain on a Stripe-powered storefront you need two pieces: a custom-domain layer that terminates TLS for shop.janesbakery.com and proxies to your origin, and a Checkout Session whose success_url and cancel_url point back at that same hostname. Stripe itself never sees the domain. Your platform owns the hostname-to-seller mapping.

That's the whole architecture. The rest of this post is the code: registering the domain, mapping the incoming hostname to a tenant, creating the Checkout Session against the seller's Connect account, and the webhook that flips the storefront live.

Why this trips people up

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:

  1. They already own a domain. Use the Connect API. They add a CNAME, you do nothing else.
  2. 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.com
  • X-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.

FAQ

Can Stripe Checkout run on my seller's custom domain? The hosted Checkout page itself lives at checkout.stripe.com unless you pay for Stripe's Custom Domains for Checkout feature. What you can do for free is send buyers back to the seller's domain: set success_url and cancel_url to https://shop.janesbakery.com/.... That's the part buyers actually notice.

Does Stripe need to know about the custom domain? No. Stripe only sees the URLs you pass in the Checkout Session. The custom domain is handled entirely at your edge, which terminates TLS for the hostname and proxies to your origin with the original host in a header.

How do I know which seller a request belongs to? Read X-Domainee-Original-Host (or X-Forwarded-Host) on the incoming request and look the hostname up in your tenants collection. Store metadata.tenantId on the domain when you create it, and every webhook carries it back to you.

Can a seller use their root domain instead of a subdomain? Yes. Apex domains work through ALIAS/ANAME records or CNAME flattening depending on the registrar, and the API returns both the CNAME and the A records so your UI can show whichever their registrar supports.

What happens if the seller's DNS is wrong? The domain stays in pending and no certificate is issued, so nothing serves at the hostname until DNS resolves. Your dashboard should show "Pending DNS" until the domain.verified webhook lands. The debug runbook covers the usual causes.

Ship it

Sign up at domainee.dev/sign-up for 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, and the wider walkthrough of selling domains in-app is at /blog/let-your-users-buy-domains-inside-your-saas. For when to mask vs forward in an e-commerce flow see /domain-masking-api. Pricing for every tier is at /pricing.