← Back to blog

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

Jonathan Geiger·
stripecustom domainstutorialsaasstorefrontsconnect

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.

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.

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