Tracking
Link visitors to customers with identify()
Call identify() at signup, from the browser or your server, and use one user ID across Stripe, RevenueCat and your app so each person is one customer.
The snippet knows an anonymous visitor: a random ID, a landing page, a referrer. Your billing knows a customer: a Stripe customer or a RevenueCat app user. identify() is the call that says they are the same person. It links the browser's anonymous ID to your own user ID, and once that user pays, their revenue is credited to the visit that brought them.
It is the one call that turns traffic data into revenue attribution, so it is worth getting exactly right.
The call
joinsight("identify", user.id, user.email);
The first argument is your user ID, the primary key of the user in your own database. The second is optional: the email address, used as a fallback match. The call sends your site key, the browser's anonymous ID, the user ID and the email to /api/identify. User IDs can be up to 200 characters. An email that isn't a valid address is dropped, and the call is recorded without it.
Joinsight never stores the email as typed. It is lowercased, trimmed and stored as a keyed hash (HMAC-SHA256 with a server-side secret), which can be compared with another hash but not turned back into the address.
When to call it
Call it once, in the browser, right after signup succeeds: after your server has created the user and returned their ID.
"use client";
async function onSubmit(form: FormData) {
const res = await fetch("/api/signup", { method: "POST", body: form });
if (!res.ok) return;
const user: { id: string; email: string } = await res.json();
window.joinsight("identify", user.id, user.email);
window.location.assign("/welcome");
}
Why at signup and not at every login:
- It happens in the browser that has the first touch. The visitor who arrived from your blog post signs up in that same browser. A login weeks later might happen on a phone that has never seen your site.
- The earliest identify is the signup date. Joinsight uses the time of a user's first identify call as their signup time.
- Repeated calls add nothing. See repeated calls below.
Calling it again on login is harmless when the visitor has consented, and it can help: a user who logs in on a second device adds that device's visits, and the earliest visit across all of them is the one credited. Only visits on or before the day of the customer's first payment count, so a login visit never takes the credit for a customer who was already paying. But one call at signup does the essential work.
The queue stub from the snippet install means the call works even if j.js hasn't finished loading. If your signup redirects with a full page load, call identify() before the redirect, or on the first page after it.
Use one user ID everywhere
Joinsight matches a customer to an identify call by user ID first. The user ID must be the same string in three places:
identify(), at signup.- Stripe, as
metadata.user_idon the customer or the subscription. If both carry one, the customer's wins. A subscription's ID is used when the customer has none. - RevenueCat, as the app user ID. Anonymous RevenueCat IDs (those starting with
$RCAnonymousID:) are never treated as user IDs.
When the IDs match, a person who subscribes on the web and again in your app is one customer, with MRR summed, and both purchases trace back to the same first touch.
When there is no user ID to match on, Joinsight falls back to the email hash: the email you passed to identify() against the billing customer's email. The fallback never merges two customers that carry different known user IDs. It works, but it misses everyone who signs up with one address and pays with another, which is why the user ID comes first.
Stripe Checkout example
Create the Stripe customer yourself with the user ID in its metadata, and put the same ID on the subscription through subscription_data.metadata. The Checkout Session's own metadata stays on the session and is not copied to the subscription or customer.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
export async function getOrCreateCustomer(user: { id: string; email: string; stripeCustomerId: string | null }) {
if (user.stripeCustomerId) return user.stripeCustomerId;
const customer = await stripe.customers.create({
email: user.email,
metadata: { user_id: user.id },
});
await saveStripeCustomerId(user.id, customer.id); // your own database update
return customer.id;
}
export async function createCheckout(user: { id: string; stripeCustomerId: string }, priceId: string) {
return stripe.checkout.sessions.create({
mode: "subscription",
customer: user.stripeCustomerId,
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: user.id,
metadata: { user_id: user.id },
subscription_data: { metadata: { user_id: user.id } },
success_url: "https://example.com/welcome",
cancel_url: "https://example.com/pricing",
});
}
The reasoning, Payment Links, webhook handling and backfilling older subscriptions are covered in Stripe Checkout metadata and user IDs. Stripe-side setup is in Connect Stripe and RevenueCat.
Mobile apps and RevenueCat
The snippet runs on websites, not in native apps, so attribution for mobile revenue depends on the person having visited your site first. The flow that works:
- A visitor lands on your site and signs up there. Your site calls
identify()with their user ID. - They install the app and sign in. Your app tells RevenueCat their user ID, by configuring the SDK with it or logging the user in, so the RevenueCat app user ID is the same string.
- They subscribe in the app. Joinsight matches the RevenueCat customer to the identify call by user ID, and the mobile subscription is credited to the web first touch.
If people sign up inside the app and never visit your site, there is no visit to link. The App Store side is still visible in aggregate: downloads by source and campaign from App Store Connect, next to RevenueCat trials and new MRR, but not per person.
Visitors who refused consent
Without consent the snippet stores nothing in the browser and has no anonymous ID. On the page where the visitor arrived, from another site or by typing your address, it still holds the landing details in memory. When you call identify() on that page, the snippet sends the in-memory touch along with the user ID, and Joinsight keeps it on the identity. It is used only when the customer has no consented visit linked.
A page reached through a link on your own site has no landing details to send. In a single-page app, where signup happens without a full reload, the signup still runs on the landing page, so the touch is the real one. On a multi-page site, the signup page is a later page load, so the call carries no touch and the customer stays unattributed, rather than being credited to your signup page as a Direct visit.
Server-side identify
You can identify from your backend instead, for example inside the signup handler, where the user ID is already at hand and nothing depends on the browser running JavaScript.
POST https://joinsight.app/api/v1/identify
Authorization: Bearer js_sk_...
Content-Type: application/json
The exact URL is shown in the app under Connections → Tracking snippet, in "Identify from your server instead". This endpoint is for servers only: it has no CORS headers, and the secret key must never reach a browser.
The secret key
The secret key starts with js_sk_. It is shown once, with a copy button, right after you create the workspace. Joinsight stores only a hash of it and the last four characters, so it can't show it to you again. If you didn't keep it, an owner opens Settings → Keys and chooses Rotate secret key. The new key is shown once, and the previous key stops working immediately, so update your server's environment right after rotating. The same page shows the public site key and the last four characters of the current secret key.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
userId | string | Yes | 1 to 200 characters. The same ID as in Stripe and RevenueCat. |
email | string or null | No | A valid email, at most 320 characters. Hashed on arrival. Unlike the browser call, an invalid one fails the request, so you notice. |
anonymousId | string or null | No | The visitor's js_aid value, 8 to 64 characters. |
firstTouch | object or null | No | A touch you captured yourself. Used only when anonymousId is absent. |
The firstTouch object:
| Field | Type | Required | Notes |
|---|---|---|---|
path | string | Yes | Landing path, 1 to 500 characters, such as /pricing. A full URL is accepted; its query string and hash are dropped. |
ref | string or null | No | Referrer hostname, such as www.google.com. At most 255 characters. |
source | string or null | No | utm_source, at most 200 characters. |
medium | string or null | No | utm_medium, at most 200 characters. |
campaign | string or null | No | utm_campaign, at most 200 characters. |
content | string or null | No | utm_content, at most 200 characters. |
term | string or null | No | utm_term, at most 200 characters. |
click | string or null | No | One of gclid, gbraid, wbraid, msclkid, fbclid: which click ID parameter was present. |
A firstTouch is classified into a channel exactly like a snippet event. Its time is recorded as the time of the identify call.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | {"ok":true} | Recorded. |
401 | {"error":"Missing secret key"} | No Authorization header, or the key doesn't start with js_sk_. |
401 | {"error":"Invalid secret key"} | The key doesn't match a workspace, for example after a rotation. |
400 | {"error":"Invalid body","issues":[...]} | The body isn't valid JSON or fails validation. issues lists each problem with its field path. |
429 | {"error":"Too many requests"} | More than 30 calls in a minute with the same secret key. The Retry-After header gives the seconds to wait. |
curl
curl -X POST https://joinsight.app/api/v1/identify \
-H "Authorization: Bearer $JOINSIGHT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"userId":"usr_123","email":"jane@example.com","anonymousId":"0b6f1c9e-5d2a-4f0e-9a7b-3c1d2e4f5a6b"}'
Node
export async function identifyInJoinsight(user: { id: string; email: string }, anonymousId?: string | null) {
const res = await fetch("https://joinsight.app/api/v1/identify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.JOINSIGHT_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ userId: user.id, email: user.email, anonymousId: anonymousId || null }),
});
if (!res.ok) throw new Error(`Joinsight identify failed: ${res.status} ${await res.text()}`);
}
Call it after the user is saved, and don't let a failure here fail the signup. Catch the error and log it; a missed identify costs one attributed customer, a failed signup costs the customer.
Forwarding the anonymous ID
Server-side identify only links a visit if you send the visitor's anonymous ID. It lives in the browser's localStorage under js_aid, and only exists once the visitor has consented. Read it on the client and send it with the signup request:
function joinsightAnonymousId(): string | null {
try {
return localStorage.getItem("js_aid");
} catch {
return null;
}
}
await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, anonymousId: joinsightAnonymousId() }),
});
Your signup handler then passes anonymousId on to Joinsight. Read it on the same origin where the snippet runs: localStorage is not shared between example.com and app.example.com.
Repeated calls
Each identify call stores one link between a user ID and an anonymous ID. Joinsight keeps one row per pair, so calling identify() again from the same browser for the same user changes nothing. A call from a different browser adds that browser's anonymous ID to the same user, and the earliest consented visit among them is credited.
Calls without an anonymous ID, from consent-refused visitors or from server-side calls without anonymousId, share one row per user in the same way. A repeated call only fills in what the first one lacked, an email or an in-memory touch, and never replaces it. The first call's time stays the signup time.
When results appear
Signup counts on pages and channels are read from identify calls directly. The link to revenue is made by the attribution recompute, which Joinsight schedules two minutes after an identify call, so a burst of signups is processed in one pass. A call that arrives while a recompute is running queues one more run. A customer appears with a first touch once they have paid, Stripe or RevenueCat has synced the payment, and a recompute has run.
To confirm the calls arrive, check Last identify() under Connections → Tracking snippet. It shows when the last call was received, updated at most once every five minutes. The browser endpoint answers 204 on success, 400 for an invalid body (a missing user ID, for instance), 404 for an unknown site key and 429 when rate limited, at 30 calls per minute per IP. Behind your own proxy, all visitors share that budget; see Serve it from your own domain.
If identify calls arrive but customers stay unmatched, the user IDs usually differ between identify() and billing. Troubleshooting covers how to check, and How attribution works explains what happens after the match.