Skip to main content

Cello Integration Guide for React + Node.js

A comprehensive guide to integrating Cello’s referral system into any React + Node.js application. This guide covers the referral widget, public attribution, signup tracking, and Stripe integration.

Integration Planning Framework

IMPORTANT: When planning a Cello integration, structure your plan into TWO PHASES:

Phase 1: Mandatory Core Integration

Must be implemented for Cello to work:
  • ✅ Step 1: Server-Side Helpers
  • ✅ Step 2: Referral Widget (cello.js - Authenticated Users)
  • ✅ Step 3: Public Attribution (cello-attribution.js - Public Pages)
  • ✅ Step 4: Signup Event Tracking
  • ✅ Step 5: Stripe Integration
Critical for Custom Launchers:
  • ✅ Dynamic reward labels (Step 2.3.D) - REQUIRED if using custom launcher

Phase 2: Optional Enhancements

Highly recommended but can be added later:
  • ⭐ Enhanced error handling & logging
  • ⭐ Retry logic for failed operations
  • ⭐ Loading states & user feedback
  • ⭐ Advanced signup personalization
  • ⭐ Referral code validation
  • ⭐ Campaign-specific messaging
  • ⭐ Environment validation
  • ⭐ Detailed debugging tools

Why Two Phases?

Phase 1 gets Cello working and generating referrals.
Phase 2 makes it production-ready, debuggable, and user-friendly.
Common Mistake: Treating Phase 2 features (especially dynamic labels for custom launchers) as truly optional. They’re essential for a good user experience.
Best Practice: Plan both phases upfront, then implement Phase 1 fully before moving to Phase 2. This ensures nothing critical is missed.

General Prerequisites

Before starting the Cello integration, ensure your application meets these requirements:

Application Requirements

RequirementDescription
Authentication SystemYour app must have user authentication with session/token management
User ModelUsers must have unique IDs, email addresses, and names
Server-Side RenderingSupport for SSR or hybrid rendering (Next.js, Remix, etc.)
API RoutesAbility to create server-side API endpoints

Environment Variables

Create these environment variables in your .env file:
# Required for Referral Widget
CELLO_PRODUCT_ID=your-product-id          # From Cello Portal
CELLO_PRODUCT_SECRET=your-product-secret  # From Cello Portal (for JWT signing)
CELLO_ENV=sandbox                          # "sandbox" or "production"

# Required for API Calls (Attribution, Event Tracking)
CELLO_ACCESS_KEY_ID=your-access-key-id
CELLO_SECRET_ACCESS_KEY=your-secret-access-key

# Optional
CELLO_API_BASE_URL=https://api.sandbox.cello.so  # Defaults based on CELLO_ENV
Environment variable names: This guide uses CELLO_PRODUCT_ID, CELLO_PRODUCT_SECRET, and CELLO_ENV. Your project may use different names. Product ID and secret are server-side only — do not use NEXT_PUBLIC_ for them. Use the script URLs from Quickstart and Embedded Script Tag in the steps below.

Cello Portal Setup

  1. Create an account at cello.so
  2. Create a product and note the Product ID and Product Secret
  3. Generate API access keys for server-side calls
  4. Configure your reward structure and campaigns

Cello Scripts Overview

Cello provides two separate JavaScript SDKs for different purposes:
ScriptPurposeUsed InUsers
cello.jsReferral widget for authenticated usersStep 2Authenticated users only
cello-attribution.jsTrack referral codes from URLStep 3Public visitors (unauthenticated)
Key Distinction:
  • cello.js - Loaded in authenticated layouts to show the referral widget/launcher
  • cello-attribution.js - Loaded in public layouts to capture referral codes from URL parameters
Both scripts are independent and serve different purposes in the referral flow.

Step 1: Server-Side Helpers

1.1 Documentation

1.2 Prerequisites

  • CELLO_PRODUCT_ID and CELLO_PRODUCT_SECRET environment variables set
  • A JWT signing library (e.g., jsonwebtoken for Node.js)

1.3 Integration Description

Create a server-side helper module with three functions:

A. JWT Generator

Generate HS512-signed JWTs for widget authentication. CRITICAL: Use Cello’s custom claim names, not standard JWT claims.
import jwt from "jsonwebtoken"

export function createCelloJwt(userId: string): string {
  const payload = {
    productId: process.env.CELLO_PRODUCT_ID,  // NOT "iss"
    productUserId: userId,                      // NOT "sub"
    iat: Math.floor(Date.now() / 1000),        // Only these 3 fields
  }
  
  return jwt.sign(payload, process.env.CELLO_PRODUCT_SECRET!, {
    algorithm: "HS512",  // Must be HS512
  })
}
Common Mistakes to Avoid:
  • ❌ Using iss, sub, exp (standard JWT claims)
  • ❌ Including name, email in the JWT
  • ✅ Use exactly: productId, productUserId, iat

B. Script URL Resolver

Use the same script URLs as in the Referral Component Quickstart (path includes /latest/).
export function getCelloScriptUrl(): string {
  const env = process.env.CELLO_ENV || "sandbox"
  return env === "production"
    ? "https://assets.cello.so/app/latest/cello.js"
    : "https://assets.sandbox.cello.so/app/latest/cello.js"
}
CRITICAL URLs:
  • ✅ Sandbox: https://assets.sandbox.cello.so/app/latest/cello.js
  • ✅ Production: https://assets.cello.so/app/latest/cello.js
  • ❌ Wrong: https://sandbox.cello.so/widget/cello.js
  • ❌ Wrong: https://assets.sandbox.cello.so/app/cello.js (missing /latest/ → Access Denied)

C. Boot Configuration Builder

export function buildCelloBootConfig(user: { id: string; email: string; name: string }) {
  return {
    productId: process.env.CELLO_PRODUCT_ID,
    token: createCelloJwt(user.id),
    language: "en",
    productUserDetails: {
      firstName: user.name.split(" ")[0] ?? user.name,
      lastName: user.name.split(" ").slice(1).join(" ") || undefined,
      fullName: user.name,
      email: user.email,
    },
  }
}

