Tenant branding is exposed two ways: a structured GraphQL query (branding) and a flat REST endpoint (GET /api/branding) the proxy uses to render an HTML splash before the JS engine boots. This page covers both and walks through the actual shape of the data — preset themes, generated palettes, and an SVG-keyed LogoSet — not the simplified "primary + hover + active" model an earlier draft of this article invented.
Two endpoints, two purposes
| Endpoint | When to use it | Auth | Source |
|---|---|---|---|
query branding (GraphQL) | Full tree — every color, logo, every per-app override. Needed by client apps that render the tenant's UI. | Any caller in the tenant's context (JWT, btk_, or bpk_). | branding: BrandingConfig on Query — schema.graphql:27629 |
GET /api/branding (REST) | Flat payload for HTML <head> injection — splash color, OG/Twitter meta, favicon, public client key. | None — tenant is resolved from Host. | unified_schema/branding_api.rs:155 |
If you're inside an authenticated SDK, use the GraphQL query. If you're a reverse proxy stamping <meta> tags into an HTML shell, use the REST endpoint.
The GraphQL shape
The schema models branding as BrandingConfig (schema.graphql:4361) with three levels — mirroring how operators edit it:
company: CompanyBrand!— company name, tagline, social links, legal URLs, a fallbackLogoSet.verticals: [VerticalBrandEntry!]!— one entry per vertical the tenant enabled (TAXI,SHOP,PARKING,SERVICE). Each holds a theme choice, resolved palette, typography, corner radius, and per-app overrides.version: Int!— monotonic, bumped whenever branding is published.createdAt: DateTime!— when this revision was created.
A minimal query the website itself runs (full version: website/lib/graphql/documents/branding.graphql):
query Branding {
branding {
version
company {
companyName
tagline
logo { primary { id downloadUrl } iconDark { id downloadUrl } }
privacyPolicyUrl
termsUrl
}
verticals {
vertical
brand {
theme { preset custom { primary secondary tertiary } }
colors { ...FullColorPalette }
lightPalette { ...FullColorPalette }
darkPalette { ...FullColorPalette }
typography { headingFont bodyFont }
radiusTheme
apps {
app
brand {
name
tagline
logo { primary { id downloadUrl } }
supportEmail
}
}
}
}
}
}
The theme model
ThemeChoice (schema.graphql:36737) is either a preset or a custom seed:
type ThemeChoice {
preset: BrandTheme # one of nine
custom: CustomSeed # primary + optional secondary + tertiary
}
The nine presets (schema.graphql:4322):
| Enum value | Notes |
|---|---|
COBALT | Default — professional blue |
CORAL_RED | Warm, energetic |
EARTHY_GREEN | Eco / wellness |
SUNBURST_YELLOW | Bright |
HYPER_PINK | Bold, modern |
ELECTRIC_INDIGO | Tech-forward |
AUTUMN_ORANGE | Friendly |
NOIR | Minimal dark |
OCEAN_TEAL | Calm |
There are not three theme tokens (primary / primaryHover / primaryActive). The full palette is ColorPalette (schema.graphql:5947) — a Material 3-style set with primary, primaryBold, primaryDisabled, primaryVariant, primaryVariantLow, onPrimary, primaryContainer, and the parallel secondary / tertiary / surface / outline / success / warning / error / info / insight families, plus fixed fixedLight / fixedDark for things that should never invert. The palette is generated from the preset or seed and stored on the vertical brand alongside the theme choice.
Corner radius
radiusTheme: RadiusTheme! (schema.graphql:29636) — one of:
ROUNDED— default, 8 / 10 / 12 pxSHARP— 2 / 4 / 6 px, geometricPILL— 16 / 20 / 24 px, playful
Apply to your buttons / cards / chips at whichever scale you've named your radius tokens.
Typography
Typography { headingFont, bodyFont } (schema.graphql:37650). Both are nullable strings — the Google Fonts family name (e.g. "Poppins", "Inter"). The Flutter design system loads them at runtime via google_fonts; for web, treat them as the font-family value and load the matching @import from fonts.googleapis.com.
Logos
LogoSet (schema.graphql:16399) has nine slots, all but primary nullable:
| Slot | Use |
|---|---|
primary | Required. The default logo. |
primaryDark | Variant for dark backgrounds. |
icon / iconDark | Square icon (app icon, favicon). |
monochrome | Single-color version for stamping. |
wordmarkHorizontal / wordmarkHorizontalDark | Landscape text logo. |
wordmarkVertical / wordmarkVerticalDark | Stacked/portrait text logo. |
Each slot is an Upload! — query { id, downloadUrl } to get a stable CDN URL.
Logos resolve in a fallback chain: per-app LogoSet → per-vertical → company.logo. Most consumer apps use wordmarkVerticalDark on their splash and fall back to primary / wordmarkVertical (see branding_api.rs:443-447).
The REST splash endpoint
GET /api/branding
Host: rides.acme.com
Returns a flat JSON payload (branding_api.rs:47 — BrandingResponse):
{
"appName": "AcmeRides",
"companyName": "Acme Transportation Inc.",
"tagline": "Get there faster",
"publicClientKey": "bpk_acme_abc123…",
"app": "TAXI_PASSENGER",
"primaryColor": "#0253E8",
"onPrimaryColor": "#FFFFFF",
"primaryColorDark": "#5B8DEF",
"onPrimaryColorDark": "#000000",
"faviconUrl": "https://.../favicon.png",
"splashLogoUrl": "https://.../splash.png",
"splashLogoDarkUrl": "https://.../splash_dark.png",
"ogUrl": "https://rides.acme.com/",
"ogSiteName": "Acme Transportation Inc.",
"ogLocale": "en_US",
"ogType": "website",
"ogImage": "https://.../splash.png",
"ogImageWidth": 1200,
"ogImageHeight": 630,
"twitterCard": "summary_large_image",
"twitterSite": "@acme",
"twitterCreator": "@acme",
"twitterImageAlt": "AcmeRides — Get there faster",
"twitterImage": "https://.../splash.png",
"twitterImageWidth": 1200,
"twitterImageHeight": 630,
"googleMapsApiKey": "AIza…"
}
The endpoint resolves the tenant from Host, looks up the matching tenant_domains row, and chooses the active app from it. If the host doesn't resolve, it returns a default-branding payload with the host echoed back and cache-control: no-cache so nothing gets stuck. Successful responses ship with:
cache-control: public, max-age=300, stale-while-revalidate=60
(branding_api.rs:380). 5 minutes fresh, 1 minute stale-while-revalidate — not the 5-min / 1-hour split an earlier version of this page advertised.
There is no brandingUpdates subscription. The Root Subscription type has nothing for branding (schema.graphql:34596 lists driverEvents, passengerEvents, shopCustomerEvents, vendorEvents, parkingUserEvents, adminEvents — no branding). If you need to detect a change, re-query branding { version } on a schedule (cheap — the response is small and cache-friendly) and compare versions.
Version & rollback
BrandingConfig.version bumps every time an operator publishes a change via updateBranding, updateCompanyBrand, setVerticalBrand, or setVerticalAppBrand — all of which return the new BrandingConfig. Revisions are kept; you can pull a specific one with brandingVersion(version: Int!) or list the history with brandingRevisions: [BrandingConfig!]!. The rollbackBranding(version: Int!) mutation re-applies an old revision as the new current.
Applying in web
Pull once, write to CSS variables on the document root, then style by variable:
const { branding } = await getBranding(token);
const v = branding.verticals.find(e => e.vertical === "TAXI")?.brand;
if (v) {
const root = document.documentElement;
root.style.setProperty("--color-primary", v.colors.primary);
root.style.setProperty("--color-on-primary", v.colors.onPrimary);
root.style.setProperty("--color-surface", v.colors.surface);
root.style.setProperty("--font-heading", v.typography.headingFont ?? "Inter");
root.style.setProperty("--font-body", v.typography.bodyFont ?? "Inter");
}
Applying in Flutter
The internal Flutter SDK loads typography via google_fonts and ships the palette to the better_design_system ThemeData extension automatically — feature code reads tokens like context.colors.primary without touching the GraphQL response directly. If you're embedding inside a third-party Flutter app, mirror the same: pull the vertical brand for the app you're rendering, build a ColorScheme from light / darkPalette, and pass it through your MaterialApp.
Multi-brand: a clarifying note
Operators can configure per-vertical branding (every vertical has its own theme, typography, radius) and per-app overrides (apps: [VerticalAppBrandEntry!]! inside each vertical, overriding name, tagline, logo, support email). There is no separate "brand" entity the previous version of this page invented (brand(id: BrandId!)). All branding hangs off the single BrandingConfig returned by the branding query, scoped to the current tenant.
If two tenants need entirely separate brands, that's two tenants — not two brands on one tenant.
What's next
- Operator-facing Branding guide — what the dashboard looks like, the preset table, the per-vertical/per-app form.
- Custom Domains —
/api/brandingresolves through the sameHost→tenant_domainschain. - Quickstart — the smallest possible GraphQL call to confirm your key works.