Skip to content

Install + identify

The browser SDK captures the referral token when a visitor arrives through a partner link (and keeps it alive across sessions), and stitches that visitor to a user account when they sign up or log in. It can also hand the referral to Stripe Payment Links, Pricing Tables, Buy Buttons and your own Checkout Sessions, so Stripe payments credit the partner. Revenue that isn’t a Stripe payment is reported server-side — never from the browser.

Install

Terminal window
npm install @openpartner/sdk
import { OpenPartner } from '@openpartner/sdk';
export const op = OpenPartner.init({
apiUrl: 'https://app.openpartner.dev/api/t/<your-workspace>',
});

apiUrl is your workspace’s API base:

DeploymentapiUrl
Hostedhttps://app.openpartner.dev/api/t/<your-workspace>
Hosted, white-label domainhttps://portal.yourbrand.com/api
Self-hostedhttps://openpartner.yourbrand.com (your API URL)

The SDK ships ESM and CJS bundles, has zero runtime dependencies, and is ~4 KB gzipped.

Script tag

No bundler, or a site builder that only lets you paste into <head>? Your OpenPartner portal serves a self-starting build of the SDK at /op.js. Paste it into the <head> of every page:

<script src="https://app.openpartner.dev/op.js" data-api-url="https://app.openpartner.dev/api/t/<your-workspace>"></script>

Admin → Integrations shows the exact tag for your workspace. On a white-label domain it’s https://portal.yourbrand.com/op.js with data-api-url="https://portal.yourbrand.com/api"; on a self-hosted install, your portal’s /op.js with your API URL.

The tag starts on its own. It captures the referral like init() below, links Stripe checkouts to it, keeps it in the op_ref cookie, and sends the landing report. The instance is window.openpartner: call openpartner.identify(user.id) after signup or login, openpartner.getReferral() to read the referral, and openpartner.setConsent() from your consent banner.

Optional attributes:

AttributeWhat it does
data-api-urlYour workspace’s API base, as in the table above. Defaults to the script’s origin plus /api.
data-stripe-hosts="pay.yourbrand.com"Your Payment Links use a custom domain. Comma-separate several.
data-cookie-domain=".yourbrand.com"Share op_ref across subdomains, when visitors land on www. and check out on app..
data-stripe="off"Leave Stripe links and embeds alone.
data-cookie="off"Don’t set the op_ref cookie.
data-consent="pending"Wait for openpartner.setConsent() before storing anything. "denied" starts with nothing. See Consent banners.
data-landing="off"Don’t send the landing report.

The script always serves the current build, so it never needs updating.

Capture is automatic

init() runs capture immediately. It reads ?cref=… from the landing URL (a visitor’s first arrival from a partner link) and stashes the token in localStorage, so it survives navigation and later visits for as long as the browser keeps it.

On a page without ?cref=, when localStorage has nothing, the SDK looks for the referral in two cookies on your own domain: the click router’s _cref cookie, when the router runs on your domain, then the op_ref cookie. That’s how a visitor who landed on www. keeps the referral on app. (with the cookie shared across subdomains), and how it survives where localStorage is blocked.

Safari deletes what a page’s scripts store, localStorage included, after 7 days without a visit. A cookie set by your own server isn’t capped that way. Once identify has run, attribution no longer depends on browser storage at all.

There is no separate tracking call. One init() per page load is the whole thing.

Identify

Call when a user signs up or logs in — any time you learn their ID. This is what stitches the authenticated user to the click that brought them in:

await op.identify(currentUser.id);

identify() takes exactly one argument: your internal user ID. Pass the same identifier you’ll later use when reporting events — that’s the join key for the whole attribution chain. If you don’t want to expose raw IDs, a stable hash works too (e.g. sha256(user.id + SALT)), as long as you use the same value everywhere.

Calling identify() repeatedly for the same user is harmless — the same (click, user) pair always maps to one Identity row.

When the visitor came from a partner link, the SDK hands that click to Stripe for you, from the npm package and the script tag alike:

  • Every Payment Link on the page (buy.stripe.com, plus any custom Payment Link domain you list) gets client_reference_id.
  • Every <stripe-pricing-table> and <stripe-buy-button> embed gets client-reference-id.