1.4 Acceptance Criteria

  • createCelloJwt() generates a valid HS512 JWT with productId, productUserId, iat claims only
  • getCelloScriptUrl() returns correct URL based on environment
  • buildCelloBootConfig() returns config with productUserDetails including firstName, lastName, fullName, email
  • No sensitive data (product secret) is exposed to the client

Step 2: Referral Widget (Authenticated Users)

📦 Required Script: cello.js (Referral Widget) This step integrates the Cello referral widget for authenticated users only. The widget allows users to access their referral link, share it, and track referrals.

2.1 Documentation

2.2 Prerequisites

  • Server-side helpers from Step 1 implemented
  • Authenticated user session available
  • Authenticated layout/pages where widget will appear

2.3 Integration Description

A. Create Token API Endpoint

Create an API route that returns boot configuration for authenticated users:
// /api/cello/token (or equivalent)
export async function GET(request: Request) {
  const user = await getCurrentUser(request)  // Your auth logic
  
  if (!user || !process.env.CELLO_PRODUCT_ID) {
    return Response.json({ enabled: false })
  }
  
  return Response.json({
    enabled: true,
    bootConfig: buildCelloBootConfig(user),
  })
}

B. Add Scripts to Authenticated Layout (cello.js)

CRITICAL: Use cello.js for authenticated users This script enables the referral widget functionality. Do not confuse with cello-attribution.js (used in Step 3 for public pages). Per Referral Component Quickstart: load the script with type="module" and async. Use the same tag shape in your layout (raw <script> with those attributes). In Next.js, if you use next/script, ensure the script is loaded as a module (e.g. pass type="module" or use the raw tag below). In your authenticated layout, add the Cello queue snippet and script:
// Authenticated layout — script must have type="module" and async (per Quickstart)
export default function AuthenticatedLayout({ children }) {
  const celloScriptUrl = getCelloScriptUrl()
  
  return (
    <html>
      <body>
        {/* Queue snippet - must load before script */}
        <script
          dangerouslySetInnerHTML={{
            __html: `window.cello = window.cello || { cmd: [] };`
          }}
        />
        
        {/* Cello widget script — type="module" and async per docs.cello.so/referral-component/quickstart */}
        <script
          src={celloScriptUrl}
          type="module"
          async
          crossOrigin="anonymous"
        />
        
        <CelloBootstrap />
        {children}
      </body>
    </html>
  )
}
Script attributes (from Quickstart): type="module", async. Do not add data-product-id to the widget script.

C. Create Bootstrap Component

First, add TypeScript declarations for the Cello SDK:
// types/cello.d.ts (or at top of component file)
declare global {
  interface Window {
    cello?: {
      cmd: Array<(cello: CelloSDK) => void | Promise<void>>
    }
    Cello?: (command: string, ...args: unknown[]) => Promise<unknown>
  }
}

interface CelloSDK {
  boot: (config: CelloBootConfig) => Promise<void>
}

interface CelloBootConfig {
  productId: string
  token: string
  language: string
  productUserDetails: {
    firstName: string
    lastName?: string
    fullName: string
    email: string
  }
}
Then create the bootstrap component:
"use client"  // For Next.js App Router

import { useEffect, useState } from "react"

export function CelloBootstrap() {
  const [hasBooted, setHasBooted] = useState(false)

  useEffect(() => {
    if (hasBooted) return
    
    async function bootCello() {
      try {
        const response = await fetch("/api/cello/token", {
          credentials: "include",  // Include session cookies
        })
        const { enabled, bootConfig } = await response.json()
        
        if (!enabled || !bootConfig) return
        
        // Defensive initialization in case queue snippet didn't load
        if (!window.cello) {
          window.cello = { cmd: [] }
        }
        
        // Official boot pattern
        window.cello.cmd.push(async function(cello) {
          await cello.boot(bootConfig)
          console.log("[Cello] Widget booted successfully")
        })
        
        setHasBooted(true)
      } catch (error) {
        console.error("[Cello] Boot failed:", error)
      }
    }
    
    bootCello()
  }, [hasBooted])
  
  return null
}

D. Custom Launcher

CRITICAL - PHASE 1 REQUIREMENT: If using a custom launcher instead of Cello’s default floating button, you MUST implement dynamic reward labels. Without them, users won’t see the reward amount and won’t be motivated to refer.
Planning Note: While custom launchers are technically optional (you can use Cello’s default floating button), dynamic labels are mandatory if you choose to implement a custom launcher. Include this in your Phase 1 plan, not Phase 2.
Basic Custom Launcher Button
export function ReferralLauncherButton() {
  return (
    <button
      className="cello-launcher"  // Required class for Cello to find
      style={{ position: "relative" }}  // Required for badge positioning
    >
      Earn Rewards
    </button>
  )
}
Dynamic Reward Labels (REQUIRED for Custom Launchers)
The key feature of custom launchers is showing the dynamic reward amount (e.g., “Earn €1000” instead of static “Earn”). Fetch labels after boot:
window.cello.cmd.push(async function(cello) {
  await cello.boot(config)
  
  // Fetch dynamic labels
  const labels = await window.Cello("getLabels")
  // labels.customLauncher contains "Earn €1000" etc.
  
  // Update your button text with the dynamic label
  updateLauncherText(labels.customLauncher)
})
Common Timing Issue and Solution
Problem: Dynamic labels don’t appear on first login, only after page refresh.
Root Cause: This is a state management/timing issue, not a Cello bug. The launcher component renders before labels are fetched, and without proper state management, the component doesn’t re-render when labels become available. Incorrect Flow:
  1. User logs in
  2. Launcher component renders with fallback text
  3. Bootstrap fetches labels asynchronously
  4. Labels are fetched but component doesn’t know about them
  5. User refreshes → component re-renders with labels already in state ✓
Correct Implementation Flow: To fix this issue, you need a mechanism to notify components when labels are ready:
  1. Boot Cello in bootstrap component
  2. After boot completes, fetch labels via window.Cello("getLabels")
  3. Store labels in shared state (global variable, context, or state management)
  4. Notify components that labels are ready (custom event, callback, or state update)
  5. Components listen for labels and update UI accordingly
