Skip to content New SourceLoop MCP: chat with your attribution data in Claude, ChatGPT & Cursor
SourceLoop
Tracking pixel

Track SaaS signups, trials, and subscriptions

Attribute the full SaaS journey, from visit to signup, free trial, and paid subscription, back to the original click. Next.js and Stripe examples.

On this page
  1. Prerequisites
  2. Key concept: identify vs track
  3. Cross-subdomain note (important)
  4. Step 1: Capture the signup
  5. 1a. Email/password form
  6. 1b. Google / Facebook / GitHub OAuth signup (the important case)
  7. Step 2: The free trial (no payment yet)
  8. Step 3: Connect your payment provider
  9. Step 4: Stamp the checkout so revenue stitches back
  10. Stripe (server-side checkout session)
  11. Other providers (same meta object)
  12. If you cannot stamp metadata
  13. Step 5: Trial to paid and renewals (automatic)
  14. Putting it together (the minimal checklist)
  15. Common mistakes
  16. Verify

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: the standard snippet on the marketing site (Install the tracking pixel) and mode: 'conversions' in the app (Install SourceLoop in your app). Subdomains (www. and app.) share identity automatically, no extra setup.
  2. Your payment provider connected in SourceLoop, under Setup, Payment. This is what lets SourceLoop receive the subscription and payment events. You only do it once, and it is Step 3 below.
  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.)

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

Call identify + track right after the account is created:

// 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' });

Call these after the account actually exists, not on submit, so a failed or rejected signup does not record a conversion.

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)

// 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, isNewUser }) {
      try {
        const anonymousId = getAnonymousId(cookies()); // Next App Router cookie store
        if (anonymousId && user.email) {
          // Runs on EVERY sign-in. identify is idempotent, so this is correct.
          await sl.identify({ anonymousId, email: user.email });

          // Only on account creation — otherwise every login is a "signup".
          if (isNewUser) {
            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)

// 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)

// 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 });

    // This callback also runs on every returning OAuth login, so only record
    // the signup for a genuinely new account.
    const isNewUser = user.created_at === user.last_sign_in_at;
    if (isNewUser) {
      await sl.track({ anonymousId, email: user.email, externalId: user.id, eventName: 'signup_completed' });
    }
  }
  return Response.redirect(new URL('/dashboard', req.url));
}

The same rule applies to every provider: the auth callback is a login path first and a signup path only sometimes. Whatever your stack calls it, gate signup_completed on account creation and leave identify ungated.

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:

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

Step 3: Connect your payment provider

Everything about revenue depends on this, so do it before you touch checkout code. Connecting the provider is what lets SourceLoop see subscriptions, renewals, and refunds, which is how a payment months from now still credits the ad that started it.

You do this once, and it takes about two minutes.

  1. In SourceLoop, open Setup -> Payment in the sidebar. Every supported provider is listed as a card: Stripe, Lemon Squeezy, Paddle, Polar, and Dodo Payments.
SourceLoop Setup Payment page showing the supported payment provider cards
  1. Click your provider’s card. A drawer opens on the right with two tabs, Connect webhook and Wire attribution. Stay on the first tab and copy the webhook URL.
SourceLoop payment provider drawer with the webhook URL and signing secret fields
  1. Paste that URL into your provider’s dashboard as a new webhook endpoint, then copy the signing secret the provider gives you back into SourceLoop and save.

The exact menu path differs per provider, so follow the one you use:

ProviderWhere the webhook goesFull guide
StripeDevelopers -> WebhooksStripe setup
Lemon SqueezySettings -> WebhooksLemon Squeezy setup
PaddleDeveloper Tools -> NotificationsPaddle setup
PolarSettings -> WebhooksPolar setup
Dodo PaymentsDeveloper -> WebhooksDodo setup

Step 4: 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)

// 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)

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 5: Trial to paid and renewals (automatic)

Once your payment provider is connected (Step 3), 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 under Setup, Payment.
  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.
  • Installing the marketing snippet unchanged inside your app. It captures every form it sees, so invite, settings, and support forms become “leads” and in-app navigation eats your pageview allowance. Use mode: 'conversions' in the app.
  • 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

  1. 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.

  2. 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.

  3. 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 under Setup, Payment, trial conversions and renewals attribute automatically from the provider webhooks.

  4. 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.

Track every conversion to its true source

Capture and send full attribution data from every signup, lead, booking, and sale to your CRM and ad platforms, so you know exactly what's driving revenue.

Without SourceLoop

Untagged

Kayden Floyd

kayden@abc.com

  • SourceUnknown
  • MediumUnknown
  • CampaignUnknown
  • Landing pageUnknown
Journey
No touchpoints captured

With SourceLoop

Auto-tagged

Kayden Floyd

kayden@abc.com · Acme Co.

  • Channel Paid Social
  • CampaignFree_demo
  • Landing page/pricing
Journey
Synced to HubSpot Google Ads Meta