Conversion tracking

Detecting an incoming referral is automatic. Telling Roastnest that the referral turned into something — a signup, a subscription, a purchase — is one hook call.

The flow

A referral has two halves, and only the second one needs code from you.

  • Detection, automatic. A visitor arrives on a link carrying ?ref=CODE. The SDK reads it, stores it in both a cookie and localStorage, and keeps it for 30 days by default. It also recognises ?referral= and ?invite=.
  • Conversion, yours to call. When that visitor does the thing you care about, call trackConversion. The stored code, visitor ID, session ID and device metadata are attached for you.

Detection survives navigation

Because the code is persisted rather than read from the current URL, a visitor can land on your pricing page, browse for a week and sign up from a bookmark — the attribution still holds.

Tracking a conversion

Call the matching track.* method at the point of success — after the account exists, not when the form is submitted.

SignupSuccess.tsx
import { useEffect } from "react";
import { useReferral } from "@roastnest/react";

export function SignupSuccess() {
  const { track, hasReferral, referralCode } = useReferral();

  useEffect(() => {
    if (hasReferral) {
      track.signup();
    }
  }, [hasReferral]);

  return <h1>Welcome aboard{referralCode ? " — referral applied" : ""}!</h1>;
}

Conversions can carry a value, a currency and arbitrary metadata:

Purchase conversion
const { track } = useReferral();

await track.purchase({
  value: 49.0,
  currency: "USD",
  metadata: { plan: "pro", billing: "annual" },
});

Identifying the referee

A conversion with no identity is recorded for analytics but can never trigger a reward — there is nobody to grant one to. To name the person who converted, put id (your own user id) or email in the metadata. Either is enough on its own, exactly as with the referrer.

Attributed conversion
const { track } = useReferral();

await track.purchase({
  value: 49.0,
  currency: "USD",
  metadata: {
    id: user.id,        // your own user id - enough on its own
    name: user.name,
    email: user.email,  // optional if you sent an id
  },
});

Whichever you send comes back on the referral.converted and referral.reward_granted webhooks, so a project that never shares emails can still match a payout to a user.

Self-referrals are dropped

A referrer converting their own link is rejected with a 400 rather than recorded. It is meaningless attribution and an easy way to farm rewards.

Custom events

For event names not covered by the built-in methods, use track.custom(). It will reject names that overlap with the built-in catalog to prevent accidental shadowing.

Custom event
// For event names not in the built-in catalog
const { track } = useReferral();

await track.custom("app_installed", {
  metadata: { platform: "ios" },
});

Guard against double counting

Each track.* call sends an event every time it is called. Fire it from a page the user reaches once, or gate it behind a flag you persist, so a refresh does not credit the referrer twice.

Migrating from trackConversion

The old trackConversion({ event: "signup" }) API still works but is deprecated. Replace it with the corresponding track.* method for type safety and autocompletion.

useReferral

The hook is headless — it reads from the referral API instance the widget initialises, so a ReferralWidget must be mounted somewhere in the tree for it to have state to report.

PropTypeDescription
referralCodestring | nullThe detected code, or null if this visitor arrived directly.
hasReferralbooleanConvenience boolean for the same thing.
referralDataReferralData | nullThe code plus where it came from (query, cookie or localStorage) and when it was first seen.
trackReferralTrackerType-safe namespace with track.signup(), track.purchase(), track.subscription(), track.trial_started(), and track.custom() methods.
trackConversion (deprecated)(event: ConversionEvent) => Promise<void>Deprecated. Use track.* methods instead. Kept for backward compatibility.
isTrackingbooleanTrue while a conversion request is in flight.
clearReferral() => voidForgets the stored referral — use after a conversion that should not be re-attributed.
queuedEventsQueuedEvent[]Events that failed to send and are awaiting retry.
retryQueue() => Promise<void>Flushes the retry queue immediately.
visitorIdstringStable per-browser identifier.
sessionIdstringIdentifier for the current session.
referrerIdentityReferrerIdentity | undefinedThe identity currently attached to outgoing referral links.
setReferrerIdentity(identity) => voidAttaches or replaces the referrer identity after login. See Referral widget.

The event payload

Every referral event — copies, shares and conversions alike — is delivered in the same shape. In cloud mode it goes to Roastnest; in self-hosted mode it goes to your onEvent callback.

ReferralEventPayload
{
  projectId: "prj_123",
  referralCode: "AYUSH123",
  event: "purchase",
  value: 49,
  currency: "USD",
  metadata: { plan: "pro" },
  visitorId: "vis_9f2c...",
  sessionId: "ses_41ab...",
  currentPage: "https://myapp.com/checkout/success",
  referrerUrl: "https://myapp.com/pricing",
  browser: "Chrome",
  os: "macOS",
  device: "desktop",
  timestamp: "2026-01-14T09:22:31.004Z"
}

Self-hosted handling

Forwarding to your own API
<ReferralWidget
  mode="self-hosted"
  referralLink="https://myapp.com/invite"
  onEvent={async (payload) => {
    await fetch("/api/referral-events", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
  }}
/>

The retry queue

A conversion that fails to send is not lost. Failed events are written to localStorage and retried with backoff — three attempts, spaced roughly one, three and ten seconds apart. Anything still unsent survives a reload and is retried on the next mount.

You can inspect and flush the queue yourself, which is occasionally useful right after the browser comes back online:

Manual flush
const { queuedEvents, retryQueue } = useReferral();

if (queuedEvents.length > 0) {
  await retryQueue();
}

Configuration

Two details of detection are adjustable through the referral config — queryParam, which defaults to "ref", and cookieDurationDays, which defaults to 30. Change the parameter name if ref already means something else in your URLs; the fallback names are still recognised either way.

For the widget props that surround this — reward copy, sharing, identity — see Referral widget.