Partner White-Labeling — Development Log¶
Progress¶
| Task | Status | Notes |
|---|---|---|
| Partner detection (URL-based) | detectPartnerFromUrl() in workflows-frontend |
|
| Branding config API | GET /v1/partners/{id}/config, JSONB column on partners table |
|
| Frontend UI white-labeling | usePartnerConfig() hook, CSS variable overrides |
|
| Auth0 login page | Single template, multi-tenant detection via state param | |
| Passwordless email branding | Liquid templates with client_metadata |
|
| Error notification emails | {platform_name} in build_error_notification |
|
| SendGrid templates updated | 5 templates, new inactive versions created | |
| Write error messages genericized | "in our platform" instead of "in Stacksync" | |
| Partner API (CRUD workspaces/users) | Service token + API key auth | |
| Partner provisioning gate | "Account Not Provisioned" page with partner branding | |
| Workspace filtering (partner mode) | /login returns only matching partner workspaces |
|
| Hidden UI elements in partner mode | No "Create workspace", no pricing section | |
| Activate SendGrid template v6 versions | All 5 templates activated via API (2026-04-03) | |
Pass all branding vars in send_email() |
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 |
partners.branding.emails JSONB with all 5 vars set |
|
| E2E email notification test (PG→PG) | Local Docker Compose with two PG DBs, password change triggers auth error → notification with Rillet branding | |
| Auth0 dev login template — side image | Split layout: login form left, branding image right (dev only) | |
| SendGrid template subjects dynamic | All 5 templates use {{#if subject}}{{subject}}{{else}}Default{{/if}} — API subject overrides template default |
|
subject in template_data dict |
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) |
Self-documenting endpoint with partner presets, env-gated (blocked in prod) | |
| Email template testing — all 5 types | Tested with Rillet + Stacksync fallback: workspace_invite, base_activity, email_change, email_change_confirmation, default | |
| Auth0 login — custom inputs inside widget | 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 |
Value set in DB branding config | |
| Fix Cloudflare Pages routing for dev | rillet-test-dev.stacksync-dev.com now serves dev build |
|
Partner details_link in error notifications |
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 |
Third notification path in fe-logics — switch_off_base_due_to_error() now accepts workspace_id and resolves branding vars |
|
| GCP prod deployment restored | gcp-deploy-prod.yml re-added — notifications scheduler still hits GCP fe-logics, not Azure |
|
| Airtable OAuth on partner domains | Added postMessage listener to Airtable PKCE handler — fixes cross-origin popup communication for partner domains |
|
| Prod E2E email testing — Rillet | All notification types verified on prod with Rillet branding: base_activity (connectors), detect_schema_changes (fe-logics), workspace_invite | |
| Workflow module branding (backend-driven) | 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 | apps_branding endpoint accepts workspace_id, returns partner-branded category names/icons |
|
| Resource table partner icons | Resource table "Apps" column uses appBrandings for icon resolution instead of static constant |
|
appBrandings persisted in Zustand |
Prevents flash of Stacksync icons on page load | |
| Module search filter uses branded names | Frontend searches by app_name (branded) instead of app_type (code name) |
|
get_base_schema.py credential notification |
Old credential validation path sends plain text notification without branding vars | |
| SendGrid verified sender for partner emails | From address is always hello@stacksync.com. To show hello@rillet.com, add verified sender domain in SendGrid |
|
| Auth0 login template resilience | CSS class selectors may break if Auth0 updates rendering | |
| GCP → Azure migration for notifications | 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 readswindow.location - localStorage override (
__partner_override__) for dev/testing
Branding config: JSONB column on partners table¶
- No separate tables — everything in
partners.brandingJSONB - 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
stateparam 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_id→partners.branding.emails - Connectors:
resolve_workspace_partner()incommons/partners.py— single Redis/DB lookup returnsplatform_name,docs_url, andemail_varsdict - Frontend-logics:
resolve_branding_vars()infunctions/partners/branding.py— single JOIN query (workspace → partners) - Branding vars merged into
notification_dataorbodydict beforesendgrid.send_email()— they becomedynamic_template_datain 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-centersrc + 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.pycredential 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
Nonein dev (utils/redis.pyline 77-78). Error count functions returned0, meaning thresholds were never reached. Fixed by returningfloat('inf')whenclient is Noneso error handling still triggers without Redis - The
pause_base_temporarily_due_to_errorflow does NOT create notifications — onlyswitch_off_base_due_to_errordoes.get_base_paused_temporarily_count_since_last_internal_request_successalso returned0without Redis, keeping the code in the pause loop forever. Fixed same way (float('inf'))
Full Branding Vars in Emails¶
- Initially only
platform_nameandsender_namewere 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.emailsJSONB (separate frombranding.textswhich hasdocumentation_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 separateget_partner_config()calls inerror_notification_builder.py - Branding vars merged into
notification_datadict → becomesdynamic_template_datain 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(notlocalhost) 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.+comvshello@stacksync.c+om) - Logo
width="600"made partner logos gigantic — reduced towidth="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 APIsubjectparameter sets the email envelope subject, but templates can override it with their ownsubjectfield. Fixed by changing all template subjects to{{#if subject}}{{subject}}{{else}}Stacksync Default{{/if}} subjectmust be indynamic_template_data: Thesubjectpassed tosendgrid.send_email()as a parameter is NOT available as{{subject}}in the template. It must also be in thebodydict (which becomesdynamic_template_data). Bothsend_invite()andsend_email_notification()now setbody["subject"]/notification_data["subject"]- From email address: Always
hello@stacksync.comregardless 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:
- Connectors
build_error_notification()(connectors/utils/error_notification_builder.py) — HTML error emails with diagnostics. Fixed withresolve_workspace_partner()+partner_branding["email_vars"] - Fe-logics
detect_schema_changes(frontend-logics/endpoints/common/detect_schema_changes/utils.py) — schema change errors. Fixed withresolve_branding_vars(workspace_id). Required addingworkspace_idparam toswitch_off_base_due_to_error() - 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.ymlworkflow was deleted in commit25efb60c("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"infrontend-logics-prod-besg
Airtable OAuth on Partner Domains¶
- Airtable uses PKCE flow with a custom handler (not the shared
createOAuthPopupHandler) - The handler only used
localStoragepolling — nopostMessagelistener - On partner domains (e.g.
rillet-test-dev.stacksync-dev.com), the OAuth callback redirects todev.stacksync.com(different origin), soStorageEventnever fires on the parent - Fixed by adding a
postMessagelistener (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¶
- Module endpoints (
/workflows-modules/,/search,/info) callget_partner_branding_for_workspace(workspace_id)— single DB query joiningworkspaces → partners - If a partner is found,
apply_partner_branding_to_modules()modifies the response in-place: - Replaces "Stacksync" in
module_namewith partner name (e.g. "Stacksync AI" → "Rillet AI") - Sets
options.module_icon_urlto partner icon for matching modules - Apps branding endpoint (
/common/apps-branding/) accepts optionalworkspace_idquery param, appliesapply_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): usesappBrandingsfrom the store for icon resolution. Only overrides icons forstacksync*,system,file_servicetypes — third-party apps keep curated icons fromappIconUrlsconstant - Resources page (
resources/page.tsx): addedgetAppBrandings()fetch (was missing — store was empty on direct navigation) - Zustand persist:
appBrandingsadded topartializelist so partner icons render instantly from localStorage on subsequent visits - Search filter (
view.tsx): usesapp_namefrom branding (branded) instead ofapp_type(code name) for search matching appBrandingscache: in-memory cache inget.tsinvalidates 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 forcingmax-width: 100% !important) - Removed
data-proportionally-constrainedanddata-responsiveattributes - Without
logo_width: full-width (Stacksync default) - With
logo_width: constrained to that pixel value (e.g.,80for 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:
- Preview in SendGrid editor with test data
- Activate via SendGrid UI: template → version → ⋮ → "Make Active"
- 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:
User partner info stored in users.options.partners.{partner_id}:
Related Commits¶
Key PRs/branches:
- partner-branding branch on workflows-frontend
- partner-branding branch on frontend-logics
- Connectors: error notification white-labeling, write error message generics