Implementation Approaches
1. Event-Based Pattern (Framework Agnostic) Works with any framework or vanilla JavaScript:
// Bootstrap component - fetch and broadcast labels
"use client"

import { useEffect, useState } from "react"

const CELLO_LABELS_EVENT = "cello:labels:ready"

export function CelloBootstrap() {
  const [hasBooted, setHasBooted] = useState(false)

  useEffect(() => {
    if (hasBooted) return
    
    async function bootCello() {
      try {
        const response = await fetch("/api/cello/token", {
          credentials: "include",
        })
        const { enabled, bootConfig } = await response.json()
        
        if (!enabled || !bootConfig) return
        
        if (!window.cello) {
          window.cello = { cmd: [] }
        }
        
        window.cello.cmd.push(async function(cello) {
          await cello.boot(bootConfig)
          console.log("[Cello] Widget booted successfully")
          
          // CRITICAL: Fetch labels after boot
          const labels = await window.Cello("getLabels")
          console.log("[Cello] Labels fetched:", labels)
          
          // Store globally for immediate access
          window.__celloLabels = labels
          
          // Broadcast event to all components
          window.dispatchEvent(new CustomEvent(CELLO_LABELS_EVENT, {
            detail: labels
          }))
        })
        
        setHasBooted(true)
      } catch (error) {
        console.error("[Cello] Boot failed:", error)
      }
    }
    
    bootCello()
  }, [hasBooted])
  
  return null
}
// Launcher component - listen for labels
"use client"

import { useEffect, useState } from "react"

const CELLO_LABELS_EVENT = "cello:labels:ready"

export function ReferralLauncherButton() {
  const [labelText, setLabelText] = useState("Invite & Earn")  // Fallback
  
  useEffect(() => {
    // Check if labels already available (e.g., after navigation)
    if (window.__celloLabels?.customLauncher) {
      setLabelText(window.__celloLabels.customLauncher)
    }
    
    // Listen for labels event
    const handleLabels = (event: CustomEvent) => {
      const labels = event.detail
      if (labels?.customLauncher) {
        setLabelText(labels.customLauncher)
      }
    }
    
    window.addEventListener(CELLO_LABELS_EVENT, handleLabels)
    return () => window.removeEventListener(CELLO_LABELS_EVENT, handleLabels)
  }, [])
  
  return (
    <button
      className="cello-launcher"
      style={{ position: "relative" }}
    >
      {labelText}
    </button>
  )
}
TypeScript Declarations:
// types/cello.d.ts
declare global {
  interface Window {
    __celloLabels?: {
      customLauncher?: string
      // ... other label fields
    }
  }
}
2. React Context Pattern For React applications, wrap your authenticated app with a context provider:
// context/CelloContext.tsx
const CelloLabelsContext = createContext<{ labels: CelloLabels | null }>({ labels: null })

export function CelloLabelsProvider({ children }) {
  const [labels, setLabels] = useState<CelloLabels | null>(null)
  
  useEffect(() => {
    const handleLabels = (event: CustomEvent) => {
      setLabels(event.detail)
    }
    
    window.addEventListener(CELLO_LABELS_EVENT, handleLabels)
    return () => window.removeEventListener(CELLO_LABELS_EVENT, handleLabels)
  }, [])
  
  return (
    <CelloLabelsContext.Provider value={{ labels }}>
      {children}
    </CelloLabelsContext.Provider>
  )
}

export const useCelloLabels = () => useContext(CelloLabelsContext)
Then in your launcher:
export function ReferralLauncherButton() {
  const { labels } = useCelloLabels()
  
  return (
    <button className="cello-launcher" style={{ position: "relative" }}>
      {labels?.customLauncher || "Invite & Earn"}
    </button>
  )
}
3. State Management (Redux/Zustand/etc.) Dispatch labels to your global store after boot, then components subscribe to the store. Key Principles (Any Approach):
  1. ✅ Always provide fallback text for initial render
  2. ✅ Store labels in shared state (global variable, context, store)
  3. ✅ Use event/callback/subscription to notify components
  4. ✅ Check for existing labels on component mount (handles navigation)
  5. ✅ Clean up event listeners on unmount
Why This Matters:
  • ❌ Static “Earn” - No clear incentive
  • ✅ Dynamic “Earn €1000” - Clear value proposition
  • ❌ Labels only on refresh - Poor user experience
  • ✅ Labels on first login - Professional implementation

2.4 Acceptance Criteria

Phase 1 - Basic Widget Functionality (Mandatory):
  • Token endpoint returns { enabled: true, bootConfig: {...} } for authenticated users
  • Token endpoint returns { enabled: false } for unauthenticated users
  • Cello script loads without CORS errors in browser Network tab
  • Console shows no “User is not authorized” errors
  • Widget opens when clicking the launcher button
  • Notification badges appear on the launcher (test with your own referral link)
Phase 1 - Custom Launcher with Dynamic Labels (REQUIRED If Using Custom Launcher):
  • Dynamic reward label displays on launcher (e.g., “Earn €1000” not just “Earn”)
  • Label updates within 1-2 seconds on FIRST LOGIN (not just after page refresh)
  • Browser console shows successful getLabels call
  • Launcher degrades gracefully if labels fail to load (shows fallback text)
  • Labels persist correctly during navigation within SPA (don’t require page refresh)
Phase 2 - Enhanced Error Handling & Loading States (Optional but Recommended):
  • Widget boot errors are logged with detailed information
  • Failed API calls include request/response details in logs
  • Retry logic attempts boot up to 3 times on failure
  • Attribution errors don’t block page load or form submission
  • Launcher shows loading state while fetching labels (visual feedback)
  • User sees appropriate feedback for connection issues (if implementing UI indicators)

Step 3: Public Attribution

📦 Required Script: cello-attribution.js (Attribution Tracking) This step integrates attribution tracking for public visitors. This script captures referral codes (UCC) from URL parameters when users visit via referral links, before they authenticate.

3.1 Documentation

3.2 Prerequisites

  • CELLO_PRODUCT_ID environment variable set
  • Public layout/pages (landing page, signup page)

3.3 Integration Description

A. Attribution Script URL Helper

Use the same script URLs as in Embedded Script Tag (path includes /latest/).
export function getCelloAttributionScriptUrl(): string {
  const env = process.env.CELLO_ENV || "sandbox"
  return env === "production"
    ? "https://assets.cello.so/attribution/latest/cello-attribution.js"
    : "https://assets.sandbox.cello.so/attribution/latest/cello-attribution.js"
}
CRITICAL URLs:
  • ✅ Sandbox: https://assets.sandbox.cello.so/attribution/latest/cello-attribution.js
  • ✅ Production: https://assets.cello.so/attribution/latest/cello-attribution.js
  • ❌ Wrong: https://sandbox.cello.so/attribution/cello-attribution.js
  • ❌ Wrong: https://assets.sandbox.cello.so/attribution/cello-attribution.js (missing /latest/ → Access Denied)

B. Add Scripts to Public Layout (cello-attribution.js)

CRITICAL: Use cello-attribution.js for public pages This script captures referral codes from URL parameters. Do not confuse with cello.js (used in Step 2 for authenticated users). Per Embedded Script Tag: use type="module" and async on the script tag.
export default function PublicLayout({ children }) {
  const attributionScriptUrl = getCelloAttributionScriptUrl()
  
  return (
    <html>
      <body>
        {/* Attribution queue snippet - CRITICAL: Use Cello's official queue function */}
        <script
          dangerouslySetInnerHTML={{
            __html: `window.CelloAttribution=window.CelloAttribution||function(t,...o){if("getReferral"===t)throw new Error("getReferral is not supported in this context. Use getUcc instead.");let e,n;const i=new Promise((t,o)=>{e=t,n=o});return window.CelloAttributionCmd=window.CelloAttributionCmd||[],window.CelloAttributionCmd.push({command:t,args:o,resolve:e,reject:n}),i}`
          }}
        />
        
        {/* Attribution script — type="module" and async per docs.cello.so/sdk/client-side/embedded-script-tag */}
        <script
          src={attributionScriptUrl}
          type="module"
          async
          crossOrigin="anonymous"
        />
        
        {children}
      </body>
    </html>
  )
}
  • Attribution script does NOT require data-product-id attribute (product context comes from URL parameters)
  • Use Cello’s official queue function (not the simple queue snippet) - this handles async loading and allows immediate calls
  • Queue snippet MUST load before the attribution script

C. Create Attribution Hook

First, add TypeScript declarations for the Attribution SDK:
// types/cello-attribution.d.ts (or at top of hook file)
declare global {
  interface Window {
    CelloAttribution?: {
      (command: "getUcc"): Promise<string | null>
      (command: "getReferrerName"): Promise<string | null>
      (command: "getCampaignConfig"): Promise<CampaignConfig | null>
    }
  }
}

interface CampaignConfig {
  newUserDiscountPercentage: number  // Decimal format: 0.1 = 10%, 0.25 = 25%
  newUserDiscountMonth: number       // Duration in months
}
Then create the attribution hook:
"use client"

import { useState, useEffect } from "react"

export function useCelloAttribution() {
  const [ucc, setUcc] = useState<string | null>(null)
  const [referrerName, setReferrerName] = useState<string | null>(null)
  const [campaignConfig, setCampaignConfig] = useState<CampaignConfig | null>(null)
  const [isReady, setIsReady] = useState(false)

  useEffect(() => {
    let cancelled = false

    async function fetchAttribution() {
      try {
        // Direct calls work immediately thanks to queue snippet
        // No polling needed - the queue handles async loading automatically
        const [code, name, config] = await Promise.all([
          window.CelloAttribution("getUcc"),
          window.CelloAttribution("getReferrerName"),
          window.CelloAttribution("getCampaignConfig"),
        ])
        
        if (!cancelled) {
          setUcc(code)
          setReferrerName(name)
          setCampaignConfig(config)
          setIsReady(true)
        }
      } catch (e) {
        console.error("[Cello] Attribution fetch failed:", e)
        if (!cancelled) {
          setIsReady(true)  // Mark ready even without data
        }
      }
    }

    fetchAttribution()
    return () => { cancelled = true }
  }, [])

  return { ucc, referrerName, campaignConfig, isReady }
}

/**
 * Utility to get UCC directly from CelloAttribution
 * Works immediately thanks to queue pattern - no need to check if SDK is loaded
 */
export async function getCelloUcc(): Promise<string | null> {
  try {
    return await window.CelloAttribution("getUcc")
  } catch {
    return null
  }
}
Key Implementation Notes:
  • No polling needed - Cello’s queue function handles async loading automatically
  • Direct calls work immediately - queue buffers commands until script loads
  • Simpler and more reliable - uses Cello’s official recommended pattern
  • Don’t use simple queue snippet - use the full queue function that returns promises

D. Signup Personalization (Phase 2 - Optional)

Display referrer name and campaign discount on signup page to improve conversion. Documentation: getCampaignConfig, getReferrerName
export function SignupReferralBanner() {
  const { referrerName, campaignConfig } = useCelloAttribution()
  
  // Only show banner if user came via referral
  if (!referrerName) return null
  
  // Calculate discount display values
  const hasDiscount = campaignConfig?.newUserDiscountPercentage > 0 && campaignConfig?.newUserDiscountMonth > 0
  const discountPercent = hasDiscount ? Math.round(campaignConfig.newUserDiscountPercentage * 100) : 0
  const discountMonths = campaignConfig?.newUserDiscountMonth || 0
  
  // Format discount message based on duration
  const discountMessage = hasDiscount
    ? discountMonths === 1
      ? `${discountPercent}% off your first month`
      : `${discountPercent}% off your first ${discountMonths} months`
    : null
  
  return (
    <div className="referral-banner">
      <p>{referrerName} invited you to join!</p>
      {discountMessage && <p>🎉 Special offer: {discountMessage}</p>}
    </div>
  )
}
Key Implementation Notes:
  • Referrer Name Only: If campaignConfig is empty/null or discount values are 0, show only the referrer name as social proof
  • With Discount: If campaign has a discount configured, display both referrer name and the special offer
  • Singular vs Plural: Format correctly based on newUserDiscountMonth:
    • newUserDiscountMonth === 1: “X% off your first month”
    • newUserDiscountMonth > 1: “X% off your first Y months”
  • Percentage Conversion: Convert decimal format (0.1) to display format (10%)