That includes links and embeds added after the page loads. One that already carries a reference keeps it. With Stripe connected to OpenPartner (Stripe App on the hosted plan, or a self-hosted install’s Stripe webhook), those checkouts then credit the partner with no checkout code at all.

Pricing Table and Buy Button embeds read client-reference-id when Stripe renders them, so load the SDK before Stripe’s embed script.

When your server creates the Stripe Checkout Session, it needs the referral too. The script tag keeps it in a first-party cookie on your own domain, op_ref, so your server can read it straight off the request. With the npm package, turn it on with referralCookie: { name: 'op_ref' } (see Config).

const session = await stripe.checkout.sessions.create({
// ...your existing options
client_reference_id: req.cookies.op_ref, // the partner click, if the visitor had one
});

req.cookies needs a cookie parser. Without one, readReferralCookie(req.headers.cookie) from @openpartner/sdk/server reads it (see below).

The cookie is set only when the visitor came from a partner link, and lasts 90 days from when it’s set. It isn’t rewritten while it holds the same referral, so a cookie your server set keeps its lifetime. A new partner click replaces it. It’s SameSite=Lax, and Secure on HTTPS. Visitors who never clicked a partner link get no cookie, so client_reference_id is left out.

The SDK reads op_ref back whether or not it writes it, so a cookie your server sets works with the npm package’s defaults too.

Without the cookie, send op.getReferral() up from the browser with your checkout request and pass that as client_reference_id.

Safari deletes what a page’s scripts store, localStorage and cookies written from JavaScript alike, after 7 days without a visit. A visitor who clicks a partner link today and comes back to buy in a month, without signing up in between, arrives with nothing. A cookie your own server sets on the landing page’s response isn’t capped that way, and the browser SDK reads it back.

@openpartner/sdk/server has the helpers. With Express or Connect, add the middleware:

import express from 'express';
import { referralCookieMiddleware } from '@openpartner/sdk/server';
const app = express();
app.use(referralCookieMiddleware({ domain: '.yourbrand.com' }));

It sets op_ref on the response to every request whose URL carries ?cref=, adding to any Set-Cookie already queued. Anywhere else, build the header yourself with referralCookieHeader(url, options?). It returns the Set-Cookie value, or null when the URL has no usable ?cref=:

import { referralCookieHeader } from '@openpartner/sdk/server';
const header = referralCookieHeader(req.url, { domain: '.yourbrand.com' });
if (header) res.append('Set-Cookie', header);

Both take the same options:

OptionDefaultWhat it does
name'op_ref'The cookie’s name. The browser SDK reads op_ref; if you rename it, pass the same name in referralCookie.
domainnoneShare it across subdomains, e.g. '.yourbrand.com' when visitors land on www. and buy on app..
days90Lifetime in days.
securetrueSend it over HTTPS only. Set false for plain-HTTP local development.
queryParam'cref'The query parameter that carries the click on the landing URL.

The cookie is SameSite=Lax, and not HttpOnly, because the browser SDK reads it for identify() and Stripe links. Three things to get right:

  • Set it from the server that serves the page itself. Safari caps a cookie set by a server on a different IP address (an API host at another provider, say) at 7 days too.
  • Give the browser SDK the same domain. With domain set here, set data-cookie-domain (or referralCookie.domain) to match, so the SDK can delete the cookie when a visitor declines consent.
  • It doesn’t know about your consent banner. It sets the cookie on the landing response, before the banner is answered. If your site needs consent before storing the referral, set it only for visitors who have already agreed.

Read it on your server

readReferralCookie(cookieHeader, name?) returns the referral from a request’s Cookie header, or null. Use it to call identify() from your backend at signup, or for client_reference_id on a Checkout Session:

import { OpenPartnerServer, readReferralCookie } from '@openpartner/sdk/server';
const op = new OpenPartnerServer({
apiUrl: process.env.OPENPARTNER_API_URL!,
apiKey: process.env.OPENPARTNER_API_KEY!,
});
// At signup
const cref = readReferralCookie(req.headers.cookie);
if (cref) await op.identify({ cref, userId: user.id });

name defaults to op_ref.

Behind a cookie or consent banner, start the SDK in pending mode and pass it the visitor’s answer. With the script tag:

