Cello + Lovable Detailed Integration Guide
A step-by-step guide to integrating Cello’s core referral system with the default launcher UI. When complete, you’ll have a fully functional referral system with Cello’s built-in floating action button.
Estimated Time: 1 hour
📋 What This Guide Accomplishes
By the end of this guide, you will have:
✅ Working Referral Widget - Users can access their referral link via Cello’s default floating button
✅ Attribution Tracking - Referral codes captured from URL parameters
✅ Signup Event Tracking - New signups attributed to referrers
✅ Stripe Integration - Purchases attributed for commission tracking
✅ End-to-End Flow - Complete referral journey from link click to conversion
Optional enhancements (not covered here): Custom launcher UI, signup personalization banner, referral code validation, and enhanced error handling. See the Referral Component and Custom Launcher docs for customization.
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.
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, and be saved in the database. Note: Ensure your server-side functions have appropriate database access to fetch user referral codes. In serverless environments, you may need admin/service credentials rather than user-scoped credentials. |
| 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
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
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
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
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. This guide uses Cello’s default floating action button (FAB) - no custom launcher implementation needed. For a custom launcher, see Custom Launcher.
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).
In your authenticated layout, add the Cello queue snippet and script:
// Authenticated layout component
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 */}
<script
src={celloScriptUrl}
type="module"
async
crossOrigin="anonymous"
/>
<CelloBootstrap />
{children}
</body>
</html>
)
}
CRITICAL Script Attributes:
type="module" - Required
async - Required
crossOrigin="anonymous" - Required
- Do NOT include
data-product-id on the widget script
C. Create Bootstrap Component
First, add TypeScript declarations for the Cello SDK. Put them in a declaration file (e.g. types/cello.d.ts) or at the top of your component file.
Do not import .d.ts files. TypeScript declaration files (.d.ts) are included automatically by the compiler. If you add import ... from '@/types/cello' or similar, Vite (and many bundlers) will fail because they resolve modules by file and cannot load .d.ts as a module. Use a .d.ts file and let TypeScript pick it up via your tsconfig (no import), or paste the declarations directly in your component.
// types/cello.d.ts — do NOT import this file; TypeScript includes it automatically
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
}
2.4 Acceptance Criteria
Basic Widget Functionality (Mandatory):
Use Cello’s default floating action button.
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
- Public layout/pages (landing page, signup page)
3.3 Integration Description
A. Attribution Script URL Helper
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
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).
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 */}
<script
src={attributionScriptUrl}
type="module"
async
crossOrigin="anonymous"
/>
{children}
</body>
</html>
)
}
CRITICAL:
- 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. Use a declaration file (e.g. types/cello-attribution.d.ts) or the top of your hook file. Do not import the .d.ts file — TypeScript includes it automatically; importing it will break Vite and other bundlers.
// types/cello-attribution.d.ts — do NOT import this file; TypeScript includes it automatically
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
The hook fetches referrerName and campaignConfig for completeness; you can use them for a signup banner (see Personalizing Referrals).
3.4 Acceptance Criteria
Core Attribution (Mandatory):
Displaying referrer name and campaign discount on the signup page is optional; see Personalizing Referrals.
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. 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
}
D. Update Signup Handler
// 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
}
This guide stores referral codes without validation. For validation before storing, see Fetch Referral Code Info.
// 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
Core Event Tracking (Mandatory):
Referral code validation before storing is optional; see the API reference above.
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. UCC Data Flow (Critical)
Understanding how referral codes flow through your system is critical for proper attribution.
The Complete Flow:
- Capture (Public Pages):
cello-attribution.js captures UCC from URL parameter (?ucc=CODE)
- Save (Signup): Your signup handler saves UCC to database field
user.referralCode
- Read (Checkout): Your checkout function reads UCC from database to add to Stripe metadata
⚠️ Critical: Read from Database at Checkout
At checkout time, ALWAYS read the referral code from your database, NOT from cookies or frontend.
Why?
- Attribution cookies have limited lifetime (typically 30-90 days)
- Users may checkout days or weeks after signup
- Cookies may be cleared by the user
- Database is the source of truth for stored referral codes
✅ CORRECT Pattern:
// In your checkout/subscription creation function
export async function createCheckoutSession(userId: string, priceId: string) {
// 1. Fetch user data from database (including referral code)
const user = await getUserById(userId)
// 2. Read referral code from database field
const referralCode = user.referralCode || ""
// 3. Create or update Stripe customer with Cello metadata
const customer = await createOrUpdateStripeCustomer({
...user,
referralCode, // From database, not from cookie
})
// 4. Create checkout session
// ...
}
❌ WRONG Pattern:
// DON'T: Rely on frontend passing UCC at checkout
export async function createCheckoutSession(req) {
const { userId, ucc } = await req.json() // ❌ Cookie may have expired
const customer = await stripe.customers.create({
metadata: {
celo_ucc: ucc, // ❌ May be null even though user signed up with referral
}
})
}
Key Takeaway:
- Signup: Save UCC to database (✓ covered in Step 4)
- Checkout: Read UCC from database (not from cookies/frontend)
- Database is your persistent source of truth for referral attribution
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 createOrUpdateStripeCustomer(user: User) {
// Check if customer already exists
const existingCustomers = await stripe.customers.list({
email: user.email,
limit: 1,
})
const metadata = {
userId: user.id,
// Cello-specific metadata for commission tracking
cello_ucc: user.referralCode || "",
new_user_id: user.id,
}
if (existingCustomers.data.length > 0) {
// Update existing customer
return stripe.customers.update(existingCustomers.data[0].id, {
name: user.name,
metadata,
})
} else {
// Create new customer
return stripe.customers.create({
email: user.email,
name: user.name,
metadata,
})
}
}
Required Metadata Keys:
cello_ucc - The referral code (UCC)
new_user_id - Your internal user ID
Important: Always update metadata if the customer already exists. This ensures referral attribution is preserved even if users had a Stripe customer record before signing up with a referral code.
C. Stripe Checkout Sessions (Subscription Mode)
If using Stripe Checkout Sessions with mode: "subscription", you must create the customer first with metadata, then pass the customer ID to the session.
⚠️ Important Limitations:
customer_creation: "always" only works in payment mode, NOT subscription mode
subscription_data.metadata sets metadata on the subscription, not the customer
- Cello’s webhook reads from customer metadata, not subscription metadata
✅ Correct Pattern for Checkout Sessions:
export async function createSubscriptionCheckout(user: User, priceId: string) {
// 1. Create or retrieve customer FIRST with Cello metadata
let customer = await findExistingCustomer(user.email)
if (!customer) {
customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId: user.id,
cello_ucc: user.referralCode || "",
new_user_id: user.id,
},
})
} else {
// Update existing customer with Cello metadata
customer = await stripe.customers.update(customer.id, {
metadata: {
userId: user.id,
cello_ucc: user.referralCode || "",
new_user_id: user.id,
},
})
}
// 2. Pass customer ID to checkout session
const session = await stripe.checkout.sessions.create({
customer: customer.id, // ✅ Use customer ID, not customer_email
mode: "subscription",
line_items: [{
price: priceId,
quantity: 1,
}],
success_url: `${process.env.APP_URL}/success`,
cancel_url: `${process.env.APP_URL}/cancel`,
})
return session
}
❌ Common Mistakes:
// DON'T: This doesn't work in subscription mode
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
customer_creation: "always", // Only works in payment mode!
mode: "subscription",
// ...
})
// DON'T: This sets metadata on subscription, not customer
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
mode: "subscription",
subscription_data: {
metadata: { // ❌ Cello can't read this
cello_ucc: user.referralCode,
}
},
// ...
})
Key Points:
- Always create/update the customer before creating the checkout session
- Pass
customer: customer.id to the session, not customer_email
- Cello metadata MUST be on the customer object, not the subscription
D. Edge Function Database Access
Issue: When reading user data (like referral_code) from edge functions, the Supabase client created with SUPABASE_ANON_KEY cannot bypass RLS policies. Even after verifying a user’s JWT with auth.getUser(token), subsequent queries run with auth.uid() = null, causing RLS to block the read.
Solution: Use the SUPABASE_SERVICE_ROLE_KEY for server-side reads that need to bypass RLS:
// ❌ WRONG - RLS blocks this (auth.uid() is null)
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL"),
Deno.env.get("SUPABASE_ANON_KEY")
);
const { data: profile } = await supabaseClient
.from("profiles")
.select("referral_code")
.eq("id", user.id)
.single(); // Returns null!
// ✅ CORRECT - Service role bypasses RLS
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL"),
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")
);
const { data: profile } = await supabaseAdmin
.from("profiles")
.select("referral_code")
.eq("id", user.id)
.single(); // Works!
Only use the service role key for trusted server-side operations, never expose it client-side.
Once the integration is complete, have the user complete these steps:
- 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.
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
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
type="module" and async attributes
- Queue snippet MUST load before the attribution script
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
- ❌ 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:
{
"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
You now have a fully functional referral system with Cello’s default UI. For customization (custom launcher, signup banner, referral code validation), see the Referral Component and Custom Launcher docs.