Example Campaign Config Response:
{
  "newUserDiscountPercentage": 0.3,
  "newUserDiscountMonth": 3
}
Displays as: “30% off your first 3 months” Empty/No Discount:
{
  "newUserDiscountPercentage": 0,
  "newUserDiscountMonth": 0
}
Or null - Shows only: “John invited you to join!” (social proof only)

3.4 Acceptance Criteria

Phase 1 - Core Attribution (Mandatory):
  • Attribution script loads on public pages (check Network tab)
  • window.CelloAttribution function is available after script loads
  • Visiting /?ucc=TEST123 stores the referral code
  • useCelloAttribution() hook returns the stored UCC
Phase 2 - Enhanced Attribution (Optional):
  • Hook returns error states for debugging
  • Referrer name displays on signup page when visiting via referral link
  • Campaign configuration fetched alongside UCC and referrer name
  • Campaign discount displays correctly on signup page (e.g., “30% off your first 3 months”)
  • Signup banner shows only referrer name when no discount is configured (social proof)
  • Discount format adjusts for singular vs plural months (“month” vs “months”)
  • Timeout errors are non-blocking and logged clearly
  • Attribution failures don’t crash the page

Step 4: Signup Event Tracking

4.1 Documentation

4.2 Prerequisites

  • CELLO_ACCESS_KEY_ID and CELLO_SECRET_ACCESS_KEY environment variables set
  • Attribution from Step 3 working
  • Signup API endpoint

4.3 Integration Description

A. API Authentication Helper

const CELLO_API_BASE_URL = process.env.CELLO_API_BASE_URL 
  || (process.env.CELLO_ENV === "production" 
    ? "https://api.cello.so" 
    : "https://api.sandbox.cello.so")

let cachedToken: { token: string; expiresAt: number } | null = null