<script src="https://app.openpartner.dev/op.js" data-api-url="https://app.openpartner.dev/api/t/<your-workspace>" data-consent="pending"></script>
// In your consent banner's callback
openpartner.setConsent(accepted ? 'granted' : 'denied');

With the npm package:

const op = OpenPartner.init({
apiUrl: 'https://app.openpartner.dev/api/t/<your-workspace>',
consent: 'pending',
});
// In your consent banner's callback
op.setConsent(accepted ? 'granted' : 'denied');
StateWhat the SDK does
'granted' (default)Stores the referral as soon as it loads. Integrations that don’t set consent work this way.
'pending'Holds the click from the landing URL in memory only. Nothing is read from or written to localStorage or cookies, no Stripe link is tagged, and nothing is sent: no landing report, and identify() does nothing. getReferral() returns null.
'denied'Uses nothing. At load or later, it deletes the referral an earlier visit stored, in localStorage and the op_ref cookie, and takes the client_reference_id it added back off Stripe Payment Links and embeds. A reference you set yourself stays.

setConsent('granted') stores the held click (or, with none, picks up one already stored on an earlier visit), then tags Stripe links, as if consent had been there from the start. It sends the landing report when the click is new to this browser. Calling setConsent() again is safe.

The held click lives on the current page only. It survives client-side route changes in a single-page app, so a visitor who moves around your app before answering the banner keeps the click. Loading another page before the visitor answers loses it: nothing was stored, and the new page’s URL has no ?cref=. Agreeing on a later page then picks up only a referral stored before, such as one from an earlier visit. Otherwise the visitor has to come back through a partner link.

Most consent tools report a returning visitor’s saved answer on every page load. Pass it to setConsent() then, or start with consent: 'granted' when you already know they agreed.

Landing report

When a partner click arrives on your site, one this browser hasn’t stored yet, the SDK posts the click id to POST /attribution/landing. OpenPartner records that the click landed, when, and the page’s hostname, which comes from the browser’s Origin header. It keeps the first landing per click, so a repeat report changes nothing. Nothing else about the visitor is sent. Tracking health compares landings with clicks to show the clicks lost between the partner link and your page.

It’s on by default. Turn it off with data-landing="off" on the script tag, or reportLanding: false. Tracking health then can’t measure how many clicks landed. With consent pending, the report waits for setConsent('granted').

A failed report never affects attribution, and most failures are dropped silently. The one passed to onError is click_not_found: the click isn’t one of this workspace’s, which usually means apiUrl (or data-api-url) points at another workspace. A self-hosted OpenPartner API from before October 24 2026 answers 404, and the SDK ignores it.

Config

OpenPartner.init({
apiUrl: 'https://app.openpartner.dev/api/t/<your-workspace>',
// Optional:
cookieName: '_cref', // click router's cookie, if the router runs on your domain
queryParam: 'cref', // query param on landing URLs
storageKey: 'openpartner:cref', // localStorage key
onError: (err) => Sentry.captureException(err),
stripe: { linkHosts: ['pay.yourbrand.com'] }, // or false to leave Stripe links alone
referralCookie: { name: 'op_ref', domain: '.yourbrand.com', days: 90 }, // off by default
consent: 'pending', // default 'granted'; see Consent banners
reportLanding: false, // default true; see Landing report
});

onError is called when identify() fails, and when a landing report comes back click_not_found. By default it logs [openpartner] request failed to the console. Pass your own to hook in observability. identify() never throws: when the request fails it calls onError and resolves to null.

Stripe linking is on by default; stripe: false turns it off. Writing the op_ref cookie is off unless you set referralCookie (the script tag turns it on).

Stripe linking and the op_ref cookie need SDK 0.2.0 or later. Consent mode, the landing report, the server cookie helpers, and reading op_ref back when localStorage is empty need 0.3.0. Run npm install @openpartner/sdk@latest to upgrade. The script tag always serves the current build.

Framework notes

  • React / Vue / SolidJS — call OpenPartner.init() once at the top of your app; call op.identify() from your auth callback.
  • Next.js App Router — put OpenPartner.init() in a 'use client' provider component mounted in your root layout.
  • SPAs — capture runs on init(), so one init per page load is enough; client-side route changes don’t need re-initialization. A click held while consent is pending survives them too.