# Track SaaS signups, trials, and subscriptions

Attribute the full SaaS journey, marketing visit, signup (form or Google/Facebook OAuth), free trial, and paid subscription, back to the original click. Copy-paste examples for Next.js and Stripe.

Source: https://sourceloop.ai/help/track-saas-signups-and-subscriptions/
Updated: 2026-06-29

---

This guide attributes the **entire** SaaS journey to the marketing source that started it:

```
Ad / SEO / referral
   → visits www.yoursite.com (marketing)
   → clicks "Start free trial" → app.yoursite.com (your app)
   → signs up (email form, OR Google / Facebook OAuth)
   → uses the free trial (no payment yet)
   → later subscribes (Stripe / LemonSqueezy / Paddle / Polar)
```

By the end, the signup **and** the eventual subscription revenue are both credited to the original ad/click.

## Prerequisites

1. **Tracking installed on both the marketing site and the app**, using the **same `websiteId`** (see [Install SourceLoop](/help/install-the-sourceloop-sdk/)). Subdomains (`www.` and `app.`) share identity automatically, no extra setup.
2. **Your payment provider connected in the SourceLoop dashboard** (Integrations, Revenue). Connecting Stripe/LemonSqueezy/Paddle/Polar lets SourceLoop receive the subscription/payment webhooks. You only do this once.
3. The **server SDK** installed in your app's backend (`npm install @sourceloop-analytics/sdk`, Node 18+). Strongly recommended, it is what makes OAuth signups and payments attribute reliably.

## Key concept: identify vs track

You will use exactly two calls. Understanding the difference avoids 90% of mistakes.

- **`identify({ email })`** links a known person (their email) to their anonymous visit. It is **idempotent** and creates **no** conversion. Call it **every time** a user logs in or signs up. Safe to call repeatedly.
- **`track({ eventName })`** records a **conversion** (a meaningful event like `signup_completed` or `trial_started`). Call it **once** per real event, not on every page load.

Rule of thumb: **`identify` = "this visitor is this person"**, **`track` = "this important thing happened"**.

## Cross-subdomain note (important)

When the user clicks from `www.yoursite.com` to `app.yoursite.com`, SourceLoop already treats them as the **same visitor** because the identity cookie (`_sl_aid`) is set on your root domain (`.yoursite.com`). **You do not need to do anything** for subdomains. (If your app is on a genuinely *different root domain*, e.g. marketing on `brand.com`, app on `brandapp.io`, see [Cross-domain tracking](/help/cross-domain-and-subdomain-tracking/).)

## Step 1, capture the signup

The email is what ties the anonymous visitor to a real account. How you capture it depends on the signup method.

### 1a. Email/password form

If it is a normal HTML form, the script may already capture it automatically. To be explicit (recommended), call `identify` + `track` right after the account is created:

```ts
// Client-side, after your signup succeeds
import { identify, track } from '@sourceloop-analytics/sdk';

identify({ email: form.email });
track('signup_completed', { method: 'password', plan: 'free_trial' });
```

### 1b. Google / Facebook / GitHub OAuth signup (the important case)

With OAuth, the email is only known **on your server**, in the callback, after the provider redirects back. So you bind it **server-side**, reading the visitor's id from the request cookie. This is the single most important piece for SaaS attribution.

The pattern is always:

```
const anonymousId = getAnonymousId(<the incoming request>);
await sl.identify({ anonymousId, email });
await sl.track({ anonymousId, email, eventName: 'signup_completed' });
```

`getAnonymousId` reads the `_sl_aid` cookie (set on your root domain, so it is present on `app.yoursite.com`). Concrete examples:

#### NextAuth / Auth.js (App Router)

