Operators configure custom domains in the Owner Dashboard. This page is the developer-side reality check: how a request resolves to a workspace, which headers actually matter, and what changes (and doesn't) for your code when a workspace goes white-labeled.
How a request resolves to a workspace
There's no Host-header magic on /graphql. The auth middleware (backend/crates/monolith/src/unified_schema/auth.rs:97-101) strips any client-provided x-tenant-id, x-account-id, x-session-id, x-role, x-partnership-id, and x-elevation-jti before the handler runs. The workspace is then re-asserted from one of three sources, in this order:
| # | Mechanism | What sets x-tenant-id |
|---|---|---|
| 1 | Authorization: Bearer <jwt> | Decoded tid claim on the access token (auth.rs:265). |
| 2 | X-Api-Key: btk_... | Workspace of the API key, after find_by_hash confirms it's valid and unrevoked (auth.rs:215-222). |
| 3 | X-Api-Key: bpk_... (public client key) | Workspace on the row returned by find_by_public_client_key, gated on tenant.is_active() (auth.rs:179-187). |
Requests with none of those pass through with no workspace context — fine for genuinely public operations (login, branding lookup, service discovery), rejected by anything that requires it.
Where Host does matter
The Host header is used — but only by a small set of endpoints that have to resolve workspace before any auth can happen:
| Endpoint | Purpose | Source |
|---|---|---|
GET /api/branding | Resolves workspace from Host, returns a flat JSON splash/SEO payload | unified_schema/branding_api.rs:160 |
GET /.well-known/apple-app-site-association | Apple iOS Universal Links + passkey associations | unified_schema/well_known_api.rs |
GET /.well-known/assetlinks.json | Android App Links + Credential Manager passkeys | unified_schema/well_known_api.rs |
GET /.well-known/webauthn | WebAuthn "related origins" file | unified_schema/well_known_api.rs |
Identity flows that pass the request host: startEmailLogin, verifyOtp, startPasskeyAuthentication, password reset, etc. | Used to find the workspace a passwordless email/passkey ceremony belongs to (identity/application/src/.../*.rs — see find_by_domain call sites) |
A custom domain reaches BetterSuite's edge under its own name, so these endpoints see it as Host. The host is matched against the tenant_domains table via find_by_domain(host), and unverified rows are treated as not-found.
System subdomains, not auto-discovery
When a workspace is provisioned, the backend creates one row per app in tenant_domains from the workspace slug plus a per-app suffix (provision_system_domains.rs):
| App | URL pattern (slug = acme) |
|---|---|
TAXI_PASSENGER | acme-rides.bettersuite.io |
TAXI_DRIVER | acme-drive.bettersuite.io |
SHOP_CUSTOMER | acme-shop.bettersuite.io |
SHOP_VENDOR | acme-vendor.bettersuite.io |
PARKING_APP | acme-parking.bettersuite.io |
SERVICE_CUSTOMER | acme-service.bettersuite.io |
SERVICE_PROVIDER | acme-provider.bettersuite.io |
ADMIN_CONSOLE | acme-admin.bettersuite.io |
ADMIN_CONSOLE is not a separate client. That host serves the same web console as bettersuite.io/dashboard, under the workspace's own name and limited to that workspace.
Custom domains layer on top of these (one custom per app, max). A custom domain row points at the same tenant_id + app as its system sibling — that's how /api/branding figures out which app to render when a request lands on rides.acme.com.
TLS — where it terminates
Everything terminates TLS at BetterSuite's edge, in two ways:
- System subdomains (
*.bettersuite.io) are covered by one wildcard certificate. Nothing is issued per workspace. - Custom domains get a certificate of their own, issued and renewed automatically once the domain's CNAME points at
domains.bettersuite.io. The workspace runs no proxy and holds no certificate.
If the workspace's domain publishes a restrictive CAA record, issuance fails until it allows it for the subdomain being pointed at us.
Verification
A custom domain is added first and goes live afterwards. addCustomDomain registers the hostname and returns it unverified, along with the CNAME target (cnameTarget) and where it stands in words (message). verifyCustomDomain asks whether the hostname has been validated and its certificate issued; when both are true it flips dns_verified = true, and otherwise returns what it is still waiting on.
Until a domain is verified, every host-resolved endpoint above treats it as not-found, and the edge does not route it. customDomainSettings reports the CNAME target without adding anything, for a form that needs to show it up front.
Self-hosted deployments that have no edge provider configured report managedCertificate: false. There a custom domain points at its app's system subdomain and is fronted by the operator's own TLS proxy, which must also forward the three /.well-known/ paths.
Effect on server-to-server callers
If your server calls https://api.bettersuite.io/graphql with X-Api-Key, nothing changes when a workspace adds a custom domain — the API is always served from api.bettersuite.io, never relocated. The API key still resolves to the same workspace.
There is no per-domain API endpoint to opt into. The fields removed from the previous version of this article (api.acme.com as a "Pro+" pattern, cross-domain JWT claims) don't exist in the code.
Effect on client-side SDK setup
The Flutter SDK (frontend/packages/sdk/lib/clients/graphql_client_factory.rs:100-118) does not auto-discover the workspace from the hostname. It sends a fixed set of headers per request:
x-app— which app this binary is (taxi_passenger,taxi_driver,shop_customer, etc.) — passed in viaBetterSuiteConfig.clientApp.x-role,x-platform— pulled from the same config.X-Api-Key— set if the app was configured with one (typically the workspace's public client key,bpk_...).Authorization: Bearer <jwt>— once the user logs in.
The workspace is resolved server-side from those credentials — JWT first, then X-Api-Key. A web build can also receive the public client key from the proxy via window.__TENANT_CONFIG__ (frontend/packages/sdk/lib/utils/web_tenant_config_web.dart), but it's still the key that identifies the workspace, not the hostname the page is loaded from.
If you're building your own client (web app, server worker, custom mobile client), do the same: ship a key, not a hostname-discovery routine.
Webhooks
Webhook endpoints (PSP, KYC, store provider) are served from api.bettersuite.io, not a custom domain — they were never moved when custom domains shipped, and the URL is stable. The earlier version of this article suggested webhooks "get routed through the custom domain when configured"; that's not what the code does and you don't need to reconnect anything when a workspace adds a custom domain.
What's next
- Branding Tokens —
/api/brandingis what's exposed at the edge for early page render. - Operator-facing Custom Domains guide — the DNS record, the pending states, and common issues.
- API Keys — the
btk_/bpk_distinction.