Skip to content

Partner White-Labeling — Development Log

Progress

Task Status Notes
Partner detection (URL-based) ✅ Done detectPartnerFromUrl() in workflows-frontend
Branding config API ✅ Done GET /v1/partners/{id}/config, JSONB column on partners table
Frontend UI white-labeling ✅ Done usePartnerConfig() hook, CSS variable overrides
Auth0 login page ✅ Done Single template, multi-tenant detection via state param
Passwordless email branding ✅ Done Liquid templates with client_metadata
Error notification emails ✅ Done {platform_name} in build_error_notification
SendGrid templates updated ✅ Done 5 templates, new inactive versions created
Write error messages genericized ✅ Done "in our platform" instead of "in Stacksync"
Partner API (CRUD workspaces/users) ✅ Done Service token + API key auth
Partner provisioning gate ✅ Done "Account Not Provisioned" page with partner branding
Workspace filtering (partner mode) ✅ Done /login returns only matching partner workspaces
Hidden UI elements in partner mode ✅ Done No "Create workspace", no pricing section
Activate SendGrid template v6 versions ✅ Done All 5 templates activated via API (2026-04-03)
Pass all branding vars in send_email() ✅ Done resolve_branding_vars() merges logo_url, logo_width, button_color, support_email, frontend_url into SendGrid template data
Set branding.emails for Rillet in DB ✅ Done partners.branding.emails JSONB with all 5 vars set
E2E email notification test (PG→PG) ✅ Done Local Docker Compose with two PG DBs, password change triggers auth error → notification with Rillet branding
Auth0 dev login template — side image ✅ Done Split layout: login form left, branding image right (dev only)
SendGrid template subjects dynamic ✅ Done All 5 templates use {{#if subject}}{{subject}}{{else}}Default{{/if}} — API subject overrides template default
subject in template_data dict ✅ Done subject must be in dynamic_template_data (body dict), not just the API subject param — SendGrid templates read from template data
Test email endpoint (/v1/test/email) ✅ Done Self-documenting endpoint with partner presets, env-gated (blocked in prod)
Email template testing — all 5 types ✅ Done Tested with Rillet + Stacksync fallback: workspace_invite, base_activity, email_change, email_change_confirmation, default
Auth0 login — custom inputs inside widget ✅ Done Proved Auth0 widget DOM is modifiable: injected custom fields, buttons, side images, webhook calls (dev demo only)
Set create_resource_page_header_image for Rillet ✅ Done Value set in DB branding config
Fix Cloudflare Pages routing for dev ✅ Done rillet-test-dev.stacksync-dev.com now serves dev build
Partner details_link in error notifications ✅ Done details_link now uses partner frontend_url (e.g. connect.rillet.com/workspaces/...) in both connectors build_error_notification and fe-logics detect_schema_changes
detect_schema_changes notification branding ✅ Done Third notification path in fe-logics — switch_off_base_due_to_error() now accepts workspace_id and resolves branding vars
GCP prod deployment restored ✅ Done gcp-deploy-prod.yml re-added — notifications scheduler still hits GCP fe-logics, not Azure
Airtable OAuth on partner domains ✅ Done Added postMessage listener to Airtable PKCE handler — fixes cross-origin popup communication for partner domains
Prod E2E email testing — Rillet ✅ Done All notification types verified on prod with Rillet branding: base_activity (connectors), detect_schema_changes (fe-logics), workspace_invite
Workflow module branding (backend-driven) ✅ Done fe-logics replaces names/icons in API responses. Detection: app_type starts with stacksync, or is system/file_service. Frontend reverted to use backend data only.
Apps branding partner replacement ✅ Done apps_branding endpoint accepts workspace_id, returns partner-branded category names/icons
Resource table partner icons ✅ Done Resource table "Apps" column uses appBrandings for icon resolution instead of static constant
appBrandings persisted in Zustand ✅ Done Prevents flash of Stacksync icons on page load
Module search filter uses branded names ✅ Done Frontend searches by app_name (branded) instead of app_type (code name)
get_base_schema.py credential notification 🚧 TODO Old credential validation path sends plain text notification without branding vars
SendGrid verified sender for partner emails 🚧 TODO From address is always hello@stacksync.com. To show hello@rillet.com, add verified sender domain in SendGrid
Auth0 login template resilience ⚠ Risk CSS class selectors may break if Auth0 updates rendering
GCP → Azure migration for notifications ⚠ Risk Notifications scheduler still routes to GCP fe-logics. Once infra team completes migration, remove gcp-deploy-prod.yml

Architecture Decisions

Partner detection: URL-based, not token-based

  • Partner is detected from hostname (connect.rillet.com) or URL path (/partner/rillet)
  • Single source of truth: detectPartnerFromUrl() — synce call that reads window.location
  • localStorage override (__partner_override__) for dev/testing

Branding config: JSONB column on partners table

  • No separate tables — everything in partners.branding JSONB
  • Cached in sessionStorage per tab on the frontend
  • Cached in Redis (1h TTL) on connectors backend

Auth0: One template, multi-tenant detection

  • Single Universal Login template across all tenants (dev/stage/prod)
  • Detects Rillet by checking widget text content for "Rillet" or decoding the state param for known client IDs
  • Client IDs split with string concatenation to avoid self-matching when scanning page HTML

Email white-labeling: SendGrid Handlebars + backend resolution

  • SendGrid templates use {{#if platform_name}}{{platform_name}}{{else}}Stacksync{{/if}} pattern
  • Backend resolves all branding vars from workspace.options.partner.partner_idpartners.branding.emails
  • Connectors: resolve_workspace_partner() in commons/partners.py — single Redis/DB lookup returns platform_name, docs_url, and email_vars dict
  • Frontend-logics: resolve_branding_vars() in functions/partners/branding.py — single JOIN query (workspace → partners)
  • Branding vars merged into notification_data or body dict before sendgrid.send_email() — they become dynamic_template_data in the SendGrid API call
  • Non-partner workspaces return {"platform_name": "Stacksync"} only — missing keys fall through to Handlebars {{else}} defaults in the template

What Was Tried

Auth0 Login Page

  • Attempt 1: Dark theme with CSS class selectors (.c9be907b3, etc.) — fragile, broke across Auth0 versions
  • Attempt 2: Injecting logo div above the card — ended up outside the card, mispositioned
  • Attempt 3: Scanning entire page HTML for client IDs — matched our own <script> tag, made ALL pages look like Rillet
  • Final: Swap Auth0's own #prompt-logo-center src + detect via text content and decoded state param

Passwordless Email

  • Liquid {{application.client_metadata.partner_email_logo_url}} in the email body works
  • From field: Liquid for display name works ({{ application.client_metadata.partner_name }}), but full email address customization needs Custom Email Provider Action
  • SVG logos don't render in email clients — must use PNG

Error Notification Emails

  • Two notification paths discovered: old path (get_base_schema.py credential check) and new path (build_error_notification)
  • Old path sends simple text, no HTML — doesn't mention Stacksync so it's partner-safe
  • New path generates HTML with {platform_name} — requires error threshold to be reached (uses Redis for counting)
  • Testing locally: Redis is explicitly set to None in dev (utils/redis.py line 77-78). Error count functions returned 0, meaning thresholds were never reached. Fixed by returning float('inf') when client is None so error handling still triggers without Redis
  • The pause_base_temporarily_due_to_error flow does NOT create notifications — only switch_off_base_due_to_error does. get_base_paused_temporarily_count_since_last_internal_request_success also returned 0 without Redis, keeping the code in the pause loop forever. Fixed same way (float('inf'))

Full Branding Vars in Emails

  • Initially only platform_name and sender_name were passed to SendGrid templates
  • Refactored to resolve_branding_vars() which returns all 6 vars (platform_name, logo_url, logo_width, frontend_url, support_email, button_color)
  • Branding vars stored in partners.branding.emails JSONB (separate from branding.texts which has documentation_url)
  • Frontend-logics: single DB query via get_workspace_partner_branding() (workspace → partner JOIN)
  • Connectors: single Redis/DB lookup via resolve_workspace_partner() — eliminated 3 separate get_partner_config() calls in error_notification_builder.py
  • Branding vars merged into notification_data dict → becomes dynamic_template_data in SendGrid API call
  • Email change and email change confirmation templates receive branding but don't have workspace context — left as-is for now

Local E2E Testing Setup

  • Docker Compose with two Postgres 16 DBs: source (port 5435) and target (port 5434)
  • Init script creates 4 tables (customers, products, orders, invoices) with sample data on source
  • Connect via host.docker.internal (not localhost) from Docker containers
  • To trigger error: docker exec stacksync_target_db psql -U stacksync -d target -c "ALTER USER stacksync PASSWORD 'wrongpassword';"
  • To restore: same command with 'stacksync'
  • Workspace must have options.partner.partner_id = 'rillet' set
  • Docker Compose file: frontend-logics/docker-compose.test-dbs.yml

SendGrid Templates

  • 5 templates needed updating (base_activity, default, workspace invite, email change × 2)
  • Email split across spans differently per template (hello@stacksync. + com vs hello@stacksync.c + om)
  • Logo width="600" made partner logos gigantic — reduced to width="150"
  • New inactive versions created via API, old versions untouched as fallback
  • Subject line gotcha: SendGrid templates had hardcoded subjects (e.g. Stacksync Workspace Invite). The API subject parameter sets the email envelope subject, but templates can override it with their own subject field. Fixed by changing all template subjects to {{#if subject}}{{subject}}{{else}}Stacksync Default{{/if}}
  • subject must be in dynamic_template_data: The subject passed to sendgrid.send_email() as a parameter is NOT available as {{subject}} in the template. It must also be in the body dict (which becomes dynamic_template_data). Both send_invite() and send_email_notification() now set body["subject"] / notification_data["subject"]
  • From email address: Always hello@stacksync.com regardless of partner. Changing this requires adding partner domains as verified senders in SendGrid

Multiple Notification Paths

Three separate code paths build notifications — all needed branding:

  1. Connectors build_error_notification() (connectors/utils/error_notification_builder.py) — HTML error emails with diagnostics. Fixed with resolve_workspace_partner() + partner_branding["email_vars"]
  2. Fe-logics detect_schema_changes (frontend-logics/endpoints/common/detect_schema_changes/utils.py) — schema change errors. Fixed with resolve_branding_vars(workspace_id). Required adding workspace_id param to switch_off_base_due_to_error()
  3. Connectors get_base_schema.py (_handle_invalid_credentials) — credential validation on base startup. Still sends plain text without branding (TODO)

Each path builds its own notification dict and calls send_notification() independently. The branding vars and details_link must be added in each path separately — the downstream send_email_notification() adds branding vars too, but details_link must be set at the source since it depends on frontend_url.

GCP vs Azure Deployment

  • Fe-logics prod deploys to both Azure (primary) and GCP (legacy)
  • The notifications scheduler (syncs-schedulers) still routes to GCP fe-logics (frontend-logics-prod-besg.stacksync.cloud)
  • The gcp-deploy-prod.yml workflow was deleted in commit 25efb60c ("delete all related to GCP") but had to be restored because notifications were still hitting GCP
  • Until infra completes the full migration, both workflows must stay active
  • To verify where notifications route: check GCP logs for "notifications/process" in frontend-logics-prod-besg

Airtable OAuth on Partner Domains

  • Airtable uses PKCE flow with a custom handler (not the shared createOAuthPopupHandler)
  • The handler only used localStorage polling — no postMessage listener
  • On partner domains (e.g. rillet-test-dev.stacksync-dev.com), the OAuth callback redirects to dev.stacksync.com (different origin), so StorageEvent never fires on the parent
  • Fixed by adding a postMessage listener (matching the shared handler pattern) with 3 channels: postMessage (cross-origin), StorageEvent (same-origin), polling (cancellation)
  • Other OAuth apps (HubSpot, Salesforce, etc.) use the shared handler which already had postMessage — only Airtable was affected

Workflow Module Branding (Backend-Driven)

Partner branding for workflow modules is applied at the API level in fe-logics, not in frontend components. The frontend was reverted to just display whatever the API returns.

How It Works

  1. Module endpoints (/workflows-modules/, /search, /info) call get_partner_branding_for_workspace(workspace_id) — single DB query joining workspaces → partners
  2. If a partner is found, apply_partner_branding_to_modules() modifies the response in-place:
  3. Replaces "Stacksync" in module_name with partner name (e.g. "Stacksync AI" → "Rillet AI")
  4. Sets options.module_icon_url to partner icon for matching modules
  5. Apps branding endpoint (/common/apps-branding/) accepts optional workspace_id query param, applies apply_partner_branding_to_app_brandings() to replace category names/icons

Detection Logic (app_type-based)

Two separate matching sets — modules vs category headers have different rules:

Context Matches Set
Module icons (apply_partner_branding_to_modules) stacksync*, file_service _STACKSYNC_MODULE_APP_TYPES
Category icons (apply_partner_branding_to_app_brandings) stacksync*, system, file_service _STACKSYNC_BRANDING_APP_TYPES

system modules (If Else, Transform Data, etc.) keep their own icons — only the "Standard modules" category header gets the partner icon.

Search Term Translation

When a user searches for "Rillet AI", translate_search_term() converts it to "Stacksync AI" for the DB query, since module names in the DB still contain "Stacksync".

Frontend Changes

  • Reverted frontend-side name/icon replacements in app-list-card.tsx, module-list-card.tsx, default-node-content.tsx
  • Resource table (resource-app-icons-tooltip.tsx): uses appBrandings from the store for icon resolution. Only overrides icons for stacksync*, system, file_service types — third-party apps keep curated icons from appIconUrls constant
  • Resources page (resources/page.tsx): added getAppBrandings() fetch (was missing — store was empty on direct navigation)
  • Zustand persist: appBrandings added to partialize list so partner icons render instantly from localStorage on subsequent visits
  • Search filter (view.tsx): uses app_name from branding (branded) instead of app_type (code name) for search matching
  • appBrandings cache: in-memory cache in get.ts invalidates on workspace change

Key Files

File Purpose
fe-logics/utils/a_utils/partner_branding_modules.py apply_partner_branding_to_modules(), apply_partner_branding_to_app_brandings(), translate_search_term()
fe-logics/db/partners.py get_partner_branding_for_workspace() — single JOIN query
fe-logics/endpoints/workflows/modules/endpoint.py Module list/search/info endpoints with partner branding
fe-logics/endpoints/common/apps_branding/endpoint.py Apps branding with optional workspace_id
wf-fe/src/components/tables/_carbon/resources/resource-app-icons-tooltip.tsx Resource table icon resolution with appBrandings
wf-fe/src/api/app_brandings/get.ts Passes workspace_id, in-memory cache with workspace invalidation

Stacksync Platform App Types (from prod DB)

app_type Display Name Module icon branded? Category icon branded?
stacksync_ai Stacksync AI Yes Yes
stacksync_ocr Stacksync OCR Yes Yes
stacksync_email Stacksync Email Yes Yes
stacksync-enrichment Stacksync Enrichment Yes Yes
file_service File Service Yes Yes
system Standard modules No (sub-modules keep own icons) Yes

Auth0 Setup

Tenants

Tenant Domain
Dev stacksync-dev.eu.auth0.com
Stage stacksync-stage.eu.auth0.com
Prod stacksync.eu.auth0.com

Rillet Auth0 Apps

Tenant Client ID
Dev zGVj1pe2Xy81iVRN6dgziynMLDtsytnt
Stage nqC8kYRi4UE1EmdQvZmNXtA4DKQvwSN4
Prod 078JjNE4b9t5NlAtWt4BB5GocoNr1bhV

CLI Commands

# Switch tenant
auth0 tenants use stacksync-dev.eu.auth0.com

# View/update login template
auth0 universal-login templates show
cat template.html | auth0 universal-login templates update

# View app details
auth0 apps show <client_id>

Template file: personal/a_claude/project/features/partner/auth0-login-template.html

SendGrid Templates

Template IDs

Template ID Env Var
Base activity / issues d-7834854800bb437380ad64a82a16cdd6 SENDGRID_EMAIL_TEMPLATE_WITH_LINK
Default d-dbaf71e4acd64f33b030460daffc8056 SENDGRID_DEFAULT_EMAIL_TEMPLATE_ID
Workspace Invite d-59939d6fd2344479bd92cbf7fe177109 SENDGRID_WORKSPACE_INVITE_TEMPLATE_ID
Email Change d-06c4bd01d9014aee98c179d32d80bb4e SENDGRID_EMAIL_CHANGE_TEMPLATE_ID
Email Change Confirm d-e7dc01bce38c4aaca35e61e854f8f97c SENDGRID_EMAIL_CHANGE_CONFIRMATION_TEMPLATE_ID

Dynamic Variables Available

Variable Example Fallback Notes
platform_name Rillet Stacksync Used in body text, footer, button labels
logo_url https://cdn.brandfetch.io/... Stacksync logo Must be PNG (SVG doesn't render in email clients)
logo_width 80 600 Pixel width for the logo. Set via inline style, not HTML attribute
frontend_url https://connect.rillet.com https://app.stacksync.com/ Logo links here
support_email hello@rillet.com hello@stacksync.com Footer contact email with mailto: link
button_color #5B4CFF #0000ee CTA button background color

Logo Sizing

The logo <img> uses inline styles for sizing — the HTML width attribute doesn't work because SendGrid's CSS overrides it. The working pattern:

<img style="height:auto !important; {{#if logo_width}}max-width:{{logo_width}}px; width:{{logo_width}}px;{{else}}max-width:100%; width:100%;{{/if}}" ...>
  • Removed class="max-width" (was forcing max-width: 100% !important)
  • Removed data-proportionally-constrained and data-responsive attributes
  • Without logo_width: full-width (Stacksync default)
  • With logo_width: constrained to that pixel value (e.g., 80 for Rillet)

Testing

Use test data in SendGrid editor (click "test data" in the green bar):

{
  "platform_name": "Rillet",
  "logo_url": "https://cdn.brandfetch.io/id9Bpy_H9O/w/1612/h/348/theme/dark/idTIk633FO.png",
  "logo_width": "80",
  "frontend_url": "https://connect.rillet.com",
  "support_email": "hello@rillet.com",
  "button_color": "#5B4CFF",
  "message": "<p>Your Sync <strong>Test Sync</strong> is paused.</p>",
  "subject": "Test notification",
  "details_link": "https://connect.rillet.com/workspaces/1/syncs/test",
  "workspace_name": "Acme Corp",
  "role_type": "Editor",
  "inviter_email": "admin@rillet.com",
  "invite_accept_link": "https://connect.rillet.com/accept-invite?workspace_id=1"
}

To verify the fallbacks (Stacksync defaults), remove all partner vars and test with just the template-specific vars.

Versioning

Templates are managed via the SendGrid API. Each update creates a new inactive version. To deploy:

  1. Preview in SendGrid editor with test data
  2. Activate via SendGrid UI: template → version → ⋮ → "Make Active"
  3. To rollback: activate the previous version

See SendGrid Email Templates tutorial for the full workflow.

Partner API

Authentication

Two modes: 1. Service token (PRM portal → frontend-logics): hardcoded ss_service_... token maps to partner_id = "rillet" 2. Partner API key (external): SHA-256 hashed, looked up in partner_api_keys column

Endpoints

Method Path Description
POST /v1/partner/{id}/workspaces Create workspace
GET /v1/partner/{id}/workspaces List workspaces
PATCH /v1/partner/{id}/workspaces Update workspace
DELETE /v1/partner/{id}/workspaces Delete workspace (by external_workspace_id)
POST /v1/partner/{id}/users Bulk create/link users
PATCH /v1/partner/{id}/users Bulk update users
GET /v1/partner/{id}/users List all partner users (with workspace assignments)
GET /v1/partner/{id}/workspaces/{ext_ws_id}/users List workspace users
POST /v1/partner/{id}/workspaces/{ext_ws_id}/users Assign existing users to workspace

Data Model

Workspace partner info stored in workspaces.options.partner:

{"partner_id": "rillet", "external_workspace_id": "ws_456", "created_time": "..."}

User partner info stored in users.options.partners.{partner_id}:

{"partner_id": "rillet", "external_user_id": "user_123", "external_workspace_id": "ws_456"}

Key PRs/branches: - partner-branding branch on workflows-frontend - partner-branding branch on frontend-logics - Connectors: error notification white-labeling, write error message generics