```ts
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import { cookies } from 'next/headers';
import { Sourceloop, getAnonymousId } from '@sourceloop-analytics/sdk/server';

const sl = new Sourceloop({ websiteId: 'YOUR_WEBSITE_ID' }); // not a secret, paste it directly

const handler = NextAuth({
  // ...your providers...
  callbacks: {
    async signIn({ user }) {
      try {
        const anonymousId = getAnonymousId(cookies()); // Next App Router cookie store
        if (anonymousId && user.email) {
          await sl.identify({ anonymousId, email: user.email });
          await sl.track({
            anonymousId,
            email: user.email,
            externalId: user.id,            // your DB user id (optional but recommended)
            eventName: 'signup_completed',
            properties: { plan: 'free_trial' },
          });
        }
      } catch (e) {
        // Never block sign-in on analytics. Log and continue.
        console.error('sourceloop signIn tracking failed', e);
      }
      return true;
    },
  },
});

export { handler as GET, handler as POST };
```

#### Clerk (webhook or a post-auth route handler)

```ts
// app/api/track-signup/route.ts  (call this from your client right after Clerk sign-up)
import { Sourceloop, getAnonymousId } from '@sourceloop-analytics/sdk/server';
import { currentUser } from '@clerk/nextjs/server';

const sl = new Sourceloop({ websiteId: 'YOUR_WEBSITE_ID' }); // not a secret, paste it directly

export async function POST(req: Request) {
  const user = await currentUser();
  const anonymousId = getAnonymousId(req); // reads _sl_aid from the request cookies
  const email = user?.emailAddresses[0]?.emailAddress;
  if (anonymousId && email) {
    await sl.identify({ anonymousId, email });
    await sl.track({ anonymousId, email, externalId: user!.id, eventName: 'signup_completed' });
  }
  return Response.json({ ok: true });
}
```

#### Supabase Auth (callback route)

```ts
// app/auth/callback/route.ts
import { Sourceloop, getAnonymousId } from '@sourceloop-analytics/sdk/server';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

const sl = new Sourceloop({ websiteId: 'YOUR_WEBSITE_ID' }); // not a secret, paste it directly

export async function GET(req: Request) {
  // ...exchange the code for a session as usual...
  const supabase = createServerClient(/* ... */);
  const { data: { user } } = await supabase.auth.getUser();

  const anonymousId = getAnonymousId(req);
  if (anonymousId && user?.email) {
    await sl.identify({ anonymousId, email: user.email });
    await sl.track({ anonymousId, email: user.email, externalId: user.id, eventName: 'signup_completed' });
  }
  return Response.redirect(new URL('/dashboard', req.url));
}
```

> **`getAnonymousId` accepts whatever you have:** a Web `Request`, a `Headers` object, the Next.js `cookies()` store, a Pages-Router `req`, a raw `Cookie` header string, or a plain `{ _sl_aid: '...' }` map. Pass the request object you have in that handler.

At this point you have a **signup conversion** (revenue = 0) attributed to the visitor's original marketing source, even though they signed up via OAuth.

## Step 2, the free trial (no payment yet)

There is nothing extra to do for a no-card free trial. The signup conversion from Step 1 already exists and is attributed. When the user later pays, Step 3 connects the revenue to it.

If you want a distinct "trial started" event (e.g. for funnel reporting), fire one:

```ts
await sl.track({ anonymousId, email, eventName: 'trial_started', properties: { plan: 'pro' } });
```

## Step 3, subscribe: stamp the checkout so revenue stitches back

This is what connects the eventual payment to the visitor. When you create the checkout/subscription, attach the visitor's id as **metadata**. Do this **server-side** for maximum reliability.

`checkoutMetadata(req)` returns `{ sourceloop_anonymous_id }`. Pass it into your provider's metadata field.

### Stripe (server-side checkout session)

```ts
// app/api/create-checkout/route.ts
import Stripe from 'stripe';
import { checkoutMetadata } from '@sourceloop-analytics/sdk/server';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const meta = checkoutMetadata(req); // { sourceloop_anonymous_id: '...' }

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: 'price_xxx', quantity: 1 }],
    success_url: 'https://app.yoursite.com/welcome',
    cancel_url: 'https://app.yoursite.com/pricing',
    metadata: meta,
    // CRITICAL: also stamp the subscription, so renewals carry the id too:
    subscription_data: { metadata: meta },
  });

  return Response.json({ url: session.url });
}
```

