Skip to main content

Upsell & Downsell Funnel

Time: ~10 minutes | Difficulty: Intermediate Build a complete post-purchase offer flow using the Node SDK. Customers check out, see an upsell offer, and either accept it (→ thank you) or decline it (→ cheaper downsell offer → thank you).
Checkout → Upsell $99 ──(accepted)──→ Thank You
                        ──(declined)──→ Downsell $19 → Thank You
This tutorial uses native checkout pages — no custom HTML or Plugin SDK deployment needed. TagadaPay auto-injects checkout, offer, and thank you pages when no plugin is assigned to a step.

Prerequisites

RequirementHow to get it
Node.js 18+nodejs.org
@tagadapay/node-sdknpm install @tagadapay/node-sdk
API keyDashboard → Settings → API Keys
A store with at least one payment processorMerchant Quick Start

What You’ll Build

A 4-step funnel with conditional routing based on offer acceptance:
StepTypePurpose
CheckoutcheckoutPayment page — collects card info, processes the initial order
UpsellofferPost-purchase offer — VIP Coaching Pack ($99)
DownsellofferFallback offer if upsell is declined — Quick-Start Guide ($19)
Thank YouthankyouOrder confirmation — fires conversion events
The edges between steps use conditions to route the customer:
  • { when: 'offer.accepted' } — customer accepted the offer
  • { when: 'offer.declined' } — customer declined the offer

Step 1: Initialize the SDK

import Tagada from '@tagadapay/node-sdk';

const tagada = new Tagada('your-api-key');
const STORE_ID = 'store_...';

Step 2: Create products

You need three products — one for the main checkout, one for the upsell, one for the downsell.
const mainProduct = await tagada.products.create({
  storeId: STORE_ID,
  name: 'Premium Course',
  description: 'The main product customers purchase at checkout.',
  active: true,
  isShippable: false,
  isTaxable: false,
  variants: [{
    name: 'Default',
    sku: 'COURSE-001',
    grams: null,
    active: true,
    default: true,
    price: null,
    compareAtPrice: null,
    prices: [{
      currencyOptions: { USD: { amount: 4900 } },
      recurring: false,
      billingTiming: 'usage',
      interval: null,
      intervalCount: 1,
      default: true,
    }],
  }],
});

const upsellProduct = await tagada.products.create({
  storeId: STORE_ID,
  name: 'VIP Coaching Pack',
  active: true,
  isShippable: false,
  isTaxable: false,
  variants: [{
    name: 'Default',
    sku: 'VIP-001',
    grams: null,
    active: true,
    default: true,
    price: null,
    compareAtPrice: null,
    prices: [{
      currencyOptions: { USD: { amount: 9900 } },
      recurring: false,
      billingTiming: 'usage',
      interval: null,
      intervalCount: 1,
      default: true,
    }],
  }],
});

const downsellProduct = await tagada.products.create({
  storeId: STORE_ID,
  name: 'Quick-Start Guide',
  active: true,
  isShippable: false,
  isTaxable: false,
  variants: [{
    name: 'Default',
    sku: 'GUIDE-001',
    grams: null,
    active: true,
    default: true,
    price: null,
    compareAtPrice: null,
    prices: [{
      currencyOptions: { USD: { amount: 1900 } },
      recurring: false,
      billingTiming: 'usage',
      interval: null,
      intervalCount: 1,
      default: true,
    }],
  }],
});
Prices are in cents4900 = $49.00. The currencyOptions object lets you define amounts per currency.

Step 3: Create checkout offers

Checkout offers are standalone resources that you bind to funnel steps. Each offer references product line items via their priceId. Use tagada.checkoutOffers.create() — this creates offers in the checkout offers table, which the native offer page can render.
const upsellOffer = await tagada.checkoutOffers.create({
  storeId: STORE_ID,
  type: 'upsell',
  titleTrans: { en: 'Upgrade to VIP Coaching Pack' },
  lineItems: [{
    priceId: upsellProduct.variants[0].prices[0].id,
    quantity: 1,
  }],
});

const downsellOffer = await tagada.checkoutOffers.create({
  storeId: STORE_ID,
  type: 'upsell',
  titleTrans: { en: 'Get the Quick-Start Guide instead' },
  lineItems: [{
    priceId: downsellProduct.variants[0].prices[0].id,
    quantity: 1,
  }],
});

Verify and list offers

// Retrieve a single offer
const offer = await tagada.checkoutOffers.retrieve(upsellOffer.id);
console.log('Offer title:', offer.offer.titleTrans.en);

