Use Cases

Track your full SaaS funnel (sign-up → trial → purchase)

The complete guide for SaaS products – attribute every stage of your lifecycle back to the ad, post or email that earned it. Track demo calls and free sign-ups, then Trial Started, Trial Ended and the paid Purchase. Purchases come in automatically through Stripe; the in-product milestones are sent with a tiny server route. The key idea: capture the click ID once at sign-up and replay it on every later event.

For a SaaS product the interesting moments are spread out over days or weeks and almost all of them happen inside your app, not on a marketing page: someone books a demo, creates a free account, starts a trial, the trial ends, and – eventually – they pay. This guide wires every one of those stages to LeadJourney so you can see, per campaign, ad set and ad, not just how many sign-ups an ad produced but how many of those turned into trials and paying customers.

Tracking has to be live first

Every stage is matched back to the visitor that clicked your ad, so the tracking script has to be running on your site first. If it isn't, start with Install the tracking script (and the developer notes in Install on a custom-coded website).

The SaaS funnel, mapped to LeadJourney

Pick the stages that exist in your product and skip the rest. Each event name is just the type you send – rename them to whatever your team already says.

Funnel stageEvent typeHow you send itRevenue?
Demo call booked (sales-assisted)BookedCallYour scheduler's webhook (Calendly, …)
Free sign-up / account createdFreeSignupA tiny server route (Conversion API)
Trial startedTrialStartedThe same server route
Trial endedTrialEndedThe same server route (from your billing logic / a cron job)
Purchase (trial converts to paid)WonClientStripe integration – automatic

Two ways to send a stage – and when to use each

  • Purchase → use the Stripe integration. Real money events carry revenue and attribute themselves, with no code. This is the easy one.
  • Demo call → use your scheduler's webhook. Most booking tools (Calendly, SavvyCal, …) can POST to a URL when a call is booked.
  • Free sign-up, Trial Started, Trial Ended → send them yourself with a small server route. These are in-product state changes only your app knows about, so you define the trigger and post the event to LeadJourney's Conversion API.

The one idea that makes SaaS tracking work

Here's the catch that's unique to SaaS, and the single most important thing in this guide:

The click ID lives in the browser, at sign-up. But Trial Started, Trial Ended and Purchase happen later – often server-side, in a webhook or a nightly cron, with no browser and no localStorage in sight.

So you can't read the click ID again when those later events fire. The fix is simple:

Capture the click ID once, store it on the user, replay it forever

At sign-up, read localStorage.getItem('clickId') and save it on the user/account record in your own database (a column like lj_click_id). Every later lifecycle event – Trial Started, Trial Ended, even a manual upgrade – just reads that stored value back and sends it along. One capture, reused for the whole customer journey.

Email is your safety net

LeadJourney also matches events on email. Always send the user's email with every event, so even if a click ID was never captured (an organic or direct sign-up) or got lost, the stages still stitch onto the same person – and can be attributed later if that person clicked a tracked link before. See How attribution works.

Before you start

Grab your Conversion API credentials from Settings → Events → Conversion API:

  • Your Postback URL – looks like https://api.leadjourney.io/api/v1/postback/<your-id>.
  • Your Postback Secret – starts with pb_…. Treat it like a password; it stays on your server and is never exposed to the browser.

Then create the events you'll use in the Events Manager. WonClient exists in every workspace by default; add the in-product ones:

EventSuggested modeNotes
FreeSignupIgnore duplicate postbacksOne per user – drops accidental repeats.
TrialStartedIgnore duplicate postbacksA user starts a trial once.
TrialEndedIgnore duplicate postbacksTurn Send back to Ads off – it's a drop-off signal, not a conversion.
WonClientEdit conversion with new dataTurn Include Revenue Value on.

Why “Ignore duplicate postbacks” matters here

Lifecycle events are often sent from retried webhooks or a cron that re-scans users, so the same event can fire more than once for the same person. Setting these to Ignore duplicate postbacks keeps your funnel counts honest. See the mode table in Events & the Events Manager.

Step by step

Capture and store the click ID at sign-up

When a new account is created, read the click ID from the browser and persist it on the user. Do this once – it's what every later stage depends on.

// In your sign-up handler, the moment the account is created
const clickId = localStorage.getItem("clickId") || "";

// Save it on the user record (pseudo-code – use your own ORM / DB)
await db.users.update(newUser.id, { lj_click_id: clickId });

SSO sign-ups (Google / Microsoft)

With Google or Microsoft sign-up the account is created on a callback, where localStorage may be gone. Carry the click ID through the OAuth state parameter and store it the same way – the full pattern is in Track sign-ups in your app (incl. Google & Microsoft SSO).

Add one server route that sends any lifecycle event

This single route forwards an event to LeadJourney and keeps your Postback Secret safe on the server. The example is a Next.js App Router route; any backend (Express, a serverless function) works the same way.

