Skip to content

systemoperators/better-auth-attribution

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

better-auth-attribution

subscribers

Know which channel each signup — and each paying customer — came from. A better-auth plugin that records where a user came from (UTM, referrer, ad click IDs, referral code) onto your own user, then lets you roll revenue up by channel.

First-touch. First-party. The data lives in your own database — no third-party service, no monthly fee, no cross-site cookies.

The use case

Every founder asks: "which of my marketing channels actually brings paying customers?" Answering it means connecting a visit → the signup → the payment, by original source. This plugin does that, in your own DB.

  Someone clicks your link somewhere
  e.g. a tweet:  yourapp.com/?utm_source=twitter
                 │
                 ▼
  ┌───────────────────────────────────┐
  │  BROWSER (their laptop)            │
  │  a tiny script notices:            │
  │     "this person came from twitter"│
  │  and saves it in a cookie          │
  └───────────────┬───────────────────┘
                  │  they sign up
                  │  (the cookie tags along)
                  ▼
  ┌───────────────────────────────────┐
  │  YOUR SERVER + YOUR DATABASE       │
  │  the PLUGIN writes it on the user: │
  │     email  = bob@acme.com          │
  │     source = twitter      ◄──────  │
  └───────────────┬───────────────────┘
                  │  later, bob pays $19
                  ▼
  ┌───────────────────────────────────┐
  │  YOUR DATABASE                     │
  │  now you can answer:               │
  │  "twitter brought a $19 customer"  │
  └───────────────────────────────────┘

Why this instead of the alternatives

  • vs hand-rolling it (what most people do): packaged, tested, handles first-touch + cross-subdomain + ad IDs + referral codes + channel classification for you.
  • vs DataFast / analytics SaaS: your data stays in your own database, free, no third-party. (Those are great for full pageview dashboards — this is just the attribution that ties source → user → revenue.)
  • vs @dub/better-auth: not locked to one vendor's link product; works with any link and any UTM.

What's the plugin vs what you add

   THE PLUGIN PROVIDES (you write none of this)
     • the user columns (utm_*, referrer, channel, …)
     • capture + sanitize + classify-channel logic
     • the browser cookie helper (first-touch, cross-subdomain)
     • a server cookie reader (for server-side signups)
     • recordConversion + revenue-by-channel

   YOU ADD (a few lines, in your app)
     • 1 line in your better-auth config
     • run 1 migration
     • 1 line on page load
     • make signup carry the source through

Install

npm i better-auth-attribution

Setup

  1. Add the plugin to your better-auth config (server):
import { betterAuth } from 'better-auth';
import { attribution } from 'better-auth-attribution';

export const auth = betterAuth({
  plugins: [
    attribution({
      // optional: turn a ?ref= code into the referring user's id (a referral program)
      resolveReferral: async (code) => (await db.user.findByReferralCode(code))?.id ?? null,
    }),
  ],
});
  1. Run your better-auth migration so the new user columns are created:
npx @better-auth/cli migrate   # or: generate, then apply with your own migration flow
  1. Capture on page load — call once in your site's root layout. It records the first touch and never overwrites it on later visits:
import { rememberAttribution } from 'better-auth-attribution/client';

rememberAttribution();
// marketing site and app on different subdomains? use a shared cookie:
rememberAttribution({ cookieDomain: '.yourapp.com' });
  1. Carry the source into signup — pick the line that matches how your signup works:
// (a) signup runs in the BROWSER (better-auth client SDK):
import { getAttribution } from 'better-auth-attribution/client';
await authClient.signUp.email({ email, password, name, ...getAttribution({ cookieDomain: '.yourapp.com' }) });

// (b) signup runs on your SERVER (Astro SSR, Remix action, form POST):
import { readAttributionFromRequest } from 'better-auth-attribution';
const attr = readAttributionFromRequest(request);   // reads the cookie server-side
// then include attr in the body you send to /api/auth/sign-up/email

That's the whole integration.

What it captures

field from
utmSource utmMedium utmCampaign utmTerm utmContent ?utm_* query params
gclid fbclid msclkid ad click IDs (Google / Meta / Bing)
referrer document.referrer (external only)
landingPage first path the visitor landed on
referralCode ?ref=
attributionChannel derived (e.g. social/twitter, organic/google, referral, direct)
referredByUserId resolved from referralCode (optional)

Revenue attribution

Turn "signups by channel" into "revenue by channel". From your payment webhook (Stripe / LemonSqueezy / …), once you know the userId, record the conversion — it snapshots the user's source onto the row, so revenue rolls up by channel with no join:

import { recordConversion, getRevenueByChannel } from 'better-auth-attribution';

// in your payment webhook (invoice.paid / checkout.session.completed):
await recordConversion(auth, { userId, type: 'subscription', value: 1900, currency: 'usd' });

// reporting:
const rows = await getRevenueByChannel(auth); // [{ channel, conversions, value }]

Dedupe webhook retries on your side before calling. Already store payments in your own tables keyed to the user? Skip this and just join the user's attribution columns to your billing data — recordConversion is for apps that don't.

Options

option default description
fields all restrict which fields are stored
storeReferrer true set false to store zero URL data (referrer + landingPage)
maxLength 512 per-value truncation
resolveReferral(code, ctx) map ?ref= to a referring user id
classifyChannel(a) built-in override channel classification
onSignup(user, a) called after signup with the final attribution (never blocks signup)
waitUntil(p) defer onSignup on edge runtimes
onError(err, ctx) console.error failure handler; auth is never blocked

Client capture: rememberAttribution(opts), getAttribution(opts), clearAttribution(opts) — options { cookieDomain?, maxAgeDays?, storage? }. Or bind options once with createAttribution({ cookieDomain: '.yourapp.com' })attr.remember() / attr.get() / attr.clear().

Read it back

  • GET /api/auth/attribution/me → the current user's attribution (add attributionClient() to your better-auth client for typed access).
  • Or just query the user columns: select attribution_channel, count(*) from "user" group by 1.

GDPR

The privacy-friendly option by design: values come from URL params and document.referrer, stored on your own user row — no third-party cookies, no cross-site tracking, so it doesn't trigger the cookie-consent/ePrivacy banners analytics SDKs do. Values are length-capped and self-reported (never used for authorization). Disclose it in your privacy policy under legitimate interest, set storeReferrer: false to minimize, and deletion is automatic since it lives on the user.

License

MIT © System Operator LLC

About

better-auth plugin — know which channel each signup and paying customer came from (UTM, referrer, ad click IDs, referral codes). First-touch, first-party, no third-party service.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors