Operators configure custom domains in the Owner Dashboard. This page is the developer-side reality check: how a request resolves to a tenant, which headers actually matter, and what changes (and doesn't) for your code when a tenant goes white-labeled.
How a request resolves to a tenant
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. Tenant 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_... | Tenant 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) | Tenant 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 tenant context — fine for genuinely public operations (login, branding lookup, tenant 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 tenant before any auth can happen:
| Endpoint | Purpose | Source |
|---|---|---|
GET /api/branding | Resolves tenant 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 tenant a passwordless email/passkey ceremony belongs to (identity/application/src/.../*.rs — see find_by_domain call sites) |
The well-known handlers prefer X-Forwarded-Host over Host, then strip a trailing port (well_known_api.rs:41-52). The branding handler uses Host directly (branding_api.rs:160). Either way, 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 tenant is provisioned, the backend creates one row per app in tenant_domains from the tenant 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 |
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
System subdomains terminate TLS at BetterSuite's edge. A custom domain CNAMEs to its system subdomain, so it inherits the edge cert — there's no per-domain Let's Encrypt issuance step in the verification flow. Earlier drafts of this page claimed "we issue Let's Encrypt automatically"; that's not what the verification mutation does. (Source: the verification mutation runs lookup_host + three well-known probes only — well_known_verifier.rs.)
If your tenant's apex has a restrictive CAA record, lift it for the subdomain they're delegating, same as on any CDN-fronted setup.
Verification, in two steps
When a tenant submits a custom domain, the verify mutation runs two checks (covered in detail in the operator guide):
- DNS —
lookup_hostagainst the domain on port 443. Fails immediately if it doesn't resolve. - Well-known proxy probe — three parallel GETs with a 5-second timeout each (
well_known_verifier.rs:71), to/.well-known/apple-app-site-association,/.well-known/assetlinks.json,/.well-known/webauthn. Each must return 2xx,application/json, and a body starting with{or[.
Both must pass for the domain to flip dns_verified = true. Until it does, the well-known handlers ignore the row entirely (well_known_api.rs:72 — "Domain found but DNS not verified" → returns None).
Effect on server-to-server callers
If your server calls https://api.bettersuite.io/graphql with X-Api-Key, nothing changes when a tenant adds a custom domain — the API is always served from api.bettersuite.io, never relocated. The API key still resolves to the same tenant.
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 tenant from the hostname. It sends a fixed set of headers per request:
x-app— which app this binary is (taxi_passenger,taxi_driver,admin_console, 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 tenant's public client key,bpk_...).Authorization: Bearer <jwt>— once the user logs in.
The tenant 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 tenant, 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 tenant 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 — DNS setup, the nginx snippet, the well-known proxy probe in detail.
- API Keys — the
btk_/bpk_distinction.