Skip to content
Lapis
Esc
navigateopen⌘Jpreview
On this page

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.

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 value on subscription_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 in value and put the real first invoice in properties.

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.

Was this page helpful?