Skip to main content

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:
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.

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, 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 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

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

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

  • 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 with Default Launcher

📦 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):
  • 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 default floating launcher button
  • Notification badges appear on the launcher (test with your own referral link)
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):
  • 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
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.

E. 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

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)
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:
  1. Capture (Public Pages): cello-attribution.js captures UCC from URL parameter (?ucc=CODE)
  2. Save (Signup): Your signup handler saves UCC to database field user.referralCode
  3. 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

B. Create or Update Stripe Customer with Metadata

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.

E. Configure Stripe Webhook in Cello Portal

Once the integration is complete, have the user complete these steps:
  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
  • Reading user data like referral_code from edge functions is working correctly

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. 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

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.