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
| Requirement | Description |
|---|
| Authentication System | Your app must have user authentication with session/token management |
| User Model | Users must have unique IDs, email addresses, and names |
| Server-Side Rendering | Support for SSR or hybrid rendering (Next.js, Remix, etc.) |
| API Routes | Ability 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
- Create an account at cello.so
- Create a product and note the
Product ID and Product Secret
- Generate API access keys for server-side calls
- Configure your reward structure and campaigns
Cello Scripts Overview
Cello provides two separate JavaScript SDKs for different purposes:
| Script | Purpose | Used In | Users |
|---|
| cello.js | Referral widget for authenticated users | Step 2 | Authenticated users only |
| cello-attribution.js | Track referral codes from URL | Step 3 | Public 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
📦 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:
- User logs in
- Launcher component renders with fallback text
- Bootstrap fetches labels asynchronously
- Labels are fetched but component doesn’t know about them
- 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:
- Boot Cello in bootstrap component
- After boot completes, fetch labels via
window.Cello("getLabels")
- Store labels in shared state (global variable, context, or state management)
- Notify components that labels are ready (custom event, callback, or state update)
- 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):
- ✅ Always provide fallback text for initial render
- ✅ Store labels in shared state (global variable, context, store)
- ✅ Use event/callback/subscription to notify components
- ✅ Check for existing labels on component mount (handles navigation)
- ✅ 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):
Phase 1 - Custom Launcher with Dynamic Labels (REQUIRED If Using Custom Launcher):
Phase 2 - Enhanced Error Handling & Loading States (Optional but Recommended):
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):
Phase 2 - Enhanced Attribution (Optional):
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.
// 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):
Phase 2 - Referral Code Validation (Optional but Recommended):
Phase 2 - Enhanced Logging (Optional but Recommended):
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
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
- Go to Cello Portal → Integrations → Stripe
- Connect your Stripe account
- Cello will automatically create webhooks for:
checkout.session.completed
customer.subscription.created
customer.subscription.updated
invoice.paid
charge.refunded
5.4 Acceptance Criteria
Troubleshooting
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
Symptoms:
- Script loads successfully (200 response)
- No boot success message
- Widget never appears
Common Causes:
- Missing script attributes - Must have
type="module", async, crossOrigin="anonymous"
- Wrong queue pattern - Use
window.cello = { cmd: [] } (object with array)
- Wrong boot pattern - Use
window.cello.cmd.push(async function(cello) { await cello.boot(config) })
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):
-
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)
-
React Context Pattern:
- Create CelloLabelsContext that listens for labels event
- Wrap authenticated app with provider
- Launcher consumes context
-
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:
- Ignore the warning (functionality still works)
- Test in incognito mode (extensions disabled)
- 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
| Feature | Step | Why Mandatory |
|---|
| JWT Generator | 1.2.A | Required for widget authentication |
| Script URL Resolvers | 1.2.B, 3.3.A | Required to load cello.js and cello-attribution.js |
| Boot Configuration | 1.2.C | Required to initialize widget |
| Widget Token Endpoint | 2.3.A | Required for authenticated boot |
| Widget Bootstrap Component | 2.3.C | Required to boot widget |
| Widget Scripts in Layout | 2.3.B | Required to load widget SDK |
| Custom Launcher | 2.3.D | Optional launcher, but… |
| → Dynamic Labels | 2.3.D | REQUIRED if using custom launcher |
| Attribution Scripts | 3.3.B | Required to track referral codes |
| Attribution Hook | 3.3.C | Required to read referral codes |
| API Authentication | 4.3.A | Required for event emission |
| Event Emission | 4.3.B | Required to track signups |
| Signup Form Integration | 4.3.E | Required to pass referral code |
| Database Support | 4.3.C | Required to store referral codes |
| Stripe Metadata | 5.3.A | Required for payment attribution |
Phase 2: Optional Enhancements
| Feature | Benefit | Priority |
|---|
| Environment Validation | Catch config issues early | High |
| Detailed API Logging | Debug production issues | High |
| Retry Logic | Handle transient failures | High |
| Referral Code Validation | Prevent invalid codes, cleaner data | High |
| Error State Tracking | Better debugging | Medium |
| Loading States | Better UX | Medium |
| Enhanced Signup Banner | Display referrer name + discount offers | Medium |
| Campaign Configuration | Fetch discount/campaign data | Medium |
| Status Indicators | Development debugging | Low |
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:
Phase 2 Plan Should Consider:
Questions to Ask Before Starting:
- Are we using a custom launcher or Cello’s default? (If custom → add dynamic labels to Phase 1)
- Do we need campaign-specific features? (If yes → add to Phase 2)
- What’s our error monitoring strategy? (Determines Phase 2 priority)
- When do we plan to go to production? (If soon → prioritize Phase 2 error handling)
Quick Reference
Script URLs
| Environment | Widget Script | Attribution Script |
|---|
| Sandbox | https://assets.sandbox.cello.so/app/latest/cello.js | https://assets.sandbox.cello.so/attribution/latest/cello-attribution.js |
| Production | https://assets.cello.so/app/latest/cello.js | https://assets.cello.so/attribution/latest/cello-attribution.js |
API Base URLs
| Environment | Base URL |
|---|
| Sandbox | https://api.sandbox.cello.so |
| Production | https://api.cello.so |
API Endpoints
| Purpose | Method | Endpoint |
|---|
| Get access token | POST | /token |
| Validate referral code | GET | /referral-codes/{code} |
| Send event | POST | /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"
}
}
}