// List all offers for the store
const allOffers = await tagada.checkoutOffers.list({ storeId: STORE_ID });
console.log('Total offers:', allOffers.offers.length);
Both upsell and downsell use type: 'upsell' — they are post-purchase offers. For order bumps shown on the checkout page (before payment), use tagada.offers.create() with type: 'orderbump'.

Step 4: Create the funnel with conditional edges

This is where the magic happens. Define four nodes and five edges with conditions.when to control the flow.
const funnel = await tagada.funnels.create({
  storeId: STORE_ID,
  config: {
    name: 'Upsell/Downsell Flow',
    version: '1.0.0',
    nodes: [
      {
        id: 'step_checkout',
        name: 'Checkout',
        kind: 'step',
        type: 'checkout',
        isEntry: true,
        position: { x: 0, y: 150 },
        index: 0,
        config: {
          pagePath: '/checkout',
          metadata: { internalPath: '/checkout' },
          stepConfig: {
            orderBumps: { mode: 'inherit' },
          },
        },
      },
      {
        id: 'step_upsell',
        name: 'Upsell — VIP Coaching',
        kind: 'step',
        type: 'offer',
        position: { x: 300, y: 150 },
        index: 1,
        config: {
          pagePath: '/upsell',
          metadata: { internalPath: '/offer' },
          stepConfig: {
            resources: { offer: upsellOffer.id },
          },
        },
      },
      {
        id: 'step_downsell',
        name: 'Downsell — Quick-Start Guide',
        kind: 'step',
        type: 'offer',
        position: { x: 600, y: 300 },
        index: 2,
        config: {
          pagePath: '/downsell',
          metadata: { internalPath: '/offer' },
          stepConfig: {
            resources: { offer: downsellOffer.id },
          },
        },
      },
      {
        id: 'step_thankyou',
        name: 'Thank You',
        kind: 'step',
        type: 'thankyou',
        position: { x: 900, y: 150 },
        index: 3,
        config: {
          pagePath: '/thankyou/:orderId',
          metadata: { internalPath: '/thankyou/:orderId' },
        },
      },
    ],
    edges: [
      // Checkout → Upsell (always, after payment)
      {
        id: 'e_checkout_to_upsell',
        source: 'step_checkout',
        target: 'step_upsell',
        label: 'After payment',
      },
      // Upsell accepted → Thank You
      {
        id: 'e_upsell_accepted',
        source: 'step_upsell',
        target: 'step_thankyou',
        label: 'Offer accepted',
        conditions: { when: 'offer.accepted' },
      },
      // Upsell declined → Downsell
      {
        id: 'e_upsell_declined',
        source: 'step_upsell',
        target: 'step_downsell',
        label: 'Offer declined',
        conditions: { when: 'offer.declined' },
      },
      // Downsell accepted → Thank You
      {
        id: 'e_downsell_accepted',
        source: 'step_downsell',
        target: 'step_thankyou',
        label: 'Downsell accepted',
        conditions: { when: 'offer.accepted' },
      },
      // Downsell declined → Thank You
      {
        id: 'e_downsell_declined',
        source: 'step_downsell',
        target: 'step_thankyou',
        label: 'Downsell declined',
        conditions: { when: 'offer.declined' },
      },
    ],
  },
  isDefault: false,
});

How conditional edges work

Each offer step has two possible outcomes: accepted or declined. The orchestrator checks the edge conditions to decide where to send the customer:
step_upsell
  ├── conditions: { when: 'offer.accepted' }  → step_thankyou   (customer took the VIP pack)
  └── conditions: { when: 'offer.declined' }  → step_downsell   (show cheaper alternative)
If both edges point to the same target (e.g., downsell → thank you regardless of outcome), the customer always moves forward but the system still tracks what they chose.

Step 5: Activate the funnel

Creating a funnel defines the structure. Updating it triggers the routing engine — routes get mounted to CDN, pages become reachable.
const result = await tagada.funnels.update(funnel.id, {
  storeId: STORE_ID,
  config: funnel.config,
});

// The checkout URL is now live
const checkoutUrl = result.funnel.config.nodes
  .find(n => n.id === 'step_checkout').config.url;

console.log('Checkout live at:', checkoutUrl);
TagadaPay detects that your nodes have no pluginId and automatically injects the native checkout plugin. The offer pages show the bound offer product, and the thank you page shows the order confirmation — no setup needed.

Step 6: Verify and manage the funnel

Retrieve a funnel

const retrieved = await tagada.funnels.retrieve(funnel.id);
const nodes = retrieved.config.nodes;
const edges = retrieved.config.edges;

const upsellNode = nodes.find(n => n.id === 'step_upsell');
console.log('Upsell offer:', upsellNode.config.stepConfig.resources.offer);

const downsellNode = nodes.find(n => n.id === 'step_downsell');
console.log('Downsell offer:', downsellNode.config.stepConfig.resources.offer);