> **Always set both `metadata` and `subscription_data.metadata`.** The first attributes the initial payment; the second makes every renewal carry the id, which is what powers lifetime-value (LTV) reporting.

### Other providers (same `meta` object)

```ts
const meta = checkoutMetadata(req); // { sourceloop_anonymous_id, sourceloop_id }

// LemonSqueezy (checkout custom data)
checkout: { custom: meta }

// Paddle (Paddle.js)
Paddle.Checkout.open({ items: [...], customData: meta });

// Polar / Dodo (checkout create)
{ metadata: meta }
```

### If you cannot stamp metadata

Attribution still works as a fallback **via email match**, because Step 1 bound that email to the visitor. It is slightly lower confidence than the metadata stitch, but it holds. Stamping the metadata is strongly preferred when you can.

## Step 4, trial to paid and renewals (automatic)

Once your payment provider is connected in the SourceLoop dashboard, these are handled for you from the provider's webhooks, no code:

- **Trial starts (card up front):** recorded with **no revenue**.
- **Trial converts to paid:** the **revenue and recurring subscription** are recorded and attributed to the original click.
- **Each renewal:** revenue accrues to the same original acquisition (for LTV).
- **Refunds / cancellations:** reduce the recorded value / mark churn.

## Putting it together (the minimal checklist)

1. Same `websiteId` script/SDK on marketing site **and** app.
2. Connect your payment provider in the SourceLoop dashboard.
3. On **signup** (form or OAuth): `identify({ email })` + `track('signup_completed')`. For OAuth, do it server-side with `getAnonymousId(req)`.
4. On **subscribe**: create the checkout with `metadata: checkoutMetadata(req)` (and `subscription_data.metadata` for Stripe).
5. Done. Trials, conversions, and renewals attribute automatically.

## Common mistakes

- **Calling `track('signup_completed')` on every page load or every login.** It should fire **once**, at signup. Use `identify` (idempotent) for repeated logins.
- **Doing OAuth signup attribution only on the client.** The email resolves on the server in OAuth, so do the `identify`/`track` in the callback with `getAnonymousId(req)`.
- **Forgetting `subscription_data.metadata` on Stripe.** Without it, renewals are not stitched and LTV is wrong.
- **Different `websiteId` on marketing vs app.** Use one id everywhere or the journey splits into two strangers.
- **Blocking signup on analytics.** Wrap SourceLoop calls in try/catch so a tracking hiccup never breaks auth or checkout.

## Verify

- After an OAuth signup, the user appears as a **lead/conversion** in the dashboard, with a first-touch source matching the original ad/referrer.
- After a test subscription, a **subscription/payment** conversion appears, attributed to that same visitor, with revenue.
- A renewal increases that customer's recorded value without creating a duplicate conversion.

## Frequently Asked Questions

### What is the difference between identify and track?

identify({ email }) links a known person to their anonymous visit. It is idempotent and creates no conversion, so call it every time a user logs in or signs up. track({ eventName }) records a conversion, a meaningful event like signup_completed or trial_started, so call it once per real event, not on every page load.

### How do I attribute a Google or Facebook OAuth signup?

With OAuth the email is only known on your server, in the callback. Read the visitor's id from the request cookie with getAnonymousId(req), then call sl.identify and sl.track server-side. Doing it only on the client misses the email, which is why server-side binding is the most important step for SaaS attribution.

### How does the eventual subscription revenue stitch back to the click?

When you create the checkout or subscription, attach checkoutMetadata(req) (which returns sourceloop_anonymous_id) to the provider's metadata field. For Stripe, set both metadata and subscription_data.metadata so renewals carry the id too. Once your payment provider is connected in the dashboard, trial conversions and renewals attribute automatically from the provider webhooks.

### What if I cannot stamp checkout metadata?

Attribution still works as a fallback via email match, because the signup step bound that email to the visitor. It is slightly lower confidence than the metadata stitch, but it holds. Stamping the metadata is strongly preferred when you can.
