ADR-014: White-Label Strategy & Bring Your Own API Keys (BYOAK)

Status: Proposed Owner: @bilal @deen Date: 2026-02-10 Updated: 2026-02-23

Context

As Envo scales, larger property management companies will expect customisation — from basic branding to fully white-labelled instances. This ADR addresses:

  1. BYOAK — Allowing premium organisations to use their own Twilio, Resend, LLM, and other service accounts
  2. White-Label Tiers — Spectrum of customisation available at each plan level
  3. Custom Domains — Partner-tier orgs serving the dashboard from their own domain
  4. SSO/SAML — Enterprise authentication for Partner-tier orgs

Current State

  • All integrations use shared Envo credentials via env vars
  • No per-org branding, theming, or styling
  • organisations.plan enum exists (basic, premium, partner) but isn’t used for feature gating
  • Provider abstractions exist (LLM registry, tenant intake adapters)
  • Multi-tenant RLS isolation is solid (ADR-001 Multi-Tenancy Access Model)
  • Hosting on Cloudflare Workers via @opennextjs/cloudflare
  • Auth: Cloudflare Access (ZTNA, email OTP) as primary gate + Supabase Auth (SSR, magic links) as application auth
  • Email provider: Resend (replacing SendGrid — see Costs & Usage)

Decision

1. Three-Tier White-Label Model

CapabilityBasicPremiumPartner
Custom logo + colour paletteYesYes
Custom email templatesYesYes
Own Resend / LLM API keysYesYes
Own Twilio / VAPI accountYes
Custom domain (dashboard + tenant)Yes
Remove “Powered by Envo”Yes
SSO / SAMLYes (on demand)

2. BYOAK Credential Architecture

Credential Resolution Order

1. Check integration_credentials for org-specific key
2. If found and verified → use org credentials
3. If not found → fall back to Envo platform defaults (env vars)

This resolution happens at the service layer, not at the API/GraphQL layer. Every external service call (LLM, email, SMS, voice) passes through a credential resolver that encapsulates this logic.

Credential Resolver Design

// Pseudocode — the credential resolver is a server-side utility
interface ResolvedCredentials {
  provider: string          // e.g. 'resend', 'anthropic', 'twilio'
  credentials: Record<string, string>  // API keys, account SIDs, etc.
  source: 'org' | 'platform'
}
 
async function resolveCredentials(
  organisationId: string,
  service: ServiceType  // 'email' | 'sms' | 'whatsapp' | 'voice' | 'llm'
): Promise<ResolvedCredentials> {
  // 1. Check org-specific credentials from integration_credentials
  const orgCreds = await getOrgCredentials(organisationId, service)
 
  if (orgCreds && orgCreds.verificationStatus === 'verified') {
    return {
      provider: orgCreds.provider,
      credentials: await decryptFromVault(orgCreds.vaultSecretId),
      source: 'org'
    }
  }
 
  // 2. Fall back to platform defaults
  return {
    provider: getPlatformProvider(service),
    credentials: getPlatformCredentials(service),
    source: 'platform'
  }
}

SSO Configuration as Credentials

SSO/SAML configuration follows the same pattern — it’s stored in integration_credentials with service = 'sso'. The credential resolver returns the IdP metadata, entity ID, and ACS URL for the org. This means:

  • SSO config is encrypted at rest via Vault (same as API keys)
  • The same settings UI handles both BYOAK API keys and SSO configuration
  • SSO can be enabled/disabled per-org without code changes

SSO credential fields:

FieldDescription
idp_metadata_urlIdentity provider metadata endpoint
idp_entity_idIdP entity identifier
sp_acs_urlService provider assertion consumer URL (auto-generated per org)
saml_certificateIdP signing certificate (PEM)
attribute_mappingJSON mapping of SAML attributes to Envo user fields

Database Tables

integration_credentials — Per-org encrypted credentials

CREATE TABLE integration_credentials (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organisation_id UUID NOT NULL REFERENCES organisations(id),
    service integration_service NOT NULL,
    -- enum: email, sms, whatsapp, voice, llm, sso
    provider TEXT NOT NULL,
    -- e.g. 'resend', 'anthropic', 'twilio', 'okta', 'azure_ad'
    vault_secret_id UUID NOT NULL,
    -- Reference to Supabase Vault secret (pgsodium)
    verification_status credential_status NOT NULL DEFAULT 'pending',
    -- enum: pending, verifying, verified, failed, revoked
    last_verified_at TIMESTAMPTZ,
    error_message TEXT,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(organisation_id, service)
);

