Custom Domain
Connect a single domain to power branded email (send invoices,
quotes, dunning from noreply@acme.sn) and public storefront
hosting (visitors browse https://acme.sn instead of
https://business.leeram.today/store/acme). Both surfaces, one
registration on /settings/domain.
This is the loudest "this isn't really ours" signal customers feel in the product, and the most common upsell trigger when SMEs reach for their first paid tier.
Availability
Currently available on leeram-business deployments (the multi-org SME platform). Email is mediated by kuploy.app's master Brevo account; storefront host-binding runs on the deployment's own Vercel project. You don't configure a Brevo account yourself — sender domain registration, DKIM signing, and bounce handling all happen on kuploy-hub.
For other products (kuploy-cloud, etc.), see SMTP Setup or Email Hosting — those run a separate per-tenant SMTP / Stalwart path.
How it works
your-org owner → /settings/domain
↓
☑ branded email ☑ public storefront
↓
biz → POST kuploy-hub/api/v1/email/domains
↓
┌─ hub: Brevo create-sender-domain (email)
├─ hub: Vercel CNAME at parent zone (both)
└─ biz: Vercel project-domain attach (storefront)
↓
cert provisioning + DNS validation (~15 min)
↓
outgoing mail signs against acme.sn (DKIM)
visitors at https://acme.sn → /store/<orgslug>
You don't see Brevo API keys and never need a Brevo dashboard login. Bounces, complaints, and unsubscribes are handled centrally; reputation pools across all deployments using the master account.
Operator setup
Up to four things on your deployment, depending on what you want to offer:
1. Pick your mail transport
Set on your biz deployment's host (Vercel project envs, or wherever your runtime config lives):
MAIL_PROVIDER=hub # or "brevo" / "smtp" — see below
MAIL_PROVIDER decides where outgoing transactional mail goes. All
three values are first-class options; pick based on whether you want
per-org branding or a single deployment-wide sender.
| Value | Path | Sender | Pick this when |
|---|---|---|---|
hub | biz → kuploy-hub → master Brevo | Per-org: verified org_domain row → branded; else hub's MAIL_FROM fallback | You want this feature — per-org branded email |
brevo | biz → your own Brevo account (BREVO_API_KEY) | Deployment-wide MAIL_FROM — no per-org branding | You want to run your own Brevo and skip hub mediation |
smtp | biz → your own SMTP server (SMTP_HOST etc.) | Deployment-wide MAIL_FROM | You have an existing SMTP relay you'd rather use |
| (unset) | log-only | — | Local dev / preview without prod creds |
For the rest of this guide we assume MAIL_PROVIDER=hub — it's
the only path that exercises the per-org org_domain lookup. With
brevo or smtp, the registrations on /settings/domain still
work (operator can register a domain, hub still verifies DKIM, etc.)
but outgoing mail uses your deployment-wide sender, not the org's
branded address. You can flip back and forth at any time without
losing registrations.
When MAIL_PROVIDER=hub, the license key is read from the synced
license_cache row populated by your existing claim flow — no new
credential to manage. BREVO_API_KEY on biz becomes unused (hub
holds the master key); you can leave it set or remove it.
2. Plan flags for custom domains (paid orgs only)
To let a paid org register a fully custom domain they own
(acme.sn, acmetrading.com, etc.), the plan they're subscribed
to needs:
customEmailDomain: true— branded email on a custom domaincustomDomain: true— public storefront on a custom domain
The default seed (pnpm db:seed:plans) sets customEmailDomain on
Starter+, customDomain on Pro only.
3. Free-tier branded subdomains (optional, recommended)
Set on the hub side, per-tenant, in Admin → Tenants → <tenant id> → Free zone:
leeram.today
When set, every org (paid or free) can register
<orgslug>.<zone> as their domain. Synced down via license-sync
envelope; biz reads it without any local env. Legacy fallback:
the env var BRANDED_EMAIL_FREE_ZONE on biz still works if you
haven't migrated to the hub-stored value yet.
4. Storefront project-attach — biz VERCEL_PROJECT_ID
For custom-domain storefronts, your biz deployment must tell hub which Vercel project hub should attach the hostname to. Set on your biz deployment:
VERCEL_PROJECT_ID— biz's own Vercel project id. Non-secret; just an identifier (e.g.prj_xxxxxxxxxxxxxxxx). Hub uses its own credentials to callPOST /projects/:id/domainson your behalf — your biz deployment never holds a Vercel write token.
When VERCEL_PROJECT_ID is unset on biz, the storefront toggle on
/settings/domain is disabled; email-only registration still works.
Auto-DNS at the parent zone (and the project-attach itself) are handled hub-side using hub's own credentials and require no setup on your biz deployment beyond this one identifier — kuploy.app manages that side as part of the platform.
Per-surface flow
What happens on Register domain when both surfaces are enabled:
biz POST /api/v1/email/domains { orgId, domain, usage:"both",
vercelProjectId: $VERCEL_PROJECT_ID }
│
▼
hub: Brevo create-sender-domain (returns DKIM/DMARC/brevo-code)
├── auto-DNS write Brevo records at parent zone
├── auto-DNS write storefront A record at parent zone
└── attach <domain> to biz Vercel project (cert provision)
│
▼
hub: insert org_domain { emailStatus: "pending_dns",
storefrontStatus: pending_dns
→ "verified" when DNS + attach both succeeded }
│
▼
biz post-action license-sync; biz cache picks up the new row
│
▼
auto-verify cron polls Brevo → emailStatus → "verified"
Vercel cert provisions (~30s) → middleware host-binding active
The org owner's setup
What an organization owner does after you've enabled the feature on their plan:
- Open Settings → Domain.
- Pick a mode (when both available):
- Free subdomain — pre-filled with the org slug, e.g.
acme-trading-co.leeram.today. No DNS work needed. - Custom domain — type your own (
acme.sn). Requires the matching plan flag(s) and DNS access at your registrar.
- Free subdomain — pre-filled with the org slug, e.g.
- Tick the surfaces you want:
- Branded email — emails sent as
<localPart>@<domain>. - Public storefront — visitors reach you at
https://<domain>.
- Branded email — emails sent as
- Confirm the live sender / storefront preview matches what you want.
- Click Register domain.
What happens next depends on whether auto-DNS could write to the parent zone:
Auto-DNS path (free zone, Vercel-reachable): records are written immediately. The cron polls Brevo every 15 min; cert provisioning on the storefront side completes within a minute of DNS propagation. You walk away.
Manual path (custom domain, Vercel doesn't manage your zone): the page shows four FQDN records to publish at your DNS provider:
| Purpose | Type | Surface |
|---|---|---|
brevo_code | TXT | |
dkim1Record | CNAME | email (DKIM signing key) |
dkim2Record | CNAME | email (DKIM rotation) |
dmarc_record | TXT | email (auth-fail policy) |
| (storefront) | CNAME → cname.vercel-dns.com | storefront |
Add them, then click Verify email (or wait for the cron). The storefront cert provisions automatically once DNS resolves.
Verifying it works
A 5-minute end-to-end smoke:
- Open
/settings/domainas a paid org owner. The form should render with the right toggles enabled per the org's plan. - Walk through registration with a test domain. A free-zone
subdomain works fine —
acme-test.yourdomain.com. - Email: row flips to
emailStatus: verifiedwithin 15 min (or click Verify email). Send a test invoice; inspect headers —From: …@acme-test.yourdomain.com,DKIM-Signature: d=acme-test.yourdomain.com,Authentication-Results: dkim=pass. - Storefront: visit
https://acme-test.yourdomain.comin a browser. Should serve the org's storefront with a valid TLS cert.
What customers see
After verification, every outgoing message goes out as:
From: Acme Trading <noreply@acme.sn>
DKIM-Signature: v=1; a=rsa-sha256; d=acme.sn; ...
Authentication-Results: dkim=pass d=acme.sn
And every public link the org renders — share buttons on /catalog,
the og:url and canonical tags on the storefront page, the View
order & pay button on order-confirmation emails, the Open this
quote / invoice link in quote and invoice emails, the dunning
reminder, sitemap entries — points to the branded origin:
https://acme.sn/ (storefront)
https://acme.sn/<itemId> (catalog item deep-link)
https://acme.sn/q/<token> (quote share)
https://acme.sn/i/<token> (invoice share)
Storage of these URLs is canonical (${APP_URL}/store/<slug> /
/q/<token> / /i/<token>); the swap to the custom origin happens at
render time. So if the operator removes the custom domain later, every
old display gracefully falls back to the apex — and emails sent
yesterday on the branded origin keep their branded links until the row
is removed (then the link 404s, same as any expired branded message).
The storefront subdomain flow (acme-trading-co.business.leeram.today)
gets the same treatment: middleware passes /q/*, /i/*, /api/*,
/_next/*, /robots.txt, /sitemap.xml, and /favicon.ico through
unrewritten so token-share routes resolve cleanly on the branded host
without needing a per-org /store/<slug> prefix in the email body.
And visitors at https://acme.sn see the org's storefront under
their own domain, fully cert-protected — no business.leeram.today
in the URL bar.
Replies still go to whoever sent the mail (the user's email for user-initiated sends; not delivered for automated dunning).
Reply-to behaviour
| Send type | Reply-To |
|---|---|
| User-initiated (quote / invoice send) | The user's own email |
| Cron-driven (dunning, recurring invoices) | Not set — replies bounce |
Same as before branded email landed.
Downgrade behaviour
If you downgrade an org's plan to a tier without the matching flags:
- Outgoing mail from that org immediately falls back to the
default sender (your
MAIL_FROM, or kuploy-hub'snoreply@kuploy.app). - The verification record on hub stays intact — re-enabling the feature restores branded sending without a re-verification.
- The storefront URL stops resolving on the custom host (Vercel cert remains; the lookup just doesn't match a verified row in the synced cache, so middleware doesn't rewrite).
Starting over
If you registered the wrong domain (typo, churned customer), the
clean reset is Remove on /settings/domain. Tears down in
order: Brevo Sender → Brevo sender-domain → Vercel CNAME records →
biz Vercel project-domain detach → org_domain row.
Then re-register from a clean slate. The form preserves the org's display-name default.
Failure modes
| What you see | Cause | Fix |
|---|---|---|
Email status pending_dns for >1h | DNS hasn't propagated | dig the records to confirm; the cron retries every 15 min |
Email status failed | Brevo rejected validation | Check DNS values; click Verify email to retry |
| Storefront URL opens the apex marketing landing page | The orgDomain row's storefrontStatus is pending_dns, so biz's middleware doesn't rewrite the host. Happens when either auto-DNS or the project-attach failed at register. | Check the post-register flash banner — it surfaces the actual reason (auto-DNS skipped / VERCEL_PROJECT_ID missing / project lives in a different team). Fix the underlying cause and Remove + re-register. |
| Storefront URL returns "Deployment not found" | Hub couldn't attach the domain to biz's Vercel project | Confirm VERCEL_PROJECT_ID is set correctly on biz. The post-register flash banner surfaces the actual reason (project not found, in use elsewhere, etc.); if it points to a cross-team setup that needs platform-side action, raise it with kuploy.app support. Click Remove then re-register after fixing. |
| Storefront URL returns cert error | Cert still provisioning | Wait 1–2 min — Vercel issues Let's Encrypt automatically |
Brevo-DNS cleanup partial: removed 0, errors 1 warning on Remove | Vercel DNS-records LIST or DELETE returned non-OK | Hub now logs the actual response ([removeBrevoDnsFromVercel] LIST <zone> failed: <status> <body>). Common causes: the parent zone is no longer in this Vercel team, or the API token's scope changed. Records can be cleaned manually from the Vercel zone editor. |
| "Couldn't reach the licensing hub" red banner | Hub unreachable | Check kuploy.app status |
| Domain tab missing in Settings | Org's plan + free-zone combo gates everything off | Edit plan in Admin → Plans, or set BRANDED_EMAIL_FREE_ZONE on the tenant |
What this doesn't do (yet)
- Multi-domain per org — one domain per org for v1.
- Path 1 auto-DNS via registrar API — for domains bought through the platform. Today every domain is BYO-only.
- Inbound mail / per-org mailboxes — that's Email Hosting, a separate Stalwart-based feature.
- Per-org reputation isolation — all branded mail shares the master Brevo account's IP reputation.