export async function fetchCelloAccessToken(): Promise<string | null> {
  const accessKeyId = process.env.CELLO_ACCESS_KEY_ID
  const secretAccessKey = process.env.CELLO_SECRET_ACCESS_KEY
  
  if (!accessKeyId || !secretAccessKey) return null
  
  // Return cached token if still valid (with 60s buffer)
  if (cachedToken && cachedToken.expiresAt > Date.now() + 60000) {
    return cachedToken.token
  }
  
  const response = await fetch(`${CELLO_API_BASE_URL}/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      accessKeyId,
      secretAccessKey,
    }),
  })
  
  if (!response.ok) return null
  
  const data = await response.json()
  const expiresIn = data.expiresIn ?? 3600  // Default 1 hour
  
  cachedToken = {
    token: data.accessToken,  // CRITICAL: Field is "accessToken", not "token"
    expiresAt: Date.now() + expiresIn * 1000,
  }
  
  return cachedToken.token
}
CRITICAL API Base URLs:
  • ✅ Production: https://api.cello.so
  • ✅ Sandbox: https://api.sandbox.cello.so
  • ❌ Wrong: https://sandbox.api.cello.so (subdomain order reversed!)
CRITICAL Endpoints (no /v1/ prefix):
  • ✅ Token: POST /token
  • ✅ Events: POST /events
  • ❌ Wrong: /v1/token, /v1/events, /v1/authentication/token
CRITICAL Token Response:
  • The access token is in data.accessToken, NOT data.token
  • Expiration is in data.expiresIn (seconds)

B. Event Emission Helper

export async function emitReferralUpdatedEvent(
  user: { id: string; email: string; name: string },
  ucc: string
): Promise<boolean> {
  const token = await fetchCelloAccessToken()
  if (!token) return false
  
  const response = await fetch(`${CELLO_API_BASE_URL}/events`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      eventName: "ReferralUpdated",
      payload: {
        ucc,
        newUserId: user.id,  // CRITICAL: newUserId in payload
      },
      context: {
        newUser: {
          id: user.id,
          email: user.email,
          name: user.name,  // Use "name", not "fullName"
        },
        event: {
          trigger: "new-signup",
          timestamp: new Date().toISOString(),
        },
      },
    }),
  })
  
  return response.ok
}
CRITICAL Event Payload Structure:
{
  "eventName": "ReferralUpdated",
  "payload": {
    "ucc": "referral-code",
    "newUserId": "user-123"     // ✅ newUserId in PAYLOAD
  },
  "context": {
    "newUser": {                // ✅ Nested object
      "id": "user-123",
      "email": "user@email.com",
      "name": "John Doe"        // ✅ "name" not "fullName"
    },
    "event": {
      "trigger": "new-signup",
      "timestamp": "2024-01-01T00:00:00Z"
    }
  }
}
Common Mistakes:
  • ❌ Putting newUserId in context instead of payload
  • ❌ Using flat fields like newUserEmail instead of newUser.email
  • ❌ Using fullName instead of name

C. Referral Code Validation Helper (Phase 2 - Optional)

Why Validate?
  • Prevents storing invalid/expired referral codes in your database
  • Avoids sending unnecessary events to Cello for invalid codes
  • Provides better user feedback for invalid referral links
  • Can retrieve campaign metadata for personalization
Documentation: Fetch Referral Code Info
Simple Implementation (Returns Boolean)
export async function validateReferralCode(code: string): Promise<boolean> {
  const token = await fetchCelloAccessToken()
  if (!token) return false
  
  const response = await fetch(`${CELLO_API_BASE_URL}/referral-codes/${code}`, {
    method: "GET",
    headers: {
      "Authorization": `Bearer ${token}`,
    },
  })
  
  if (!response.ok) return false
  
  const data = await response.json()
  return data.valid === true
}
Enhanced Implementation (Returns Full Response)
interface ReferralCodeValidation {
  code: string
  valid: boolean
  productUserId?: string
  campaignId?: string
}

export async function validateReferralCode(
  code: string
): Promise<ReferralCodeValidation | null> {
  const token = await fetchCelloAccessToken()
  if (!token) return null
  
  const response = await fetch(`${CELLO_API_BASE_URL}/referral-codes/${code}`, {
    method: "GET",
    headers: {
      "Authorization": `Bearer ${token}`,
    },
  })
  
  if (!response.ok) {
    // 404 = code doesn't exist
    return { code, valid: false }
  }
  
  const data = await response.json()
  return data
}
API Response Structure:
{
  "code": "UIPIWa2Hnnr",
  "productUserId": "12345678",
  "valid": true,
  "campaignId": "campaign_partners_1"
}
Response Fields:
  • code (string) - The referral code that was validated
  • valid (boolean) - Whether the code is currently valid
  • productUserId (string) - The user ID who owns this referral code
  • campaignId (string) - The campaign this referral code belongs to
Common Mistakes:
  • ❌ Not checking response.ok before parsing JSON
  • ❌ Assuming 404 means error instead of invalid code
  • ❌ Not handling missing productUserId or campaignId (they’re optional in response)
  • ❌ Blocking signup flow when validation fails (validation should be non-blocking)

D. Extend User Model

Add referral fields to your user model:
interface User {
  id: string
  email: string
  name: string
  // Add these fields
  referralCode?: string
  referralCampaignId?: string
}

E. Update Signup Handler

Phase 1 Implementation (No Validation)
// In your signup API handler
export async function handleSignup(data: SignupData) {
  const { name, email, password, referralCode } = data
  
  // 1. Create the user
  const user = await createUser({ name, email, password })
  
  // 2. If referral code provided, store and emit event
  if (referralCode) {
    // Store on user
    user.referralCode = referralCode
    await saveUser(user)
    
    // Emit event to Cello (fire and forget)
    emitReferralUpdatedEvent(user, referralCode)
      .then(success => {
        if (success) console.log("[Cello] Referral event sent")
        else console.error("[Cello] Referral event failed")
      })
      .catch(console.error)
  }
  
  return user
}
Phase 2 Implementation (With Validation)
// In your signup API handler
export async function handleSignup(data: SignupData) {
  const { name, email, password, referralCode } = data
  
  // 1. Create the user
  const user = await createUser({ name, email, password })
  
  // 2. If referral code provided, validate before storing
  if (referralCode) {
    const validation = await validateReferralCode(referralCode)
    
    if (validation && validation.valid) {
      // Store referral code and campaign ID
      user.referralCode = referralCode
      user.referralCampaignId = validation.campaignId
      await saveUser(user)
      
      // Emit event to Cello (fire and forget)
      emitReferralUpdatedEvent(user, referralCode)
        .then(success => {
          if (success) console.log("[Cello] Referral event sent")
          else console.error("[Cello] Referral event failed")
        })
        .catch(console.error)
    } else {
      // Invalid code - don't store or emit event
      console.warn("[Cello] Invalid referral code, skipping:", referralCode)
      // Signup continues normally - don't block user registration
    }
  }
  
  return user
}
Benefits of Validation (Phase 2):
  • ✅ Prevents storing expired or invalid referral codes
  • ✅ Cleaner database without invalid data
  • ✅ Avoids unnecessary API calls to Cello for invalid codes
  • ✅ Can store campaign ID for analytics and personalization
  • ✅ Better user experience with appropriate feedback
Important: Validation should be non-blocking. Even if the referral code is invalid, allow the user to complete signup successfully.

F. Pass Referral Code from Signup Form

// In your signup form component
import { useCelloAttribution, getCelloUcc } from "@/hooks/use-cello-attribution"

const { ucc } = useCelloAttribution()

async function handleSubmit(formData) {
  // Try hook value first, fall back to direct SDK call
  let referralCode = ucc
  
  if (!referralCode) {
    referralCode = await getCelloUcc()
  }
  
  await fetch("/api/signup", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      ...formData,
      referralCode,  // Include referral code
    }),
  })
}

4.4 Acceptance Criteria

Phase 1 - Core Event Tracking (Mandatory):
  • fetchCelloAccessToken() successfully retrieves and caches access token
  • Signup with referral code triggers emitReferralUpdatedEvent()
  • Server logs show POST /events returning status 200
  • Cello dashboard shows the new signup event
  • User model stores referralCode field
  • Signup without referral code works normally (no errors)
Phase 2 - Referral Code Validation (Optional but Recommended):
  • validateReferralCode() successfully validates valid referral codes (returns true or valid object)
  • validateReferralCode() correctly identifies invalid codes (returns false or invalid object)
  • Invalid referral codes are NOT stored in user model
  • Invalid referral codes do NOT trigger emitReferralUpdatedEvent()
  • Signup completes successfully even with invalid referral codes (non-blocking)
  • Campaign ID is stored alongside referral code when available (enhanced implementation)
Phase 2 - Enhanced Logging (Optional but Recommended):
  • Token request logs include full URL and response details
  • Event emission logs include payload structure
  • Environment variables validated on startup
  • Failed API calls show detailed error context

Step 5: Stripe Integration

5.1 Documentation

5.2 Prerequisites

  • Stripe integration in your application
  • Cello API credentials (for storing referral data on Stripe customer)

5.3 Integration Description

A. Add Metadata to Stripe Customer

When creating or updating Stripe customers, include referral metadata:
import Stripe from "stripe"

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function createStripeCustomer(user: User) {
  return stripe.customers.create({
    email: user.email,
    name: user.name,
    metadata: {
      userId: user.id,
      // Cello-specific metadata for commission tracking
      cello_ucc: user.referralCode || "",
      new_user_id: user.id,
    },
  })
}
Required Metadata Keys:
  • cello_ucc - The referral code (UCC)
  • new_user_id - Your internal user ID

B. Configure Stripe Webhook in Cello Portal

  1. Go to Cello Portal → Integrations → Stripe
  2. Connect your Stripe account
  3. Cello will automatically create webhooks for:
    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • invoice.paid
    • charge.refunded

5.4 Acceptance Criteria

  • Stripe customers have cello_ucc and new_user_id in metadata
  • Stripe webhook configured in Cello portal
  • Test purchase shows in Cello dashboard with correct attribution

Troubleshooting

Widget Script Fails to Load (ERR_CONNECTION_REFUSED)

Symptoms:
  • Browser console shows GET https://sandbox.cello.so/widget/cello.js net::ERR_CONNECTION_REFUSED
  • Widget never boots
Root Cause: Script URL format is incorrect (wrong host or path). Solution:
  • ✅ Sandbox: https://assets.sandbox.cello.so/app/latest/cello.js
  • ✅ Production: https://assets.cello.so/app/latest/cello.js
  • ❌ Wrong: https://sandbox.cello.so/widget/cello.js

Widget Script Loads but Doesn’t Boot

Symptoms:
  • Script loads successfully (200 response)
  • No boot success message
  • Widget never appears
Common Causes:
  1. Missing script attributes - Must have type="module", async, crossOrigin="anonymous"
  2. Wrong queue pattern - Use window.cello = { cmd: [] } (object with array)
  3. Wrong boot pattern - Use window.cello.cmd.push(async function(cello) { await cello.boot(config) })

User is Not Authorized to Load the Widget

Symptoms:
  • Script loads successfully
  • Console error: “User is not authorized to load the widget”
Root Cause: JWT payload structure is incorrect. Solution - Use Cello’s custom claim names: WRONG (Standard JWT claims):
{
  "iss": "product-id",
  "sub": "user-123",
  "name": "John Doe",
  "email": "user@example.com",
  "exp": 1662716365
}
CORRECT (Cello’s custom claims):
{
  "productId": "product-id",
  "productUserId": "user-123",
  "iat": 1662712365
}
Key Points:
  • Use productId, NOT iss
  • Use productUserId, NOT sub
  • Do NOT include name, email, exp in JWT
  • User details go in productUserDetails during boot, not JWT
  • Algorithm must be HS512

Dynamic Labels Don’t Appear on First Login (Only After Refresh)

Symptoms:
  • Custom launcher shows fallback text (e.g., “Invite & Earn”) on initial login
  • Labels appear correctly after page refresh
  • Browser console shows successful getLabels call but UI doesn’t update
Root Cause: This is a state management/timing issue. The launcher component renders before labels are fetched, and without proper state communication, the component doesn’t re-render when labels become available. Why It Works After Refresh: On page refresh, the component re-initializes and can immediately access labels that were previously stored (e.g., in window.__celloLabels or state). Solution: Implement a communication mechanism between the bootstrap component (which fetches labels) and the launcher component (which displays them):
  1. Event-Based Pattern (Recommended - framework agnostic):
    • Bootstrap: Fetch labels after boot, store in window.__celloLabels, dispatch CustomEvent
    • Launcher: Listen for event, update state when received
    • Check for existing labels on mount (handles navigation)
  2. React Context Pattern:
    • Create CelloLabelsContext that listens for labels event
    • Wrap authenticated app with provider
    • Launcher consumes context
  3. State Management:
    • Dispatch labels to Redux/Zustand/etc. after boot
    • Launcher subscribes to store
Code Example (Event-Based):
// Bootstrap: Broadcast labels
window.cello.cmd.push(async function(cello) {
  await cello.boot(config)
  const labels = await window.Cello("getLabels")
  
  window.__celloLabels = labels  // Store globally
  window.dispatchEvent(new CustomEvent("cello:labels:ready", { detail: labels }))
})

// Launcher: Listen for labels
useEffect(() => {
  // Check if already available
  if (window.__celloLabels?.customLauncher) {
    setLabelText(window.__celloLabels.customLauncher)
  }
  
  // Listen for event
  const handler = (e: CustomEvent) => setLabelText(e.detail?.customLauncher)
  window.addEventListener("cello:labels:ready", handler)
  return () => window.removeEventListener("cello:labels:ready", handler)
}, [])
Reference: See detailed implementation patterns in Step 2.3.D - Dynamic Reward Labels

Attribution Script Fails to Load

Symptoms:
  • window.CelloAttribution never becomes available
  • Network tab shows 404 for attribution script
Root Cause: Attribution script URL is incorrect. Solution:
  • ✅ Sandbox: https://assets.sandbox.cello.so/attribution/latest/cello-attribution.js
  • ✅ Production: https://assets.cello.so/attribution/latest/cello-attribution.js
  • ❌ Wrong: https://sandbox.cello.so/attribution/cello-attribution.js
Also check:
  • Script must have data-product-id attribute
  • Script must have type="module" and async attributes

Cello API Returns 404

Symptoms:
  • Server logs show 404 for /token or /events
  • No referral validation or event emission
Root Cause: API base URL or endpoint paths are incorrect. Solution: Correct Base URLs:
  • ✅ Production: https://api.cello.so
  • ✅ Sandbox: https://api.sandbox.cello.so
  • ❌ Wrong: https://sandbox.api.cello.so (subdomain order reversed!)
Correct Endpoints (no /v1/ prefix):
  • POST /token
  • POST /events
  • GET /referral-codes/{code}
  • ❌ Wrong: /v1/token, /v1/events

Access Token is Undefined

Symptoms:
  • Token request succeeds (200 status)
  • But cachedToken.token is undefined
  • All subsequent API calls fail with 401
Root Cause: Reading the wrong field from the token response. Solution: WRONG:
cachedToken = {
  token: data.token,  // Wrong field name!
}
CORRECT:
cachedToken = {
  token: data.accessToken,  // Correct field name
}
The Cello API returns accessToken, not token.

Event Emission Returns 400 (Validation Error)

Symptoms:
  • Server logs show Response status: 400
  • Error: {"message":"Validation error: Missing newUserId"}
Root Cause: Event payload structure doesn’t match Cello’s API spec. Solution: WRONG (newUserId in wrong location):
{
  "payload": { "ucc": "code123" },
  "context": { "newUserId": "user-123" }
}
WRONG (flat fields instead of nested object):
{
  "context": {
    "newUserEmail": "user@email.com",
    "newUserFullName": "John Doe"
  }
}
CORRECT (per official API docs):
{
  "eventName": "ReferralUpdated",
  "payload": {
    "ucc": "code123",
    "newUserId": "user-123"
  },
  "context": {
    "newUser": {
      "id": "user-123",
      "email": "user@email.com",
      "name": "John Doe"
    },
    "event": {
      "trigger": "new-signup",
      "timestamp": "2024-01-01T00:00:00Z"
    }
  }
}
Key Points:
  • newUserId must be in payload, NOT context
  • User details go in context.newUser as nested object
  • Use name, NOT fullName

Hydration Mismatch Errors

Symptoms:
  • React console warning about hydration mismatch
  • Error mentions attributes like data-feedly-mini
Root Cause: Browser extensions modifying the DOM before React hydrates. Solution: This is not a Cello integration issue. Options:
  1. Ignore the warning (functionality still works)
  2. Test in incognito mode (extensions disabled)
  3. Disable the offending browser extension

Attribution Methods Return undefined Despite Script Loading

Symptoms:
  • Attribution script loads successfully (200 response)
  • window.CelloAttribution is a function
  • Console logs show {ucc: undefined, referrerName: undefined}
  • But manual calls in console work: await window.CelloAttribution("getUcc") returns data
Root Cause: Using incorrect queue snippet or calling methods before SDK initializes URL parsing. Solution: WRONG (Simple queue snippet):
window.CelloAttribution = window.CelloAttribution || function(){
  (window.CelloAttribution.q = window.CelloAttribution.q || []).push(arguments)
};
CORRECT (Cello’s official queue function):
window.CelloAttribution=window.CelloAttribution||function(t,...o){
  if("getReferral"===t)throw new Error("getReferral is not supported. Use getUcc instead.");
  let e,n;const i=new Promise((t,o)=>{e=t,n=o});
  return window.CelloAttributionCmd=window.CelloAttributionCmd||[],
  window.CelloAttributionCmd.push({command:t,args:o,resolve:e,reject:n}),i
}
Why It Matters:
  • Simple queue doesn’t return promises - calls execute but results aren’t captured
  • Official queue function returns promises and properly resolves them when script loads
  • No polling or delays needed with the correct queue function
Reference: Cello Attribution Docs - Queue Function

Phase 1 vs Phase 2 Feature Matrix

Use this matrix when creating your integration plan to ensure you include all critical features and identify which enhancements to prioritize.

Phase 1: Mandatory Core Features

FeatureStepWhy Mandatory
JWT Generator1.2.ARequired for widget authentication
Script URL Resolvers1.2.B, 3.3.ARequired to load cello.js and cello-attribution.js
Boot Configuration1.2.CRequired to initialize widget
Widget Token Endpoint2.3.ARequired for authenticated boot
Widget Bootstrap Component2.3.CRequired to boot widget
Widget Scripts in Layout2.3.BRequired to load widget SDK
Custom Launcher2.3.DOptional launcher, but…
→ Dynamic Labels2.3.DREQUIRED if using custom launcher
Attribution Scripts3.3.BRequired to track referral codes
Attribution Hook3.3.CRequired to read referral codes
API Authentication4.3.ARequired for event emission
Event Emission4.3.BRequired to track signups
Signup Form Integration4.3.ERequired to pass referral code
Database Support4.3.CRequired to store referral codes
Stripe Metadata5.3.ARequired for payment attribution

Phase 2: Optional Enhancements

FeatureBenefitPriority
Environment ValidationCatch config issues earlyHigh
Detailed API LoggingDebug production issuesHigh
Retry LogicHandle transient failuresHigh
Referral Code ValidationPrevent invalid codes, cleaner dataHigh
Error State TrackingBetter debuggingMedium
Loading StatesBetter UXMedium
Enhanced Signup BannerDisplay referrer name + discount offersMedium
Campaign ConfigurationFetch discount/campaign dataMedium
Status IndicatorsDevelopment debuggingLow

Critical Distinction

Custom Launcher Labels:
  • Custom launcher itself = Optional (can use Cello’s default floating button)
  • Dynamic labels for custom launcher = REQUIRED if you implement custom launcher
Error Handling:
  • Basic error logging = Part of Phase 1 code (console.error)
  • Enhanced error handling = Phase 2 (retry logic, detailed logs, environment validation)
Attribution:
  • Basic UCC tracking = Phase 1 (required)
  • Campaign configuration with discount display = Phase 2 (optional enhancement)

Planning Checklist

When creating your integration plan: Phase 1 Plan Must Include:
  • All items marked “Mandatory” in matrix above
  • Dynamic labels IF implementing custom launcher
  • Basic error handling (console.error for failures)
  • All acceptance criteria from each step
Phase 2 Plan Should Consider:
  • Environment validation (prevents silent failures)
  • Retry logic (improves reliability)
  • Detailed logging (enables debugging)
  • Loading states (improves UX)
  • Enhanced personalization (improves conversion)
Questions to Ask Before Starting:
  1. Are we using a custom launcher or Cello’s default? (If custom → add dynamic labels to Phase 1)
  2. Do we need campaign-specific features? (If yes → add to Phase 2)
  3. What’s our error monitoring strategy? (Determines Phase 2 priority)
  4. When do we plan to go to production? (If soon → prioritize Phase 2 error handling)

Quick Reference

Script URLs

EnvironmentWidget ScriptAttribution Script
Sandboxhttps://assets.sandbox.cello.so/app/latest/cello.jshttps://assets.sandbox.cello.so/attribution/latest/cello-attribution.js
Productionhttps://assets.cello.so/app/latest/cello.jshttps://assets.cello.so/attribution/latest/cello-attribution.js

API Base URLs

EnvironmentBase URL
Sandboxhttps://api.sandbox.cello.so
Productionhttps://api.cello.so

API Endpoints

PurposeMethodEndpoint
Get access tokenPOST/token
Validate referral codeGET/referral-codes/{code}
Send eventPOST/events

Token Response

{
  "accessToken": "eyJ...",  // Use this field, NOT "token"
  "expiresIn": 3600
}

JWT Structure

{
  "productId": "your-product-id",
  "productUserId": "user-123",
  "iat": 1662712365
}

Event Payload Structure

{
  "eventName": "ReferralUpdated",
  "payload": {
    "ucc": "referral-code",
    "newUserId": "user-123"
  },
  "context": {
    "newUser": {
      "id": "user-123",
      "email": "user@email.com",
      "name": "John Doe"
    },
    "event": {
      "trigger": "new-signup",
      "timestamp": "2024-01-01T00:00:00Z"
    }
  }
}