integration_settings — Provider selection per org

CREATE TABLE integration_settings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organisation_id UUID NOT NULL REFERENCES organisations(id) UNIQUE,
    email_provider TEXT DEFAULT 'platform',     -- 'platform' | 'resend' | etc.
    sms_provider TEXT DEFAULT 'platform',       -- 'platform' | 'twilio'
    whatsapp_provider TEXT DEFAULT 'platform',  -- 'platform' | 'twilio' | 'meta'
    voice_provider TEXT DEFAULT 'platform',     -- 'platform' | 'vapi' | 'retell'
    llm_provider TEXT DEFAULT 'platform',       -- 'platform' | 'anthropic' | 'openai'
    auth_provider TEXT DEFAULT 'platform',      -- 'platform' | 'saml' | 'oidc'
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

organisation_branding — Visual customisation and domain config

CREATE TABLE organisation_branding (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organisation_id UUID NOT NULL REFERENCES organisations(id) UNIQUE,
    logo_url TEXT,
    logo_dark_url TEXT,         -- For dark theme
    primary_colour TEXT,        -- Hex, maps to ShadCN --primary
    accent_colour TEXT,         -- Hex, maps to ShadCN --accent
    background_colour TEXT,     -- Hex
    email_from_name TEXT,       -- e.g. "BigLandlord Support"
    email_reply_to TEXT,
    sms_sender_id TEXT,         -- Alphanumeric sender ID (UK supports this)
    show_powered_by BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

organisation_domains — Custom domain mapping (see Section 4)

CREATE TABLE organisation_domains (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organisation_id UUID NOT NULL REFERENCES organisations(id),
    domain TEXT NOT NULL UNIQUE,
    -- e.g. 'portal.biglandlord.co.uk'
    cf_custom_hostname_id TEXT,
    -- Cloudflare Custom Hostname ID (from API response)
    verification_status domain_status NOT NULL DEFAULT 'pending',
    -- enum: pending, dns_verified, ssl_provisioning, active, failed
    ssl_status TEXT,
    -- From Cloudflare: pending_validation, active, etc.
    is_primary BOOLEAN DEFAULT false,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

Security

  • All API keys and SSO certificates stored via Supabase Vault (pgsodium). Application code never handles raw encryption — Vault’s vault.create_secret() and vault.decrypted_secrets view handle all crypto.
  • 5-minute in-memory cache for resolved credentials, invalidated via Supabase Realtime subscription on integration_credentials changes.
  • Credential verification runs on save — e.g. Resend: send a test API call; Twilio: verify account SID; SAML: fetch and parse IdP metadata.
  • RLS policies ensure orgs can only read/write their own credentials.

3. Webhook Routing

BYOAK orgs (own Twilio/VAPI account):

POST /api/webhooks/whatsapp?org={organisation_id}
POST /api/webhooks/voice?org={organisation_id}

The webhook handler:

  1. Extracts org from query parameter
  2. Loads the org’s webhook signing secret from integration_credentials
  3. Validates the webhook signature against the org’s secret (not Envo’s)
  4. Processes the message in the org’s context

BYOAK orgs configure their Twilio/VAPI webhook URLs to include ?org={id} during onboarding.

Shared account orgs (using Envo’s Twilio):

POST /api/webhooks/whatsapp

Phone number lookup → resolve to organisation via the tenants or properties table.

4. Custom Domain Architecture (Partner Tier)

Mechanism: Cloudflare for SaaS (Custom Hostnames)

Cloudflare for SaaS allows customer-owned domains to route through Envo’s Cloudflare zone to the same Worker. This is how Vercel, Shopify, and every multi-tenant SaaS handles custom domains.

How it works:

portal.biglandlord.co.uk  →  CNAME customers.ehq.tech  →  Worker (envo-dashboard)
  1. Envo configures a fallback origin on the ehq.tech zone: customers.ehq.tech AAAA 100:: (proxied)
  2. A Worker route */* on the zone catches all traffic, including from custom hostnames
  3. Customer adds a CNAME: portal.biglandlord.co.uk → customers.ehq.tech
  4. Cloudflare auto-provisions TLS via Domain Control Validation (DCV)
  5. The Worker receives the request with Host: portal.biglandlord.co.uk

The Worker sees the original hostname. new URL(request.url).hostname returns portal.biglandlord.co.uk, not app.ehq.tech.

Pricing

ItemCost
First 100 custom hostnamesFree (included on all CF plans, including Free)
Additional hostnames$0.10/month each
Custom Metadata (org context on hostname)Enterprise only — not needed (use DB lookup)
Apex domain support (e.g. biglandlord.co.uk without subdomain)Enterprise only — customers must use subdomains

At Envo’s scale: We’ll have <50 Partner orgs for the foreseeable future. Custom domains cost $0.

DNS Verification Flow

1. Partner org enters desired domain in Settings UI
   (e.g. "portal.biglandlord.co.uk")

2. Envo backend calls Cloudflare API:
   POST /zones/{zone_id}/custom_hostnames
   { "hostname": "portal.biglandlord.co.uk", "ssl": { "method": "http", "type": "dv" } }

3. UI shows the customer:
   "Add this CNAME record to your DNS:
    portal.biglandlord.co.uk  →  customers.ehq.tech"

4. Cloudflare polls for DNS propagation (first 10 checks in ~20 mins)
   - Certificate validation: auto-validates when CNAME resolves
   - Hostname validation: same mechanism

5. Once both pass: domain status → 'active'
   SSL auto-issued (ECDSA + RSA fallback)

6. Envo adds the domain to Supabase Auth redirect URL allow list
   via Supabase Management API:
   https://{domain}/**

7. Domain is live — middleware resolves Host header to org

Validation window: 75 retries over 7 days. If it fails, the custom hostname is deleted and the org must retry.

Middleware Host Resolution

// In middleware.ts — runs on every request
const hostname = new URL(request.url).hostname
 
if (hostname === 'app.ehq.tech') {
  // Standard path: CF Access + Supabase Auth
  return handleStandardAuth(request)
}
 
// Custom domain path: look up org from hostname
const org = await resolveOrgFromHostname(hostname)
// Uses organisation_domains table, cached in KV or in-memory (5 min TTL)
 
if (!org) {
  return new Response('Unknown domain', { status: 404 })
}
 
// Inject org context into request headers for downstream use
request.headers.set('x-envo-org-id', org.id)
request.headers.set('x-envo-org-plan', org.plan)
 
// Custom domain auth: Supabase Auth only (no CF Access)
return handleSupabaseAuth(request)

Caching: Hostname → org lookups are cached in Cloudflare Workers KV (or in-memory with 5-minute TTL). The organisation_domains table is small (<100 rows) and rarely changes.

Limitations

LimitationImpactMitigation
Apex domains not supported (Free/Pro)Customers can’t use biglandlord.co.uk, must use portal.biglandlord.co.ukDocument in onboarding. Subdomains are standard SaaS practice.
Custom Metadata is Enterprise-onlyCan’t attach org_id to the hostname in CFDB/KV lookup on every request (negligible latency with caching)
Wildcard hostnames are Enterprise-onlyCan’t do *.biglandlord.co.ukNot needed — one hostname per org is sufficient
Customer can’t use another CDN simultaneouslyBlocks hostname validationDocument: customer must remove existing CDN proxy before pointing CNAME
Max 50,000 hostnamesNot a concern at any realistic scale

5. Auth Model for Custom Domains

Custom domains introduce a dual auth model:

┌────────────────────────────┐    ┌────────────────────────────────┐
│     app.ehq.tech           │    │   portal.biglandlord.co.uk     │
│                            │    │                                │
│  Layer 1: CF Access (ZTNA) │    │  No CF Access gate             │
│  Layer 2: Supabase Auth    │    │  Layer 1: Supabase Auth only   │
│                            │    │                                │
│  For: Envo team, Basic,    │    │  For: Partner-tier orgs        │
│  Premium orgs              │    │                                │
└────────────────────────────┘    └────────────────────────────────┘

Why this is acceptable:

  • Supabase Auth is a production-grade auth system used by thousands of apps as the sole auth layer. CF Access was added as defence-in-depth for the early access period, not as a permanent requirement.
  • Partner orgs are paying customers with their own security requirements — many will want SSO, which replaces CF Access entirely.
  • The dashboard still requires Supabase Auth login regardless of domain.

When a user on portal.biglandlord.co.uk clicks “Sign in”:

  1. User enters email → Supabase sends magic link
  2. Magic link points to portal.biglandlord.co.uk/callback
  3. Callback exchanges auth code for session (PKCE flow via @supabase/ssr)
  4. Session stored in cookies on portal.biglandlord.co.uk

Requirement: Each custom domain must be in Supabase Auth’s redirect URL allow list. This is automated — when a domain passes DNS verification (step 6 in the flow above), the backend calls the Supabase Management API to add https://{domain}/** to the redirect URL list.

SSO/SAML (On-Demand for Partner Tier)

SSO is only built when a Partner customer pays for it. Two implementation paths:

PathMechanismCostWhen
A: Cloudflare Access as SSO proxyCF Access supports SAML/OIDC IdPs. Configure per-org Access Application on the custom domain.$0 (included in CF Access free tier for <50 users; Access paid plans for more)Default recommendation
B: Native Supabase SAMLSupabase Auth supports SAML on Team plan ($599/mo) or Enterprise$599/mo plan jumpOnly if CF Access proxy is insufficient

Recommendation: Path A (CF Access as SSO proxy). This avoids the $599/mo Supabase plan jump. The Partner org’s custom domain gets its own CF Access Application configured with their SAML IdP. CF Access handles the SSO flow, then the user lands on the app with a CF Access cookie. The app’s Supabase Auth session is created via a service-role call after CF Access validation.

If a customer specifically needs native SAML (e.g. their IdP can’t integrate with CF Access), Path B is the fallback — but the cost must be reflected in their contract.

6. Partner-Tier Settings UI

Partner-tier orgs need a self-service UI for managing their BYOAK credentials, branding, custom domains, and SSO. This lives in the existing Settings page as additional tabs/sections, gated by organisation.plan.

Settings Sections by Plan

SectionBasicPremiumPartner
Organisation (name, billing email)YesYesYes
Profile (user details)YesYesYes
Integrations (BYOAK credentials)YesYes
Branding (logo, colours, templates)YesYes
Custom Domain (domain setup, DNS verification)Yes
SSO (SAML/OIDC configuration)Yes

Integrations Tab (Premium + Partner)

For each service (Email, LLM, SMS, WhatsApp, Voice):

  1. Provider selector — dropdown: “Envo (default)” or specific provider
  2. Credentials form — masked input fields for API keys/secrets
  3. Verify button — tests the credentials against the provider’s API
  4. Status badge — Verified (green), Pending (yellow), Failed (red)
  5. Webhook URL display — for services that need webhooks (Twilio, VAPI), show the org-specific webhook URL with ?org={id}

Custom Domain Tab (Partner)

  1. Domain input — text field for the custom domain
  2. DNS instructions — CNAME record to add (shown after submission)
  3. Verification status — live polling for DNS propagation + SSL provisioning
  4. Active indicator — green badge when domain is live

SSO Tab (Partner)

  1. IdP metadata URL — input for the SAML metadata endpoint
  2. Manual config — entity ID, SSO URL, certificate upload (fallback if no metadata URL)
  3. Attribute mapping — configure how SAML attributes map to Envo user fields (email, name, role)
  4. Test SSO — initiates a test SAML flow
  5. Status badge — Configured / Not configured
  6. Breakglass — org owner can disable SSO and revert to magic links if IdP goes down

7. White-Label Theming

CSS custom properties injected at the layout level, mapping directly to ShadCN/ui theme variables:

// In the root layout, after resolving the org
const branding = await getOrgBranding(organisationId)
 
const themeVars = branding ? {
  '--primary': branding.primaryColour,
  '--accent': branding.accentColour,
  '--background': branding.backgroundColour,
} : {}
 
// Applied to <html> or <body> style attribute

Logo is loaded from organisation_branding.logo_url (stored in Supabase Storage) and replaces the Envo logo in the sidebar.

“Powered by Envo” footer is conditionally rendered based on show_powered_by.


Implementation Phases

All phases are post-MVP. Only build when there’s a paying customer or a signed contract requiring it.

Phase 1: Foundation (1-2 weeks)

  • DB migration: integration_credentials, integration_settings, organisation_branding tables
  • Supabase Vault integration for credential storage/retrieval
  • Credential resolver service (the core resolveCredentials() function)
  • Feature-gating middleware (check organisation.plan for Premium/Partner features)
  • RLS policies for new tables

Phase 2: Easy BYOAK (1-2 weeks)

  • LLM key swap — credential resolver integrated into the AI pipeline. Org with own Anthropic/OpenAI key uses their key; others use Envo’s.
  • Resend key swap — same pattern for email sending
  • Settings UI: Integrations tab — forms for LLM + email credentials with verification

Phase 3: Branding (1-2 weeks)

  • Theming pipeline — CSS custom properties from organisation_branding, injected at layout
  • Logo upload — Supabase Storage, displayed in sidebar
  • Email branding — custom from name, reply-to, templates
  • “Powered by Envo” toggle
  • Settings UI: Branding tab

Phase 4: Twilio/Voice BYOAK (1-2 weeks)

  • Webhook routing?org={id} parameter on webhook endpoints, per-org secret validation
  • SMS sender ID — org-specific alphanumeric sender (UK supports this)
  • WhatsApp BYOAK — org brings own Meta Business account or Twilio WhatsApp number
  • VAPI BYOAK — org’s own VAPI account, webhook routing
  • Settings UI: extended Integrations tab for SMS, WhatsApp, Voice

Phase 5: Custom Domains (1 week)

  • Cloudflare for SaaS setup — fallback origin, CNAME target on ehq.tech zone
  • organisation_domains table + migration
  • Domain verification flow — CF API integration, DNS polling, SSL provisioning
  • Middleware Host resolution — hostname → org lookup with KV/in-memory cache
  • Supabase redirect URL automation — Management API call on domain verification
  • Settings UI: Custom Domain tab

Phase 6: SSO (On demand)

  • Only when a Partner customer requests and pays for it
  • Preferred: Cloudflare Access as SSO proxy — configure per-org CF Access Application with customer’s SAML IdP
  • Fallback: Native Supabase SAML — requires Supabase Team plan ($599/mo), cost passed to customer
  • Settings UI: SSO tab — IdP metadata configuration, attribute mapping, test flow, breakglass toggle

Cost Impact

Infrastructure Cost of BYOAK/White-Label

FeatureCost to EnvoNotes
Supabase Vault (credential storage)$0Included on Pro plan (pgsodium)
Custom domains via CF for SaaS$0First 100 hostnames free. We’ll have <50 Partner orgs for years.
Supabase Custom Domain add-on$10/moOptional — brands the API endpoint (api.envo.energy instead of *.supabase.co). Not required for white-labelling.
SSO via CF Access$0CF Access free for <50 users. For larger orgs, CF Access paid plans.
SSO via native Supabase SAML$599/moOnly if CF Access path is insufficient. Plan jump from Pro to Team.

Cost Savings from BYOAK

When Premium/Partner orgs bring their own keys, Envo’s variable costs drop:

TierEnvo’s cost per property/moTypical chargeGross margin
Basic (all on Envo)~£0.63£3/property~79%
Premium (BYOAK LLM + Email)~£0.25-0.55£5/property~89-95%
Partner (BYOAK everything)~£0.03-0.10£2+/property (custom)~95%+

At 3,000 properties, if 20% are Partner-tier with BYOAK, Envo saves ~$300/mo in shifted API costs.


Consequences

Positive

  • Revenue differentiation with clear upgrade path (Basic → Premium → Partner)
  • Reduced platform cost at scale (orgs pay own API bills)
  • Enterprise readiness (white-labelling and SSO are table stakes for large operators)
  • High stickiness once credentials, branding, and SSO are configured
  • Custom domains are effectively free ($0 on Cloudflare)
  • SSO can be offered without the $599/mo Supabase plan jump (via CF Access proxy)
  • Credential resolver + SSO config share the same storage pattern (Vault) and UI paradigm

Negative

  • Support complexity increases (debugging customer’s own API keys and SSO config)
  • Onboarding friction (BYOAK setup requires third-party accounts)
  • Credential security responsibility (Envo stores customer keys in Vault)
  • Dual auth model: app.ehq.tech (CF Access + Supabase) vs custom domains (Supabase only) — must be clearly documented and tested
  • Apex domains not supported for custom domains (customers must use subdomains)
  • Each custom domain must be added to Supabase Auth redirect URL allow list (automated, but a moving part)

Risks

RiskLikelihoodImpactMitigation
Customer misconfigures BYOAK keys → messages not sentMediumHigh (missed maintenance reports)Verification on save + monitoring for send failures + auto-fallback to platform credentials with alert
SAML IdP goes down → org users locked outLowHighBreakglass: org owner can disable SSO and revert to magic links via Envo support
Supabase redirect URL list grows unmanageablyLowLowAutomated via Management API. No documented limit. Cleanup on domain removal.
CF for SaaS hostname validation fails (customer DNS misconfigured)MediumLowClear UI instructions, 7-day validation window, retry mechanism