Clerk
Connect Clerk signups to referral clicks with a few copied files and Clerk's user.created webhook, with no storage in your database.
The RefRef recipe for Clerk connects each new account to the browser's referral click. It carries the click in Clerk's sign-up metadata and sends the signup from Clerk's user.created webhook. It adds nothing to your database.
Set up
- Add the landing script to the pages where a referral link can land. It keeps the browser's referral handle in the
refref_handlecookie. - Copy the recipe files into your application. Set
REFREF_API_URL,REFREF_API_KEY, andREFREF_PROJECT_IDon your server. - Render Clerk's sign-up and sign-in with the RefRef metadata, and register the source before the page renders:
import { prepareRefRefSource } from "@/lib/refref";
import { RefRefSignUp } from "@/app/refref-auth";
export default async function SignUpPage() {
await prepareRefRefSource();
return <RefRefSignUp />;
}- Set
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-inandNEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up, so Clerk's own "Sign in" and "Sign up" links stay on your pages, where the recipe runs. A sign-up on Clerk's hosted Account Portal carries no RefRef data. - Optionally, also call
prepareRefRefSource()fromregister()ininstrumentation.ts, so the source exists when the server starts. - In the Clerk Dashboard, add a webhook endpoint for
user.createdat your/api/clerk/webhookroute, and set its signing secret asCLERK_WEBHOOK_SIGNING_SECRET.
Keep the API key on your server.
What it does
- A new account: in the browser, the recipe waits for the landing script, reads the handle, and passes
{ refrefSignup: true, refrefHandle }as the sign-up'sunsafeMetadata. Clerk copies it to the user when the sign-up creates the user. Theuser.createdwebhook then sends the signup with that handle. By Clerk's design the metadata also reaches a user whom an OAuth redirect creates; only email and password sign-up is tested live. - A page without a referral handoff: the form renders at once. With a handoff, it waits at most 3 seconds for the landing script.
- Nothing else: a sign-in or a returning user does not create a user, so the webhook never fires. A user created in the Clerk Dashboard or through the Backend API has no RefRef mark and sends nothing.
- Organizations: not covered yet.
Failures
A RefRef failure never fails a sign-up. When RefRef fails, the webhook route answers with an error, and Clerk sends the event again; a repeated signup returns the first result.
The recipe files
/**
* The RefRef recipe for Clerk (decision 0065), server part. Copy this file
* and `refref-metadata.ts` into a Clerk application. It needs no table and
* stores no referral state:
*
* - `await prepareRefRefSource()` runs when the server starts
* (`instrumentation.ts`) and before a sign-up page renders: it registers
* the app as a source before the first account.
* - In the browser, `refrefSignupMetadata()` (`refref-metadata.ts`) goes into
* the `unsafeMetadata` of `<SignUp>` and `<SignIn>`. Clerk copies it to the
* user only when a sign-up creates the user, so it marks a user that a
* sign-up of this app created, with the handle of the browser that started
* it. An existing user's metadata never changes.
* - `trackClerkSignup(user)` runs in the verified `user.created` webhook. It
* sends the signup with that handle, also when the sign-up finished in
* another tab or after an OAuth redirect.
*/
/** Registers the source; a RefRef failure never fails the page. */
export async function prepareRefRefSource() {
await sourceNamespaceId().catch((error: unknown) => console.error(error));
}
let source: Promise<string> | undefined;
/** Registers this Clerk instance as a source of the RefRef Project, once per
* process, before the first account exists. RefRef never attributes a signup
* older than its source namespace, and never identifies its handle. */
function sourceNamespaceId() {
source ??= call("/v1/sources", {
provider: "clerk",
accountKey: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ?? "",
}).then(
async (response) =>
((await response.json()) as { sourceNamespaceId: string })
.sourceNamespaceId,
);
source.catch(() => (source = undefined));
return source;
}
async function call(path: string, body: object) {
const response = await fetch(
`${(process.env.REFREF_API_URL ?? "").replace(/\/$/, "")}${path}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": process.env.REFREF_API_KEY ?? "",
},
body: JSON.stringify({
projectId: process.env.REFREF_PROJECT_ID,
...body,
}),
},
);
if (!response.ok)
throw new Error(`RefRef ${path} failed with status ${response.status}`);
return response;
}
/** The fields of a Clerk `user.created` event that the recipe reads. */
export interface ClerkUserCreated {
id: string;
created_at: number;
unsafe_metadata: Record<string, unknown>;
}
/**
* Sends the signup of a user that a sign-up of this app created. A user with
* no `refrefSignup` mark (created in the Clerk Dashboard, through the Backend
* API, or by another app) sends nothing. Throws when RefRef fails, so the
* webhook answers with an error and Clerk sends it again; a repeated signup
* returns the first result.
*/
export async function trackClerkSignup(user: ClerkUserCreated) {
if (user.unsafe_metadata.refrefSignup !== true) return;
const handle =
typeof user.unsafe_metadata.refrefHandle === "string"
? user.unsafe_metadata.refrefHandle
: undefined;
const response = await call("/v1/track/events", {
sourceNamespaceId: await sourceNamespaceId(),
type: "signup",
participant: { kind: "individual", externalId: user.id },
occurredAt: new Date(user.created_at).toISOString(),
...(handle && { identify: { handle } }),
});
// A handle that RefRef did not identify: unknown, identified to another
// user, or a user older than the source. The signup still counts.
const { identified } = (await response.json()) as { identified?: boolean };
if (handle && identified === false)
console.warn(`RefRef did not identify the handle of ${user.id}`);
}import { waitForCapture } from "./capture";
/**
* The RefRef recipe for Clerk, browser part: the `unsafeMetadata` of a
* sign-up. It waits for the landing script's capture, at most 3 seconds, so a
* referral link that lands on the sign-up page itself keeps its click. The
* `refref_handle` cookie is readable by scripts.
*/
export async function refrefSignupMetadata() {
await waitForCapture();
const handle = document.cookie
.split(/;\s*/)
.find((cookie) => cookie.startsWith("refref_handle="))
?.slice("refref_handle=".length);
return { refrefSignup: true, ...(handle && { refrefHandle: handle }) };
}declare global {
interface Window {
RefRefAttribution?: { capture(): Promise<unknown> };
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** Waits for the landing script to load and capture, at most 3 seconds in
* all, so a sign-up form that renders at once still sees the
* `refref_handle` cookie. Without a `refref_handoff` in the URL there is
* nothing to capture, so it does not wait. Capture never blocks the form: a
* script that does not load or a failed capture only ends the wait. */
export async function waitForCapture() {
if (!new URLSearchParams(location.search).has("refref_handoff")) return;
const deadline = Date.now() + 3000;
while (!window.RefRefAttribution && Date.now() < deadline) await sleep(50);
const capture = window.RefRefAttribution?.capture();
if (!capture) return;
await Promise.race([
capture.catch(() => undefined),
sleep(Math.max(0, deadline - Date.now())),
]);
}"use client";
import { SignIn, SignUp } from "@clerk/nextjs";
import { useEffect, useState } from "react";
import { refrefSignupMetadata } from "@/lib/refref-metadata";
type Metadata = Awaited<ReturnType<typeof refrefSignupMetadata>>;
function useMetadata() {
const [metadata, setMetadata] = useState<Metadata>();
useEffect(() => {
void refrefSignupMetadata().then(setMetadata);
}, []);
return metadata;
}
/** `<SignUp>` with the RefRef metadata, once capture finished. */
export function RefRefSignUp() {
const metadata = useMetadata();
return metadata ? (
<SignUp unsafeMetadata={metadata} forceRedirectUrl="/dashboard" />
) : null;
}
/** `<SignIn>` with the same metadata: a sign-in that becomes a sign-up (OAuth
* or the combined flow) carries it; an existing user's metadata never
* changes. */
export function RefRefSignIn() {
const metadata = useMetadata();
return metadata ? (
<SignIn
unsafeMetadata={metadata}
forceRedirectUrl="/dashboard"
signUpForceRedirectUrl="/dashboard"
/>
) : null;
}import { verifyWebhook } from "@clerk/nextjs/webhooks";
import type { NextRequest } from "next/server";
import { trackClerkSignup } from "@/lib/refref";
/** Clerk's webhook (Svix), with `CLERK_WEBHOOK_SIGNING_SECRET`. A non-2xx
* answer makes Clerk send the event again. */
export async function POST(request: NextRequest) {
let event;
try {
event = await verifyWebhook(request);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "user.created") {
try {
await trackClerkSignup(event.data);
} catch (error) {
console.error(error);
return new Response("RefRef failed", { status: 502 });
}
}
return new Response(null, { status: 204 });
}Check your integration
| Test | Expected result |
|---|---|
| A shares a link that lands on your sign-up page; B signs up | B's signup has a referral from A |
| B signs in again on another device | No second signup |
| An existing user follows another referral link and signs in | The first referral stays |
| A user created in the Clerk Dashboard | No signup |