@openpartner/sdk 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/README.md +140 -6
- package/dist/browser.cjs +260 -26
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.d.cts +163 -18
- package/dist/browser.d.ts +163 -18
- package/dist/browser.js +260 -26
- package/dist/browser.js.map +1 -1
- package/dist/server.cjs +60 -2
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +112 -8
- package/dist/server.d.ts +112 -8
- package/dist/server.js +56 -1
- package/dist/server.js.map +1 -1
- package/dist/{shared-CLfBjPeb.d.cts → shared-dh1vlldf.d.cts} +19 -4
- package/dist/{shared-CLfBjPeb.d.ts → shared-dh1vlldf.d.ts} +19 -4
- package/dist-global/openpartner.js +1 -0
- package/package.json +38 -16
- package/LICENSE +0 -21
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
Closes the places where a partner click could go missing in the browser, and
|
|
6
|
+
reports clicks that arrive so Admin → Tracking health can show the ones that
|
|
7
|
+
don't. No breaking API changes; the one new default behaviour is the landing
|
|
8
|
+
report (see Compatibility).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Consent mode.** `consent: 'granted' | 'pending' | 'denied'` in
|
|
13
|
+
`OpenPartner.init()` (script tag: `data-consent="pending"`) and
|
|
14
|
+
`setConsent('granted' | 'denied')`.
|
|
15
|
+
- `'pending'` holds the click from the URL in memory only. Nothing is read
|
|
16
|
+
from or written to storage or cookies, no Stripe link is tagged and no
|
|
17
|
+
request is sent until `setConsent('granted')`, which then works even
|
|
18
|
+
after a single-page app has moved off the landing URL.
|
|
19
|
+
- `'denied'`, at load or later, deletes what an earlier visit stored and
|
|
20
|
+
takes the `client_reference_id` the SDK added back off Stripe Payment
|
|
21
|
+
Links and embeds. A reference you set yourself stays. Safe to call more
|
|
22
|
+
than once.
|
|
23
|
+
- The default is `'granted'`: integrations that don't set it behave as
|
|
24
|
+
before.
|
|
25
|
+
- **Landing report.** When a click first arrives from the URL, the SDK posts
|
|
26
|
+
its id once to `POST /attribution/landing` (the page's hostname comes from
|
|
27
|
+
the browser's `Origin` header; nothing else about the visitor is sent).
|
|
28
|
+
Admin → Tracking health compares landings with clicks to show the clicks
|
|
29
|
+
lost between the partner link and your page. Turn it off with
|
|
30
|
+
`reportLanding: false` (script tag: `data-landing="off"`).
|
|
31
|
+
- **Server-side referral cookie** in `@openpartner/sdk/server`, for Safari,
|
|
32
|
+
which deletes storage a page's scripts wrote after 7 days without a visit.
|
|
33
|
+
A cookie your own server sets on the landing response isn't capped that way,
|
|
34
|
+
and the browser SDK reads it back.
|
|
35
|
+
- `referralCookieHeader(url, options?)`: the `Set-Cookie` value for a
|
|
36
|
+
request whose URL carries `?cref=`, or `null`.
|
|
37
|
+
- `referralCookieMiddleware(options?)`: Express / Connect middleware that
|
|
38
|
+
sets it, adding to any `Set-Cookie` already queued.
|
|
39
|
+
- `readReferralCookie(cookieHeader, name?)`: the referral off a `Cookie`
|
|
40
|
+
header, for `identify()` from your backend or `client_reference_id` on a
|
|
41
|
+
Checkout Session.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **The referral is read back from the `op_ref` cookie** (or
|
|
46
|
+
`referralCookie.name`) when localStorage is empty: on a second subdomain
|
|
47
|
+
(land on `www.`, sign up on `app.` with `referralCookie.domain` /
|
|
48
|
+
`data-cookie-domain` set), after Safari clears localStorage, or where
|
|
49
|
+
localStorage is blocked. Previously only localStorage and the router's
|
|
50
|
+
`_cref` cookie were read.
|
|
51
|
+
- **An `op_ref` cookie that already holds the referral isn't rewritten** from
|
|
52
|
+
script, so one your server set keeps its lifetime in Safari. A new click
|
|
53
|
+
still replaces it.
|
|
54
|
+
- **A click from the URL is kept in memory as well,** so the referral cookie,
|
|
55
|
+
Stripe tagging and `identify()` still get it where localStorage throws.
|
|
56
|
+
- **`clear()` also expires the referral cookie** and removes the Stripe
|
|
57
|
+
references the SDK added.
|
|
58
|
+
- **`onError`'s default log line** is now `[openpartner] request failed` (was
|
|
59
|
+
`identify failed`): it also covers the landing report.
|
|
60
|
+
|
|
61
|
+
### Compatibility
|
|
62
|
+
|
|
63
|
+
- The landing report is on by default and needs an OpenPartner API that has
|
|
64
|
+
`POST /attribution/landing` (OpenPartner Cloud, or a self-hosted instance
|
|
65
|
+
from October 24 2026 on). An older API answers 404, and the SDK ignores it:
|
|
66
|
+
only a `click_not_found` from a current API reaches `onError`, because that
|
|
67
|
+
usually means `apiUrl` names another program.
|
|
68
|
+
- The script tag an OpenPartner portal serves at `/op.js` is built from this
|
|
69
|
+
source and already behaves this way.
|
|
70
|
+
|
|
71
|
+
## 0.2.0
|
|
72
|
+
|
|
73
|
+
Published to npm on October 20 2026: Stripe Payment Link and Pricing Table /
|
|
74
|
+
Buy Button linking, the script-tag build, the `op_ref` referral cookie, and the
|
|
75
|
+
server SDK (`trackEvent`, `trackConversion`, `identify`).
|
|
76
|
+
|
|
77
|
+
## 0.1.0
|
|
78
|
+
|
|
79
|
+
The first published version.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Client + server SDK for [OpenPartner](https://github.com/getcoherence/openpartner).
|
|
4
4
|
|
|
5
|
-
- **Browser** — captures the `cref` attribution token from landing URLs and
|
|
5
|
+
- **Browser** — captures the `cref` attribution token from landing URLs and first-party cookies, keeps it across pages and subdomains, waits for consent when you ask it to, and stitches to a logged-in user on auth.
|
|
6
6
|
- **Server** — posts conversion events, stitches server-side where needed, with typed errors.
|
|
7
7
|
|
|
8
8
|
Zero runtime dependencies. ~4 KB gzipped browser bundle.
|
|
@@ -13,7 +13,7 @@ npm install @openpartner/sdk
|
|
|
13
13
|
|
|
14
14
|
## Browser
|
|
15
15
|
|
|
16
|
-
Embed once on every page of your marketing site and product. The SDK runs on init, pulling the cref out of `?cref=…` (first visit from a partner link) or
|
|
16
|
+
Embed once on every page of your marketing site and product. The SDK runs on init, pulling the cref out of `?cref=…` (first visit from a partner link), or on later pages out of localStorage or a first-party cookie (`_cref` from the click router, `op_ref` from a sibling subdomain or your server), and stashing it in localStorage.
|
|
17
17
|
|
|
18
18
|
```ts
|
|
19
19
|
import { OpenPartner } from '@openpartner/sdk';
|
|
@@ -38,9 +38,84 @@ OpenPartner.init({
|
|
|
38
38
|
queryParam: 'cref', // query param on landing URLs
|
|
39
39
|
storageKey: 'openpartner:cref', // localStorage key
|
|
40
40
|
onError: (err) => Sentry.captureException(err),
|
|
41
|
+
stripe: { linkHosts: ['pay.yourbrand.com'] }, // or false — see below
|
|
42
|
+
referralCookie: { name: 'op_ref', domain: '.yourbrand.com' }, // off by default
|
|
43
|
+
consent: 'pending', // default 'granted' — see Consent below
|
|
44
|
+
reportLanding: true, // default on — see Landing report below
|
|
41
45
|
});
|
|
42
46
|
```
|
|
43
47
|
|
|
48
|
+
### Subdomains
|
|
49
|
+
|
|
50
|
+
A visitor who lands on `www.yourbrand.com` and signs up on `app.yourbrand.com` has two localStorages, one per subdomain. Share the referral through a cookie on the parent domain: `referralCookie: { domain: '.yourbrand.com' }` (script tag: `data-cookie-domain=".yourbrand.com"`) on every subdomain. The SDK on `app.` reads `op_ref` back when its own storage is empty, so `identify()` and Stripe links work there too. A journey across two different domains (`yourbrand.com` → `yourbrand-shop.com`) has no shared cookie: carry `?cref=` on the link between them.
|
|
51
|
+
|
|
52
|
+
### Safari and long gaps
|
|
53
|
+
|
|
54
|
+
Safari deletes storage a page's scripts wrote (localStorage, and cookies set through `document.cookie`) after 7 days without a visit. A visitor who clicks, comes back after a fortnight and buys without signing up first would arrive with nothing. Two things close that gap:
|
|
55
|
+
|
|
56
|
+
- **Call `identify()` as early as you can.** Once a click is stitched to a user, the link lives on OpenPartner's side and no browser storage matters. An email capture or account creation before checkout is enough.
|
|
57
|
+
- **Set the referral cookie from your own server**, on the landing page's response. A cookie your server sets keeps its full lifetime in Safari, and the browser SDK reads it back:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { referralCookieHeader, referralCookieMiddleware } from '@openpartner/sdk/server';
|
|
61
|
+
|
|
62
|
+
// Express / Connect:
|
|
63
|
+
app.use(referralCookieMiddleware({ domain: '.yourbrand.com' }));
|
|
64
|
+
|
|
65
|
+
// Anything else (Next.js middleware, a Worker): set the header yourself.
|
|
66
|
+
const header = referralCookieHeader(request.url, { domain: '.yourbrand.com' });
|
|
67
|
+
if (header) response.headers.append('Set-Cookie', header);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Set it from the server that serves the page. Safari caps a cookie at 7 days when it comes from a server on a different IP address than the site, such as an API hosted on another provider. The SDK never rewrites an `op_ref` cookie that already holds the referral, because rewriting it from script would bring the 7-day cap back.
|
|
71
|
+
|
|
72
|
+
### Consent
|
|
73
|
+
|
|
74
|
+
Behind a consent banner, start the SDK with `consent: 'pending'` and tell it the answer:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const op = OpenPartner.init({ apiUrl, consent: 'pending' });
|
|
78
|
+
|
|
79
|
+
yourConsentBanner.onChange((choices) => {
|
|
80
|
+
op.setConsent(choices.marketing ? 'granted' : 'denied');
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
While consent is pending, the SDK keeps the click from the URL in memory and does nothing else: it doesn't read or write storage or cookies, tag Stripe links, or call OpenPartner. `setConsent('granted')` then works as if the page had just loaded, even after the visitor has navigated within a single-page app. `setConsent('denied')` stops everything, takes the references the SDK added back off Stripe links and embeds (one you set yourself stays), and deletes what was stored. Starting with `consent: 'denied'` deletes what an earlier visit stored. `getReferral()` and `identify()` return `null` until consent is granted. Script tag: `data-consent="pending"`, then `openpartner.setConsent('granted')`.
|
|
85
|
+
|
|
86
|
+
Clicks from visitors who never consent can't be credited, and Admin → Tracking health counts them among the clicks that never landed.
|
|
87
|
+
|
|
88
|
+
### Landing report
|
|
89
|
+
|
|
90
|
+
When a partner click first arrives from the URL, the SDK tells OpenPartner (`POST /attribution/landing`, the click id and nothing else; the page's hostname comes from the browser's `Origin` header). **Admin → Tracking health** compares landings with clicks to show the ones lost between the partner link and your page: a redirect that dropped `?cref=`, a page without the script, a consent banner. A `click_not_found` from it reaches `onError` and usually means `apiUrl` names another program; any other failure is dropped, because the report is a diagnostic and never part of attribution. Turn it off with `reportLanding: false` (script tag: `data-landing="off"`).
|
|
91
|
+
|
|
92
|
+
### Stripe links and embeds
|
|
93
|
+
|
|
94
|
+
When the visitor came from a partner link, the SDK hands that click to Stripe for you:
|
|
95
|
+
|
|
96
|
+
- Every Payment Link on the page (`buy.stripe.com`, plus any `linkHosts` you list for a custom Payment Link domain) gets `client_reference_id`.
|
|
97
|
+
- Every `<stripe-pricing-table>` and `<stripe-buy-button>` embed gets `client-reference-id`.
|
|
98
|
+
|
|
99
|
+
That includes elements added after the page loads. A link or embed that already carries a reference keeps it. With your Stripe account connected to OpenPartner (or your self-host's webhook pointed at it), those checkouts then credit the partner with no code at all. `stripe: false` turns it off. For an app-created Checkout Session, pass `op.getReferral()`, or the `op_ref` cookie with `referralCookie` set, as `client_reference_id`.
|
|
100
|
+
|
|
101
|
+
Pricing Table and Buy Button embeds read `client-reference-id` when Stripe renders them, so load the SDK before Stripe's embed script.
|
|
102
|
+
|
|
103
|
+
### Script tag (no bundler)
|
|
104
|
+
|
|
105
|
+
An OpenPartner portal serves a self-starting build of this SDK at `/op.js`. Paste it into your site's `<head>`:
|
|
106
|
+
|
|
107
|
+
```html
|
|
108
|
+
<script src="https://app.openpartner.dev/op.js" data-api-url="https://app.openpartner.dev/api/t/your-slug"></script>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Admin → Integrations** shows the exact tag for your program. It does everything above, keeps the referral in an `op_ref` cookie on your domain, and exposes `window.openpartner` (`openpartner.identify(userId)`, `openpartner.getReferral()`, `openpartner.setConsent('granted')`). Optional attributes:
|
|
112
|
+
- `data-stripe-hosts="pay.yourbrand.com"`: a custom Payment Link domain.
|
|
113
|
+
- `data-cookie-domain=".yourbrand.com"`: share `op_ref` across subdomains.
|
|
114
|
+
- `data-consent="pending"`: wait for `openpartner.setConsent()` (see [Consent](#consent)).
|
|
115
|
+
- `data-stripe="off"`, `data-cookie="off"`, `data-landing="off"`: turn any of them off.
|
|
116
|
+
|
|
117
|
+
The npm package carries the same file at `dist-global/openpartner.js`.
|
|
118
|
+
|
|
44
119
|
### Framework notes
|
|
45
120
|
|
|
46
121
|
- **React / Vue / SolidJS**: call `OpenPartner.init()` once at the top of your app. Call `op.identify()` from your auth callback.
|
|
@@ -68,10 +143,13 @@ await op.trackEvent({
|
|
|
68
143
|
type: 'invoice_paid',
|
|
69
144
|
value: 249,
|
|
70
145
|
currency: 'USD',
|
|
146
|
+
externalEventId: invoice.id, // makes retries safe; see below
|
|
71
147
|
metadata: { stripeInvoiceId: invoice.id },
|
|
72
148
|
});
|
|
73
149
|
```
|
|
74
150
|
|
|
151
|
+
Pass `externalEventId` on every call. Use your system's stable id for that one event, such as the invoice id or order id. Your job queue or HTTP client will retry when a request times out. Without a key, each retry records a second event and pays a second commission. With a key, a retry resolves with the original `eventId` and attribution plus `replayed: true`, and nothing new is written. That includes duplicates sent at the same moment. Use one key per event, not per order: an order's `subscription_created` and its `invoice_paid` need different keys. Reusing a key for a different `userId`, `type`, `value` or `currency` throws an `OpenPartnerError` with code `external_event_id_conflict` (409).
|
|
152
|
+
|
|
75
153
|
The response includes the attribution result:
|
|
76
154
|
|
|
77
155
|
```ts
|
|
@@ -88,15 +166,71 @@ The response includes the attribution result:
|
|
|
88
166
|
}
|
|
89
167
|
```
|
|
90
168
|
|
|
169
|
+
A replay adds `replayed: true`. Its `attribution` is rebuilt from the rows the first call wrote. If that call didn't attribute, the status is `'not_attributed'`, because the specific reason isn't stored.
|
|
170
|
+
|
|
91
171
|
For multi-touch campaigns (`linear`, `position`, etc.) `touches` will contain one entry per attributed click with a fractional `weight`.
|
|
92
172
|
|
|
93
|
-
###
|
|
173
|
+
### Conversions by click id
|
|
174
|
+
|
|
175
|
+
Sometimes the system that learns about the conversion only has the click id, not a user you `identify()`'d. A lender funds a loan, or a CRM closes a deal from a lead form that captured the click id. The click id reaches it from the partner link: as `?cref=…` on the destination URL, or in any parameter you choose, by putting `{click_id}` in the program's destination URL (`https://apply.example.com/?ref={click_id}`). Report the conversion in one call:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
await op.trackConversion({
|
|
179
|
+
clickId, // from ?cref= or your {click_id} parameter
|
|
180
|
+
type: 'loan_funded',
|
|
181
|
+
value: 5000,
|
|
182
|
+
currency: 'USD',
|
|
183
|
+
externalEventId: `${loan.id}:funded`, // required
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
OpenPartner stitches the click and records the event together. Without a `userId` the conversion goes to the click's own user (`click:<clickId>`), so every conversion you report for that click is credited to it, and a "first" commission rule fires once. Pass `userId` when you also identify that user elsewhere. Attribution then follows the program's model across all of that user's clicks. `externalEventId` is required and behaves as on `trackEvent`. An unknown click throws `click_not_found` (404). A click flagged as fraud throws `click_flagged` (422). The response adds `userId` to `trackEvent`'s shape.
|
|
188
|
+
|
|
189
|
+
Senders that can only fire a URL (affiliate networks, many lenders) use the postback URL from the admin Integrations page instead: `GET /postback?click_id=…&type=…&amount=…¤cy=…&txn_id=…&token=…`. Its token records conversions and nothing else.
|
|
190
|
+
|
|
191
|
+
### Stripe
|
|
192
|
+
|
|
193
|
+
How Stripe revenue reaches OpenPartner depends on where OpenPartner runs.
|
|
194
|
+
|
|
195
|
+
**Hosted OpenPartner.** Connect your Stripe account under **Admin → Integrations → Connect Stripe**. That installs the OpenPartner Stripe App on it (beta; read-only except coupons and promotion codes), and your Checkout and Payment Link purchases, invoices, renewals, refunds and disputes are then recorded from Stripe, with no `trackEvent` for them. Customers are linked to the partner click the same way as on self-host, below: the [script tag](#script-tag-no-bundler) does it for Payment Links and embeds on your site, and a Checkout Session your backend creates passes `client_reference_id`. With Stripe connected, a `trackEvent` keyed on a Stripe invoice or Checkout Session id (`in_…`, `cs_…`) throws `recorded_from_stripe` (409): Stripe's record of the payment wins. See [openpartner.dev/docs/stripe-app](https://openpartner.dev/docs/stripe-app). Don't point a webhook from your Stripe account at OpenPartner's `/webhooks/stripe`: on hosted it accepts only events signed with OpenPartner's own Stripe signing secrets.
|
|
196
|
+
|
|
197
|
+
**Self-hosted, single tenant.** If the instance's `STRIPE_SECRET_KEY` and webhook signing secret (`STRIPE_WEBHOOK_SECRET_PLATFORM`, or the legacy `STRIPE_WEBHOOK_SECRET`) belong to *your* Stripe account, you can skip `trackEvent` for Stripe revenue. OpenPartner takes revenue from `invoice.paid` (subscriptions and other invoices) and from paid one-time Checkout Sessions (`mode: 'payment'`, including Payment Links). Point a webhook in your Stripe account at `https://<your-openpartner-api>/webhooks/stripe`. Subscribe it to `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `customer.created`, `customer.subscription.created`, `invoice.paid`, `charge.refunded` and `charge.dispute.created`.
|
|
198
|
+
|
|
199
|
+
The endpoint can use any Stripe API version, including the one Stripe preselects when you create it. Version `2025-03-31.basil` removed the invoice ↔ payment pointers from webhook payloads (`Invoice.charge`, `Invoice.payment_intent`, `Charge.invoice`). On basil and later, OpenPartner reads a refunded charge's invoice back from Stripe's InvoicePayments: one Stripe API call per refund of an invoice's charge.
|
|
200
|
+
|
|
201
|
+
OpenPartner has to know which Stripe customer is which referred user. Link them in one of two ways:
|
|
202
|
+
|
|
203
|
+
- **Checkout `client_reference_id`.** Pass the captured referral through when you create the Checkout Session:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
// Browser: the captured referral, or null if the visitor didn't come from a partner link.
|
|
207
|
+
const ref = op.getReferral();
|
|
208
|
+
|
|
209
|
+
// Your backend, with `ref` sent up from the browser:
|
|
210
|
+
const session = await stripe.checkout.sessions.create({
|
|
211
|
+
client_reference_id: ref ?? undefined,
|
|
212
|
+
// ...line_items, mode, success_url, etc.
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
With the [script tag](#script-tag-no-bundler) (or `referralCookie` set), your backend can read the referral straight off the request instead: `client_reference_id: req.cookies.op_ref`, or `readReferralCookie(req.headers.cookie)` from `@openpartner/sdk/server` without a cookie parser. The same call lets your backend stitch at signup, which works even where browser scripts are blocked: `await op.identify({ cref, userId: user.id })`.
|
|
217
|
+
|
|
218
|
+
A Stripe Payment Link can't run your code, so let the partner link fill it in: set the program's destination URL to the Payment Link with `?client_reference_id={click_id}`, and the click router puts the click id there.
|
|
219
|
+
|
|
220
|
+
On `checkout.session.completed`, OpenPartner stitches the session's customer to that click and records a `signup` event. A paid one-time session also records its `amount_total` as an `invoice_paid` event, keyed on the session id. A guest checkout with no Stripe Customer, the Payment Link default, is keyed to the click's own user (`click:<clickId>`). A delayed payment method (SEPA, ACH, Bacs) records the revenue at `checkout.session.async_payment_succeeded`, when the money actually clears. Subscription sessions record no revenue at checkout, because their `invoice.paid` carries it. Neither does a session with `invoice_creation` enabled, for the same reason. The customer's events are keyed to the Stripe customer id (`cus_…`). Stripe doesn't deliver events in order. If the first `invoice.paid` or `customer.subscription.created` arrives before `checkout.session.completed`, OpenPartner looks up the customer's completed Checkout Sessions in Stripe and stitches then.
|
|
221
|
+
- **Customer metadata `openpartner_user_id`.** Set it on the Stripe Customer to the user id you pass to `identify()`. Set it before the customer pays, for example by creating the Customer with the metadata and passing `customer` when you create the Checkout Session. An invoice that arrives before the metadata is set is skipped, and Stripe won't redeliver it.
|
|
222
|
+
|
|
223
|
+
You can use both. If the Customer already carries your `openpartner_user_id` when the checkout completes, OpenPartner stitches the click to that id instead of the `cus_…` id and leaves your metadata as you set it. The user gets one signup, although `customer.created` and `checkout.session.completed` both report it.
|
|
224
|
+
|
|
225
|
+
Once a customer is linked, OpenPartner ingests and attributes their `customer.subscription.created` and `invoice.paid` automatically, and their later one-time Checkout purchases even without a `client_reference_id`. A refund of an invoice's or one-time purchase's charge (`charge.refunded`) leaves each of that payment's commissions worth its original amount times the share of the collected amount the customer kept, so a full refund keeps nothing. Partial refunds add up. Accrued and approved commissions are reversed, and on a partial refund a replacement for the kept share is accrued with the same status and accrual date. Paid commissions stay paid: a `refund_clawback` adjustment records what the partner holds, and OpenPartner takes it off that partner's next manual or self-hosted Stripe Connect payout. Stripe Connect payouts on hosted OpenPartner don't deduct it, so recover those by hand. A commission in a payout that's in progress is held until the next refund of that charge. Since `2025-03-31.basil`, an invoice can be paid in several payments, and one payment can pay several invoices. A refund of such a payment is recorded but reverses nothing, because the charge doesn't say how much of which invoice it gave back. Report it with `POST /attribution/refunds` and the amount. A dispute is recorded and reverses nothing. If you lose it, report the chargeback with `POST /attribution/refunds` (`"kind": "chargeback"`). Don't also `trackEvent` the same invoices from your backend, or the revenue is counted twice.
|
|
94
226
|
|
|
95
|
-
|
|
227
|
+
If `STRIPE_SECRET_KEY` is a restricted key, give it read access to Customers, Checkout Sessions and Invoices. Invoices covers a promo code applied to an invoice, and on API versions from `2025-03-31.basil` the InvoicePayment that names a refunded charge's invoice. When the key isn't allowed to read one of them, OpenPartner skips that lookup instead of failing the webhook, and the event can go unattributed.
|
|
96
228
|
|
|
97
229
|
Call `trackEvent` from your backend when:
|
|
98
230
|
|
|
99
|
-
- You
|
|
231
|
+
- You're on hosted OpenPartner and haven't connected Stripe.
|
|
232
|
+
- You don't use Stripe, or the customer is linked neither by a Checkout `client_reference_id` nor by `openpartner_user_id` metadata.
|
|
233
|
+
- The revenue doesn't come through Stripe Checkout or an invoice, such as a PaymentIntent your backend charges directly. `payment_intent.*` and `charge.succeeded` aren't ingested as revenue.
|
|
100
234
|
- You want to track custom event types (`trial_started`, `feature_activated`, etc.).
|
|
101
235
|
- You want to override the event timestamp (`ts: '2026-04-20T12:00:00Z'`).
|
|
102
236
|
|
|
@@ -144,7 +278,7 @@ attribution engine ← Applies campaign model, emits Attribution + C
|
|
|
144
278
|
|
|
145
279
|
- **click** — visitor lands on `https://vendor.example.com/r/<slug>`. The OpenPartner router sets `_cref` cookie and writes a `Click` row.
|
|
146
280
|
- **identify** — once the visitor authenticates on the vendor's site, the browser SDK (or server SDK) calls `/attribution/identify` which writes an `Identity` row stitching `cref → userId`.
|
|
147
|
-
- **trackEvent** — any subsequent conversion event is written as an `Event` row. The attribution engine walks `Event.userId → Identity → Click → Campaign`, checks the attribution window, applies the configured model (`last_click` / `first_click` / `linear` / `position`), and writes `Attribution` + `Commission` rows.
|
|
281
|
+
- **trackEvent** — any subsequent conversion event is written as an `Event` row. The attribution engine walks `Event.userId → Identity → Click → Campaign`, checks the attribution window (a repeat of a conversion the user already made, such as a renewal, keeps that conversion's partner past it), applies the configured model (`last_click` / `first_click` / `linear` / `position`), and writes `Attribution` + `Commission` rows.
|
|
148
282
|
|
|
149
283
|
## License
|
|
150
284
|
|
package/dist/browser.cjs
CHANGED
|
@@ -73,23 +73,44 @@ var DEFAULTS = {
|
|
|
73
73
|
cookieName: "_cref",
|
|
74
74
|
queryParam: "cref",
|
|
75
75
|
storageKey: "openpartner:cref",
|
|
76
|
+
stripe: true,
|
|
77
|
+
referralCookie: false,
|
|
78
|
+
consent: "granted",
|
|
79
|
+
reportLanding: true,
|
|
76
80
|
onError: (err) => {
|
|
77
|
-
console.warn("[openpartner]
|
|
81
|
+
console.warn("[openpartner] request failed", err);
|
|
78
82
|
}
|
|
79
83
|
};
|
|
84
|
+
var STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;
|
|
85
|
+
var STRIPE_EMBED_SELECTOR = "stripe-pricing-table, stripe-buy-button";
|
|
86
|
+
var REFERRAL_COOKIE_DEFAULT = "op_ref";
|
|
80
87
|
var OpenPartner = class _OpenPartner {
|
|
81
88
|
constructor(config) {
|
|
82
89
|
__publicField(this, "config");
|
|
90
|
+
__publicField(this, "observer", null);
|
|
91
|
+
__publicField(this, "consent");
|
|
92
|
+
/** The URL's referral while consent is pending, held in memory only. */
|
|
93
|
+
__publicField(this, "held", null);
|
|
94
|
+
/** This page's referral, kept here too: where localStorage throws it is
|
|
95
|
+
* the only copy until the cookie is written. */
|
|
96
|
+
__publicField(this, "current", null);
|
|
97
|
+
/** The Stripe links and embeds this SDK tagged, and with what, so a
|
|
98
|
+
* withdrawn consent can take the tags back off. */
|
|
99
|
+
__publicField(this, "tagged", []);
|
|
83
100
|
this.config = { ...DEFAULTS, ...config };
|
|
84
|
-
this.
|
|
101
|
+
this.consent = this.config.consent;
|
|
102
|
+
if (this.consent === "granted") this.start();
|
|
103
|
+
else if (this.consent === "pending") this.held = this.queryCref();
|
|
104
|
+
else this.clear();
|
|
85
105
|
}
|
|
86
106
|
static init(config) {
|
|
87
107
|
return new _OpenPartner(config);
|
|
88
108
|
}
|
|
89
|
-
/** The cref we're holding onto, if any. Useful for debugging.
|
|
109
|
+
/** The cref we're holding onto, if any. Useful for debugging. Null until
|
|
110
|
+
* consent is granted. */
|
|
90
111
|
getCref() {
|
|
91
|
-
if (
|
|
92
|
-
return
|
|
112
|
+
if (this.consent !== "granted") return null;
|
|
113
|
+
return this.storageGet() ?? this.current ?? this.readCookie(this.referralCookieName());
|
|
93
114
|
}
|
|
94
115
|
/**
|
|
95
116
|
* Captured referral token for this visitor. Pass to Stripe Checkout
|
|
@@ -101,47 +122,260 @@ var OpenPartner = class _OpenPartner {
|
|
|
101
122
|
* // ...
|
|
102
123
|
* });
|
|
103
124
|
*
|
|
104
|
-
* Returns null if the visitor didn't arrive via a partner link
|
|
125
|
+
* Returns null if the visitor didn't arrive via a partner link, or
|
|
126
|
+
* hasn't agreed to tracking yet.
|
|
105
127
|
*/
|
|
106
128
|
getReferral() {
|
|
107
129
|
return this.getCref();
|
|
108
130
|
}
|
|
109
|
-
/** Explicitly discard the captured cref
|
|
131
|
+
/** Explicitly discard the captured cref, the referral cookie, and the
|
|
132
|
+
* references this SDK added to Stripe links and embeds. */
|
|
110
133
|
clear() {
|
|
111
|
-
|
|
112
|
-
|
|
134
|
+
this.held = null;
|
|
135
|
+
this.current = null;
|
|
136
|
+
this.untagStripe();
|
|
137
|
+
this.storageRemove();
|
|
138
|
+
if (typeof document === "undefined") return;
|
|
139
|
+
const name = this.referralCookieName();
|
|
140
|
+
if (!this.readCookie(name)) return;
|
|
141
|
+
const cfg = this.config.referralCookie;
|
|
142
|
+
document.cookie = `${name}=; Max-Age=0; Path=/`;
|
|
143
|
+
if (cfg && cfg.domain) document.cookie = `${name}=; Max-Age=0; Path=/; Domain=${cfg.domain}`;
|
|
113
144
|
}
|
|
114
145
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
146
|
+
* Record the visitor's answer to your consent banner. `'granted'` stores
|
|
147
|
+
* the referral (the one held since the page loaded, or one already
|
|
148
|
+
* stored), starts tagging Stripe links and sends the landing report.
|
|
149
|
+
* `'denied'` stops all of it, takes the references it added off Stripe
|
|
150
|
+
* links and embeds, and deletes what was stored. Calling it again is
|
|
151
|
+
* safe.
|
|
118
152
|
*/
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
localStorage.setItem(this.config.storageKey, queryCref);
|
|
153
|
+
setConsent(state) {
|
|
154
|
+
if (state === "granted") {
|
|
155
|
+
if (this.consent === "granted") return;
|
|
156
|
+
this.consent = state;
|
|
157
|
+
this.start();
|
|
125
158
|
return;
|
|
126
159
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
160
|
+
this.consent = state;
|
|
161
|
+
this.stopLinkingStripe();
|
|
162
|
+
this.clear();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Add the referral to every Stripe Payment Link and Pricing Table / Buy
|
|
166
|
+
* Button embed under `root` that doesn't carry a reference already.
|
|
167
|
+
* Runs on its own at init and for anything added later; call it yourself
|
|
168
|
+
* only if you build Stripe elements inside a shadow root. Returns how
|
|
169
|
+
* many it changed.
|
|
170
|
+
*/
|
|
171
|
+
linkStripe(root = document) {
|
|
172
|
+
const referral = this.getReferral();
|
|
173
|
+
if (!referral || !STRIPE_REFERENCE_RE.test(referral) || typeof document === "undefined") return 0;
|
|
174
|
+
const hosts = /* @__PURE__ */ new Set(["buy.stripe.com", ...this.stripeLinkHosts()]);
|
|
175
|
+
let changed = 0;
|
|
176
|
+
const anchors = [];
|
|
177
|
+
if (root instanceof HTMLAnchorElement) anchors.push(root);
|
|
178
|
+
anchors.push(...Array.from(root.querySelectorAll("a[href]")));
|
|
179
|
+
for (const a of anchors) {
|
|
180
|
+
let url;
|
|
181
|
+
try {
|
|
182
|
+
url = new URL(a.href);
|
|
183
|
+
} catch {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has("client_reference_id")) continue;
|
|
187
|
+
url.searchParams.set("client_reference_id", referral);
|
|
188
|
+
a.href = url.toString();
|
|
189
|
+
this.remember(a, referral);
|
|
190
|
+
changed += 1;
|
|
191
|
+
}
|
|
192
|
+
const embeds = [];
|
|
193
|
+
if (root instanceof Element && root.matches(STRIPE_EMBED_SELECTOR)) embeds.push(root);
|
|
194
|
+
embeds.push(...Array.from(root.querySelectorAll(STRIPE_EMBED_SELECTOR)));
|
|
195
|
+
for (const el of embeds) {
|
|
196
|
+
if (el.hasAttribute("client-reference-id")) continue;
|
|
197
|
+
el.setAttribute("client-reference-id", referral);
|
|
198
|
+
this.remember(el, referral);
|
|
199
|
+
changed += 1;
|
|
200
|
+
}
|
|
201
|
+
return changed;
|
|
202
|
+
}
|
|
203
|
+
remember(el, value) {
|
|
204
|
+
this.tagged = this.tagged.filter((t) => t.el.isConnected);
|
|
205
|
+
this.tagged.push({ el, value });
|
|
206
|
+
}
|
|
207
|
+
/** Take back the references this SDK added. One the brand set, or one
|
|
208
|
+
* changed since, stays. */
|
|
209
|
+
untagStripe() {
|
|
210
|
+
for (const { el, value } of this.tagged) {
|
|
211
|
+
if (el instanceof HTMLAnchorElement) {
|
|
212
|
+
let url;
|
|
213
|
+
try {
|
|
214
|
+
url = new URL(el.href);
|
|
215
|
+
} catch {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (url.searchParams.get("client_reference_id") !== value) continue;
|
|
219
|
+
url.searchParams.delete("client_reference_id");
|
|
220
|
+
el.href = url.toString();
|
|
221
|
+
} else if (el.getAttribute("client-reference-id") === value) {
|
|
222
|
+
el.removeAttribute("client-reference-id");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
this.tagged = [];
|
|
226
|
+
}
|
|
227
|
+
/** Stop watching the page for new Stripe links and embeds. */
|
|
228
|
+
stopLinkingStripe() {
|
|
229
|
+
this.observer?.disconnect();
|
|
230
|
+
this.observer = null;
|
|
231
|
+
}
|
|
232
|
+
stripeLinkHosts() {
|
|
233
|
+
const cfg = this.config.stripe;
|
|
234
|
+
return typeof cfg === "object" && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];
|
|
235
|
+
}
|
|
236
|
+
/** Everything the SDK does once it may: store, mirror, link, report. */
|
|
237
|
+
start() {
|
|
238
|
+
const landed = this.captureCref(this.held);
|
|
239
|
+
this.held = null;
|
|
240
|
+
this.writeReferralCookie();
|
|
241
|
+
if (this.config.stripe !== false) this.watchStripe();
|
|
242
|
+
if (landed && this.config.reportLanding !== false) void this.reportLanding(landed);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Link what's on the page now, then anything added or re-pointed later
|
|
246
|
+
* (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor
|
|
247
|
+
* who didn't come from a partner link costs nothing.
|
|
248
|
+
*
|
|
249
|
+
* Pricing Table and Buy Button embeds read client-reference-id when
|
|
250
|
+
* Stripe's script renders them, so the script tag belongs in <head>,
|
|
251
|
+
* ahead of the embed: the observer then sets the attribute as the
|
|
252
|
+
* element is parsed, before Stripe upgrades it.
|
|
253
|
+
*/
|
|
254
|
+
watchStripe() {
|
|
255
|
+
if (typeof document === "undefined" || this.observer || !this.getReferral()) return;
|
|
256
|
+
this.linkStripe(document);
|
|
257
|
+
if (typeof MutationObserver === "undefined") return;
|
|
258
|
+
this.observer = new MutationObserver((records) => {
|
|
259
|
+
for (const record of records) {
|
|
260
|
+
if (record.type === "attributes") {
|
|
261
|
+
if (record.target instanceof HTMLAnchorElement) this.linkStripe(record.target);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
for (const node of Array.from(record.addedNodes)) {
|
|
265
|
+
if (node instanceof Element) this.linkStripe(node);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
this.observer.observe(document.documentElement, {
|
|
270
|
+
childList: true,
|
|
271
|
+
subtree: true,
|
|
272
|
+
attributes: true,
|
|
273
|
+
attributeFilter: ["href"]
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/** The cref on the landing URL, if any. */
|
|
277
|
+
queryCref() {
|
|
278
|
+
if (typeof window === "undefined") return null;
|
|
279
|
+
return new URL(window.location.href).searchParams.get(this.config.queryParam);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Capture the cref from the URL (or the one held while consent was
|
|
283
|
+
* pending), else the router's cookie, else the referral cookie, and
|
|
284
|
+
* stash it in localStorage so it survives SPA navigations and later
|
|
285
|
+
* page loads. Returns the cref when it's a click arriving now, one this
|
|
286
|
+
* browser didn't have yet, for the landing report.
|
|
287
|
+
*/
|
|
288
|
+
captureCref(held) {
|
|
289
|
+
if (typeof window === "undefined") return null;
|
|
290
|
+
const incoming = held ?? this.queryCref();
|
|
291
|
+
if (incoming) {
|
|
292
|
+
const isNew = this.storageGet() !== incoming;
|
|
293
|
+
this.current = incoming;
|
|
294
|
+
this.storageSet(incoming);
|
|
295
|
+
return isNew ? incoming : null;
|
|
296
|
+
}
|
|
297
|
+
if (this.storageGet()) return null;
|
|
298
|
+
const restored = this.readCookie(this.config.cookieName) ?? this.readCookie(this.referralCookieName());
|
|
299
|
+
if (restored) {
|
|
300
|
+
this.current = restored;
|
|
301
|
+
this.storageSet(restored);
|
|
130
302
|
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
referralCookieName() {
|
|
306
|
+
const cfg = this.config.referralCookie;
|
|
307
|
+
return cfg && cfg.name || REFERRAL_COOKIE_DEFAULT;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Mirror the referral into the brand's own first-party cookie (when
|
|
311
|
+
* configured). Left alone when it already holds this referral: in Safari
|
|
312
|
+
* rewriting it from script would trade a server-set cookie's full
|
|
313
|
+
* lifetime for the 7-day cap. A new click overwrites it.
|
|
314
|
+
*/
|
|
315
|
+
writeReferralCookie() {
|
|
316
|
+
const cfg = this.config.referralCookie;
|
|
317
|
+
if (!cfg || typeof document === "undefined") return;
|
|
318
|
+
const referral = this.getReferral();
|
|
319
|
+
if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;
|
|
320
|
+
const name = this.referralCookieName();
|
|
321
|
+
if (this.readCookie(name) === referral) return;
|
|
322
|
+
const maxAge = Math.round((cfg.days ?? 90) * 86400);
|
|
323
|
+
const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
|
|
324
|
+
const domain = cfg.domain ? `; Domain=${cfg.domain}` : "";
|
|
325
|
+
document.cookie = `${name}=${encodeURIComponent(referral)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${domain}${secure}`;
|
|
131
326
|
}
|
|
132
327
|
readCookie(name) {
|
|
133
328
|
if (typeof document === "undefined") return null;
|
|
134
329
|
const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));
|
|
135
|
-
|
|
330
|
+
if (!match) return null;
|
|
331
|
+
try {
|
|
332
|
+
return decodeURIComponent(match[2]);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// localStorage throws in some sandboxed iframes and with site data
|
|
338
|
+
// blocked; the referral then lives in the cookie alone.
|
|
339
|
+
storageGet() {
|
|
340
|
+
try {
|
|
341
|
+
return typeof localStorage === "undefined" ? null : localStorage.getItem(this.config.storageKey);
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
storageSet(value) {
|
|
347
|
+
try {
|
|
348
|
+
if (typeof localStorage !== "undefined") localStorage.setItem(this.config.storageKey, value);
|
|
349
|
+
} catch {
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
storageRemove() {
|
|
353
|
+
try {
|
|
354
|
+
if (typeof localStorage !== "undefined") localStorage.removeItem(this.config.storageKey);
|
|
355
|
+
} catch {
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Tell OpenPartner the click arrived. Best-effort, once per click. */
|
|
359
|
+
async reportLanding(cref) {
|
|
360
|
+
if (!STRIPE_REFERENCE_RE.test(cref) || typeof fetch === "undefined") return;
|
|
361
|
+
try {
|
|
362
|
+
await requestJson(`${this.config.apiUrl}/attribution/landing`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
body: { cref },
|
|
365
|
+
keepalive: true
|
|
366
|
+
});
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (err instanceof OpenPartnerError && err.code === "click_not_found") this.config.onError(err);
|
|
369
|
+
}
|
|
136
370
|
}
|
|
137
371
|
/**
|
|
138
372
|
* Stitch the current captured click to an authenticated user. Call
|
|
139
373
|
* this on login, signup, or anywhere the user becomes known.
|
|
140
374
|
*
|
|
141
|
-
* No-op if no cref was captured. Errors are
|
|
142
|
-
* `config.onError` by default — attribution is
|
|
143
|
-
* browser — but you can await this and try/catch
|
|
144
|
-
* surface failures explicitly.
|
|
375
|
+
* No-op if no cref was captured, or consent isn't granted. Errors are
|
|
376
|
+
* reported through `config.onError` by default — attribution is
|
|
377
|
+
* best-effort from the browser — but you can await this and try/catch
|
|
378
|
+
* if you want to surface failures explicitly.
|
|
145
379
|
*/
|
|
146
380
|
async identify(userId) {
|
|
147
381
|
const cref = this.getCref();
|