You connected Stripe in DataFast. Revenue shows up. Every dollar of it says "Direct / None". Your Twitter thread that sold 40 licences gets no credit.
The payment reached DataFast. The visitor did not come with it.
How DataFast joins a payment to a visit
The script gives every browser a random id in a first-party cookie,
datafast_visitor_id, plus a datafast_session_id. DataFast knows where that
visitor came from: referrer, UTM tags, landing page.
The payment provider knows nothing about that cookie. It knows a customer and an amount. DataFast joins the two only if the checkout carries the visitor id in its metadata. No id, no join, "direct".
The fix: read the cookie where you create the checkout
The cookie is first-party, so your server receives it on every request. Read it in the server action or route handler that creates the checkout.
Stripe Checkout
import { cookies } from "next/headers";
const jar = await cookies();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
metadata: {
userId: user.id,
datafast_visitor_id: jar.get("datafast_visitor_id")?.value ?? "",
datafast_session_id: jar.get("datafast_session_id")?.value ?? "",
},
success_url: `${origin}/billing?checkout=success`,
});
Put it on the Checkout Session's own metadata, not only on
subscription_data.metadata. The session is what DataFast reads.
Polar
await polar.checkouts.create({
products: [productId],
metadata: { userId: user.id, datafast_visitor_id: visitorId },
});
Lemon Squeezy uses checkoutData.custom instead of metadata:
await createCheckout(storeId, variantId, {
checkoutData: { custom: { datafast_visitor_id: visitorId } },
});
Dodo Payments takes metadata the same way. Dodo also needs a webhook to
DataFast: in Dodo, Developer, Webhooks, add endpoint, choose DataFast, paste a
DataFast API key.
For Stripe, Polar and Lemon Squeezy, no webhook is needed. Connect the provider in DataFast (Website settings, Revenue) and it reads the metadata itself.
Make it one helper
Three payment providers, one rule. Wrap it once:
export async function withCheckoutAttribution<T extends { metadata?: Record<string, string> }>(
params: T,
): Promise<T & { metadata: Record<string, string> }> {
const jar = await cookies();
const metadata: Record<string, string> = { ...params.metadata };
const visitor = jar.get("datafast_visitor_id")?.value;
if (visitor && /^[A-Za-z0-9_-]{1,128}$/.test(visitor)) {
metadata.datafast_visitor_id ??= visitor;
}
return { ...params, metadata };
}
Two details in there are deliberate:
- Validate the cookie. It is client-writable. Anything that does not look like an id is dropped, not copied into your payment provider.
- Never replace a key. Your webhook probably reads
metadata.userId. The helper only adds.
Mistakes that look like they work
- Taking the id from the request body. A client can post any id and credit any channel. Read the cookie on the server.
- Also sending a
paymentevent from the browser. The provider connection already records it. DataFast's manualpaymentcall exists for flows with no connected provider, and it identifies by email. Pick one path, and prefer the one that keeps customer emails out of your analytics. - Tracking revenue as a custom goal.
purchase_completedwith an amount is a second, worse copy of the revenue DataFast already has. - Creating the checkout from a background job. No request, no cookie. Store the visitor id on the user row at signup and pass it from there.
- Testing with a fresh browser that never loaded a page. No page view, no cookie, nothing to attribute.
Check it
- Visit the site from a link with
?ref=test. - Buy something in test mode.
- In the provider's dashboard, open the session.
datafast_visitor_idis in its metadata. - In DataFast, the payment shows with
testas its source.
If step 3 passes and step 4 fails, the provider is not connected in DataFast.