SaaS & signups
Send registration_completed, trial_started, and subscription_created, including the cross-domain click-ID handoff.
For products with a signup: which lifecycle events to send, how to survive the handoff from your marketing site to your app domain, and how to key event IDs to your account model.
Event priority
| Priority | Event | Fires when | Why it matters |
|---|---|---|---|
| 1 | Your primary conversion (see below) | Varies | The one your ads optimize toward. Implement and verify this first. |
| 2 | registration_completed |
Account created and verified | The top of the product funnel. Almost always worth sending, even when revenue events also exist. |
| 3 | trial_started |
Free trial begins | Distinguishes tire-kickers from activated signups. |
| 4 | subscription_created |
First payment, or paid plan starts | Revenue attribution. Include amount and plan_id. |
Choosing the primary conversion: if your sales cycle closes in-product, use subscription_created. If most signups convert weeks later, optimize on registration_completed or trial_started instead. Ad platforms need enough conversion volume inside the attribution window (30 days for Lapis), and a signal that arrives too rarely teaches the optimizer nothing.
The cross-domain problem
SaaS almost always splits the marketing site (example.com, where the ad lands and the Lapis pixel runs) from the app (app.example.com, where the signup happens). Cookies do not cross that boundary reliably, so pass the click ID through the URL instead.
1. Marketing site: decorate app links
Add this below the Lapis pixel snippet. It works in Framer or Webflow custom code, or in your site’s layout:
<script>
(function () {
var APP_HOSTS = ["app.example.com"]; // your app domain(s)
function getLapisClickId() {
var cookie = document.cookie.split("; ").find(function (entry) {
return entry.indexOf("lapis_sid=") === 0;
});
if (!cookie) return null;
try {
var session = JSON.parse(
decodeURIComponent(cookie.split("=").slice(1).join("="))
);
return session.sid || null;
} catch (e) { return null; }
}
var clickId = getLapisClickId();
if (!clickId) return; // organic visitor, so never invent a click id
document.addEventListener("click", function (e) {
var link = e.target && e.target.closest && e.target.closest("a[href]");
if (!link) return;
try {
var url = new URL(link.href);
if (APP_HOSTS.indexOf(url.hostname) !== -1 && !url.searchParams.has("lapis_click_id")) {
url.searchParams.set("lapis_click_id", clickId);
link.href = url.toString();
}
} catch (err) {}
}, true);
})();
</script>
2. App: persist it at signup
Read lapis_click_id from the signup page URL and store it on the account record (a nullable column). If it is absent, leave it null. Most signups are organic, and that is expected.
// Signup page (Next.js example)
const lapisClickId = useSearchParams().get("lapis_click_id");
// include it in your signup mutation → accounts.lapis_click_id
If your signup flow has multiple steps or an OAuth redirect, stash the value in sessionStorage on arrival and read it back when the account is created.
The events
registration_completed
Send it server-side once the account is real (email verified, or whatever “real” means for you):
{
"event_id": "registration_completed:acct_8f24d1",
"event_type": "registration_completed",
"lapis_click_id": "<from the account record>",
"properties": { "signup_method": "email" }
}
trial_started
{
"event_id": "trial_started:acct_8f24d1",
"event_type": "trial_started",
"lapis_click_id": "<from the account record>",
"properties": { "plan_id": "pro_trial_14d" }
}
subscription_created
Fire this from your billing webhook (for example, the Stripe customer.subscription.created event or the first invoice.paid):
{
"event_id": "subscription_created:sub_1PqX3k",
"event_type": "subscription_created",
"lapis_click_id": "<from the account record>",
"value": 79.00,
"currency": "USD",
"properties": { "plan_id": "pro_monthly", "billing_interval": "month" }
}
Event ID conventions: key each event to the entity that makes it unique. Use the account for signup and trial (an account signs up once), and the subscription ID for subscription_created (an account can churn and resubscribe). Because the click ID lives on the account record, late conversions still attribute correctly as long as they land inside the 30-day window.
Note: Watch for annual-versus-monthly value skew. If you send
valueonsubscription_created, an annual plan looks 12x better than a monthly one on day zero. Two common fixes: send first-period value consistently, or send a normalized monthly value invalueand put the real first invoice inproperties.
Backend helper
One function, called from wherever your lifecycle moments happen:
async function sendLapisEvent(event: {
event_id: string;
event_type: string;
lapis_click_id: string | null;
value?: number;
currency?: string;
properties?: Record<string, unknown>;
}) {
if (!event.lapis_click_id) return; // organic, so skip
await fetch("https://api.trylapis.com/api/v1/pixel/server-events", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LAPIS_SERVER_EVENT_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(event),
});
}
What good looks like
Read the funnel left to right. Each number is how many reached that stage.
sessions → registration_completed → trial_started → subscription_created
800 → 42 → 28 → 9
Healthy signups but no subscriptions after 30 or more days usually means your primary conversion arrives outside the attribution window. Consider optimizing on trial_started instead, and treat subscription_created as the reporting truth rather than the optimization signal.