// app/api/lj-event/route.js
export async function POST(req) {
  const d = await req.json();

  await fetch(
    "https://api.leadjourney.io/api/v1/postback/YOUR-POSTBACK-ID",
    {
      method: "POST",
      headers: {
        "Authorization": "Bearer YOUR_POSTBACK_SECRET",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        type: d.type,              // "FreeSignup" | "TrialStarted" | "TrialEnded" | "WonClient"
        click_id: d.click_id,      // the value you stored at sign-up
        email: d.email,            // always send this – it's the durable match key
        firstname: d.firstname,
        lastname: d.lastname,
        tag: d.tag,                // optional source/campaign tag, a fixed word is fine
        revenue: d.revenue,        // only for WonClient – leave out otherwise
      }),
    }
  );

  return Response.json({ ok: true });
}

Use your own URL and secret

Replace YOUR-POSTBACK-ID and YOUR_POSTBACK_SECRET with the Postback URL and Postback Secret from your own Settings → Events → Conversion API. Never put the secret in client-side code or commit it to a public repo.

Fire each in-product stage

At each lifecycle change, call the route with the right type. Because you stored the click ID at sign-up, every call can include it – no browser needed.

Fire once, right after the account is created (same place you stored the click ID). If you also read the click ID straight from localStorage here, you don't even need it from the DB yet.

await fetch("/api/lj-event", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    type: "FreeSignup",
    click_id: localStorage.getItem("clickId"), // or the value you just stored
    email: newUser.email,
    firstname: newUser.firstName,
    lastname: newUser.lastName,
    tag: "saas_signup",
  }),
});

Fire when the trial actually begins – which may be at sign-up (self-serve trial) or later (the user adds a card, picks a plan, or activates a trial). Read the stored click ID:

await sendEvent({
  type: "TrialStarted",
  click_id: user.lj_click_id,   // the value you stored at sign-up
  email: user.email,
  firstname: user.firstName,
  lastname: user.lastName,
  tag: "saas_trial",
});

Sign-up and trial the same moment?

In a classic product-led trial, Free sign-up = Trial Started. You can either send both events (cleaner funnel) or just one – send whichever stage your team actually reports on.

This one usually has no user action behind it – it's a date passing. Fire it from your billing logic when you flip the trial to expired, or from a nightly cron that finds trials whose end date is in the past:

// Pseudo-code for a daily job
const expired = await db.trials.findExpiredSince(yesterday);
for (const t of expired) {
  await sendEvent({
    type: "TrialEnded",
    click_id: t.user.lj_click_id,
    email: t.user.email,
    tag: "saas_trial_end",
  });
}

Trial Ended is a signal, not a sale

Trial Ended is most useful for spotting where the funnel leaks – which campaigns bring trials that never convert. Keep Send back to Ads off for this event so you don't optimise toward it.

You don't need code for this one – let the Stripe integration do it. Map a payment succeeded (or subscription created) Stripe event onto your WonClient conversion, and revenue is attributed automatically. The tracking script even decorates your Stripe Payment Links with the click ID, so payments link to the right campaign without you mapping anything by hand.

If you bill outside Stripe, send it through the same route instead – with revenue:

await sendEvent({
  type: "WonClient",
  click_id: user.lj_click_id,
  email: user.email,
  revenue: 49,          // the amount paid, in your workspace currency
  tag: "saas_purchase",
});

Demo calls (sales-assisted SaaS)

If prospects book a demo before signing up, track that as a BookedCall. Most schedulers (Calendly, SavvyCal, …) can POST a webhook when a call is booked – point it at a small route like the one above and send type: "BookedCall" with the booker's email. To attribute the call to the right ad, pass the click ID into the booking link (as a hidden field / UTM the scheduler echoes back), or rely on email matching. See the Sales Call Funnel and Events & the Events Manager for the full pattern.

Recurring revenue (MRR) without double-counting

A subscription pays every month – decide once how you want that to show up:

  • One conversion, kept up to date – set WonClient to Edit conversion with new data so the latest payment updates the same conversion (good for "current MRR / plan value").
  • Every payment as new revenue – set it to Keep original data, increment payout so each invoice adds on top (good for "total collected"). This mode is WonClient-only.
  • Net out refunds and churn – map a Stripe refund to a conversion with negative revenue, or send a Churned event so you can see which campaigns bring customers that cancel.

See the mode table in Events & the Events Manager.

Verify it's working

  1. Open your app from a tracked link (one carrying lj_* parameters, e.g. a test ad URL or a link built with the UTM Builder) so a click ID is stored.
  2. Walk the funnel with a test account: sign up, start a trial, trigger trial ended (or wait for the cron), and make a test purchase in Stripe.
  3. In Settings → Events each event's Total count goes up and its status turns Active.
  4. To inspect a single call (status code, payload, whether the click ID or email matched), open the API Postbacks log.

No click ID? Send the event anyway

A user who arrived organically or direct has no click ID, so click_id is empty – that's fine. Still send every stage with the email: LeadJourney matches on email too, so the account is captured and can be attributed later if that person clicked a tracked link before. Never block a lifecycle event just because the click ID is missing.

FAQ

On this page