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 stage | Event type | How you send it | Revenue? |
|---|---|---|---|
| Demo call booked (sales-assisted) | BookedCall | Your scheduler's webhook (Calendly, …) | — |
| Free sign-up / account created | FreeSignup | A tiny server route (Conversion API) | — |
| Trial started | TrialStarted | The same server route | — |
| Trial ended | TrialEnded | The same server route (from your billing logic / a cron job) | — |
| Purchase (trial converts to paid) | WonClient | Stripe 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
localStoragein 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:
| Event | Suggested mode | Notes |
|---|---|---|
FreeSignup | Ignore duplicate postbacks | One per user – drops accidental repeats. |
TrialStarted | Ignore duplicate postbacks | A user starts a trial once. |
TrialEnded | Ignore duplicate postbacks | Turn Send back to Ads off – it's a drop-off signal, not a conversion. |
WonClient | Edit conversion with new data | Turn 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
WonClientto 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 aChurnedevent so you can see which campaigns bring customers that cancel.
See the mode table in Events & the Events Manager.
Verify it's working
- 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. - 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.
- In Settings → Events each event's Total count goes up and its status turns Active.
- 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
Related articles
Track sign-ups in your app (incl. SSO)
The deep dive on the sign-up event, including Google & Microsoft SSO.
Connect Stripe
Bring purchases and subscription revenue in automatically – no code.
Events & the Events Manager
Create your lifecycle events, pick modes, and get the Conversion API credentials.
API Postbacks log
Inspect each event call and confirm it matched a click or email.
Affiliate Landing Pages
Register the pages you send affiliate traffic to – money pages and pre-landers – then connect them to a campaign and split-test variants by weight to find the highest-converting page.
Track your iGaming funnel (registration → FTD → NGR)
The complete guide for sportsbooks and casinos – attribute every step back to the ad, affiliate or post that earned it. Track the registration, then the First-Time Deposit (FTD), redeposits and Net Gaming Revenue. The FTD and everything after it fire server-side from your player platform or CRM. The key idea: capture the click ID once at registration and replay it on every later deposit event.