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.
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" │
└───────────────────────────────────┘
- 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.
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
npm i better-auth-attribution- 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,
}),
],
});- 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- 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' });- 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/emailThat's the whole integration.
| 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) |
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.
| 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().
GET /api/auth/attribution/me→ the current user's attribution (addattributionClient()to your better-auth client for typed access).- Or just query the user columns:
select attribution_channel, count(*) from "user" group by 1.
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.
MIT © System Operator LLC