List all funnels

const funnels = await tagada.funnels.list(STORE_ID);
console.log(`${funnels.funnels.length} funnels in store`);

Delete a funnel

await tagada.funnels.del(funnel.id, STORE_ID);
console.log('Funnel deleted');

Delete checkout offers

await tagada.checkoutOffers.del({
  offerIds: [upsellOffer.id, downsellOffer.id],
  storeId: STORE_ID,
});

Complete Flow Diagram

┌──────────────┐     ┌────────────────────┐     ┌──────────────┐
│   Checkout   │────▶│   Upsell ($99)     │────▶│  Thank You   │
│   ($49.00)   │     │   VIP Coaching     │     │              │
└──────────────┘     └─────────┬──────────┘     └──────▲───────┘
                        declined│                       │
                               ▼                       │
                     ┌────────────────────┐            │
                     │  Downsell ($19)    │────────────┘
                     │  Quick-Start Guide │
                     └────────────────────┘
Customer paths:
  1. Checkout → Accept upsell → Thank You — Customer pays 49+49 + 99 = $148
  2. Checkout → Decline upsell → Accept downsell → Thank You — Customer pays 49+49 + 19 = $68
  3. Checkout → Decline upsell → Decline downsell → Thank You — Customer pays $49

Edge Conditions Reference

The funnel orchestrator supports a rich set of conditions you can attach to edges via conditions.when. Use string format for simple conditions, or object format for conditions that take parameters.

Offer Conditions

ConditionFormatDescription
offer.acceptedstringCustomer accepted the offer
offer.declinedstringCustomer declined the offer
offer.acceptedWithIdobjectCustomer accepted a specific offer
// Simple: any offer accepted
conditions: { when: 'offer.accepted' }

// Parameterized: specific offer accepted
conditions: { when: { 'offer.acceptedWithId': { offerId: 'offer_xxx' } } }

Payment Conditions

ConditionFormatParamsDescription
payment.successstringPayment succeeded
payment.failedstringPayment failed (error, declined, rejected)
payment.amountGreaterThanobjectamount (cents)Payment amount exceeds threshold
conditions: { when: 'payment.success' }
conditions: { when: 'payment.failed' }
conditions: { when: { 'payment.amountGreaterThan': { amount: 5000 } } }  // > $50

Main Order Conditions

Evaluate the initial checkout order (the first order in the funnel).
ConditionFormatParamsDescription
mainOrder.existsstringMain order was created
mainOrder.hasProductobjectproductId or variantIdsOrder contains a specific product
mainOrder.totalGreaterThanobjectamount (cents)Order total exceeds threshold
conditions: { when: 'mainOrder.exists' }
conditions: { when: { 'mainOrder.hasProduct': { productId: 'prod_xxx' } } }
conditions: { when: { 'mainOrder.totalGreaterThan': { amount: 10000 } } }  // > $100

Last Order Conditions

Evaluate the most recent order (from the current or previous step — could be an upsell or downsell order).
ConditionFormatParamsDescription
lastOrder.existsstringLast order exists
lastOrder.hasProductobjectproductId or variantIdsLast order contains a product
lastOrder.totalGreaterThanobjectamount (cents)Last order total exceeds threshold
conditions: { when: 'lastOrder.exists' }
conditions: { when: { 'lastOrder.hasProduct': { productId: 'prod_xxx' } } }
conditions: { when: { 'lastOrder.totalGreaterThan': { amount: 2000 } } }  // > $20

Customer Tag Conditions

Route based on tags attached to the customer (set via API or automations).
ConditionFormatParamsDescription
customer.hasTagobjecttagCustomer has an exact tag
customer.hasTagPrefixobjectprefixCustomer has a tag starting with prefix
customer.hasAnyTagobjecttags (comma-separated)Customer has at least one of the tags
customer.hasAllTagsobjecttags (comma-separated)Customer has all of the tags
conditions: { when: { 'customer.hasTag': { tag: 'vip' } } }
conditions: { when: { 'customer.hasTagPrefix': { prefix: 'plan' } } }
conditions: { when: { 'customer.hasAnyTag': { tags: 'vip,premium,whale' } } }
conditions: { when: { 'customer.hasAllTags': { tags: 'active,verified' } } }

Customer Geo Conditions

Route based on the customer’s geographic location (detected automatically from IP).
ConditionFormatParamsDescription
customer.fromCountryobjectcountry (ISO code)Customer is from a specific country
customer.fromContinentobjectcontinent (code)Customer is from a continent (EU, NA, SA, AS, AF, OC, AN)
customer.fromEUstringCustomer is from the European Union
conditions: { when: { 'customer.fromCountry': { country: 'US' } } }
conditions: { when: { 'customer.fromContinent': { continent: 'EU' } } }
conditions: { when: 'customer.fromEU' }

Customer Device & Browser Conditions

Route based on the customer’s device, browser, or display mode.
ConditionFormatParamsDescription
customer.onMobilestringCustomer is on a mobile device
customer.withBrowserobjectbrowserBrowser name (chrome, firefox, safari, edge)
customer.withLocaleobjectlocaleBrowser locale (e.g., en-US, fr-FR)
customer.isChromeFamilystringChrome, Edge, Brave, or Opera
customer.isStandalonePWAstringInstalled PWA (standalone mode)
customer.isAppleSiliconstringApple Silicon Mac (M1/M2/M3)
conditions: { when: 'customer.onMobile' }
conditions: { when: { 'customer.withBrowser': { browser: 'safari' } } }
conditions: { when: { 'customer.withLocale': { locale: 'fr-FR' } } }
conditions: { when: 'customer.isChromeFamily' }

Traffic & Bot Conditions

Route based on traffic source or bot detection.
ConditionFormatParamsDescription
customer.fromUtmSourceobjectsourceUTM source (facebook, google, email, etc.)
customer.isBotstringDetected as a bot or crawler
conditions: { when: { 'customer.fromUtmSource': { source: 'facebook' } } }
conditions: { when: 'customer.isBot' }

Generic Conditions

ConditionFormatDescription
alwaysstringAlways evaluates to true — use as fallback
conditions: { when: 'always' }

Edge Priority

When multiple edges leave the same node, use priority to control evaluation order. Higher priority edges are checked first:
edges: [
  {
    id: 'e_vip_path',
    source: 'step_checkout',
    target: 'step_vip_offer',
    conditions: { when: { 'customer.hasTag': { tag: 'vip' } }, priority: 10 },
  },
  {
    id: 'e_default_path',
    source: 'step_checkout',
    target: 'step_standard_offer',
    conditions: { when: 'always', priority: 1 },
  },
]

Going Further

Add an order bump on the checkout step

Order bumps appear on the checkout page, before payment. Use stepConfig.orderBumps with mode: 'custom' to pick specific bumps:
{
  id: 'step_checkout',
  type: 'checkout',
  config: {
    stepConfig: {
      orderBumps: {
        mode: 'custom',
        enabledOfferIds: ['upsell_xxx'],
      },
    },
  },
}

Chain multiple offers

Add more offer steps between checkout and thank you. Each can have independent accept/decline routing:
Checkout → Offer A ──(accepted)──→ Offer B → Thank You
                    ──(declined)──→ Offer C ──(accepted)──→ Thank You
                                             ──(declined)──→ Thank You

Geo-based routing

Show different offers based on customer location:
edges: [
  {
    id: 'e_eu_offer',
    source: 'step_checkout',
    target: 'step_eu_offer',
    conditions: { when: 'customer.fromEU', priority: 10 },
  },
  {
    id: 'e_us_offer',
    source: 'step_checkout',
    target: 'step_us_offer',
    conditions: { when: { 'customer.fromCountry': { country: 'US' } }, priority: 10 },
  },
  {
    id: 'e_default_offer',
    source: 'step_checkout',
    target: 'step_global_offer',
    conditions: { when: 'always', priority: 1 },
  },
]
Use checkout sessions to create pre-loaded cart links:
const session = await tagada.checkout.createSession({
  storeId: STORE_ID,
  items: [{ variantId: mainProduct.variants[0].id, quantity: 1 }],
  currency: 'USD',
  checkoutUrl,
});

console.log('Share this link:', session.redirectUrl);

SDK Methods Reference

Funnels

MethodDescription
tagada.funnels.create(params)Create a funnel with nodes and edges
tagada.funnels.update(id, params)Update config and activate routing
tagada.funnels.retrieve(id)Get a funnel by ID
tagada.funnels.list(storeId)List all funnels for a store
tagada.funnels.del(id, storeId)Delete a funnel and its routes

Checkout Offers

MethodDescription
tagada.checkoutOffers.create(params)Create a post-purchase offer
tagada.checkoutOffers.retrieve(id)Get a checkout offer by ID
tagada.checkoutOffers.list(params)List offers for a store
tagada.checkoutOffers.del(params)Delete one or more offers

Next Steps

Funnel Orchestrator

Deep dive into funnel concepts — nodes, edges, routing, analytics

Step Config Guide

Configure payment methods, pixels, scripts, and order bumps per step

Funnel Pages

Three ways to build pages: native, custom HTML, or Plugin SDK

Merchant Quick Start

Full setup from processor configuration to live checkout