@openpartner/sdk 0.1.0 → 0.2.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/README.md CHANGED
@@ -38,9 +38,37 @@ 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
41
43
  });
42
44
  ```
43
45
 
46
+ ### Stripe links and embeds
47
+
48
+ When the visitor came from a partner link, the SDK hands that click to Stripe for you:
49
+
50
+ - Every Payment Link on the page (`buy.stripe.com`, plus any `linkHosts` you list for a custom Payment Link domain) gets `client_reference_id`.
51
+ - Every `<stripe-pricing-table>` and `<stripe-buy-button>` embed gets `client-reference-id`.
52
+
53
+ 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`.
54
+
55
+ Pricing Table and Buy Button embeds read `client-reference-id` when Stripe renders them, so load the SDK before Stripe's embed script.
56
+
57
+ ### Script tag (no bundler)
58
+
59
+ An OpenPartner portal serves a self-starting build of this SDK at `/op.js`. Paste it into your site's `<head>`:
60
+
61
+ ```html
62
+ <script src="https://app.openpartner.dev/op.js" data-api-url="https://app.openpartner.dev/api/t/your-slug"></script>
63
+ ```
64
+
65
+ **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()`). Optional attributes:
66
+ - `data-stripe-hosts="pay.yourbrand.com"`: a custom Payment Link domain.
67
+ - `data-cookie-domain=".yourbrand.com"`: share `op_ref` across subdomains.
68
+ - `data-stripe="off"`, `data-cookie="off"`: turn either off.
69
+
70
+ The npm package carries the same file at `dist-global/openpartner.js`.
71
+
44
72
  ### Framework notes
45
73
 
46
74
  - **React / Vue / SolidJS**: call `OpenPartner.init()` once at the top of your app. Call `op.identify()` from your auth callback.
@@ -68,10 +96,13 @@ await op.trackEvent({
68
96
  type: 'invoice_paid',
69
97
  value: 249,
70
98
  currency: 'USD',
99
+ externalEventId: invoice.id, // makes retries safe; see below
71
100
  metadata: { stripeInvoiceId: invoice.id },
72
101
  });
73
102
  ```
74
103
 
104
+ 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).
105
+
75
106
  The response includes the attribution result:
76
107
 
77
108
  ```ts
@@ -88,15 +119,71 @@ The response includes the attribution result:
88
119
  }
89
120
  ```
90
121
 
122
+ 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.
123
+
91
124
  For multi-touch campaigns (`linear`, `position`, etc.) `touches` will contain one entry per attributed click with a fractional `weight`.
92
125
 
93
- ### Stripe webhooks
126
+ ### Conversions by click id
127
+
128
+ 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:
129
+
130
+ ```ts
131
+ await op.trackConversion({
132
+ clickId, // from ?cref= or your {click_id} parameter
133
+ type: 'loan_funded',
134
+ value: 5000,
135
+ currency: 'USD',
136
+ externalEventId: `${loan.id}:funded`, // required
137
+ });
138
+ ```
139
+
140
+ 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.
141
+
142
+ 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=…&currency=…&txn_id=…&token=…`. Its token records conversions and nothing else.
143
+
144
+ ### Stripe
145
+
146
+ How Stripe revenue reaches OpenPartner depends on where OpenPartner runs.
147
+
148
+ **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.
149
+
150
+ **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`.
151
+
152
+ 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.
153
+
154
+ OpenPartner has to know which Stripe customer is which referred user. Link them in one of two ways:
155
+
156
+ - **Checkout `client_reference_id`.** Pass the captured referral through when you create the Checkout Session:
157
+
158
+ ```ts
159
+ // Browser: the captured referral, or null if the visitor didn't come from a partner link.
160
+ const ref = op.getReferral();
161
+
162
+ // Your backend, with `ref` sent up from the browser:
163
+ const session = await stripe.checkout.sessions.create({
164
+ client_reference_id: ref ?? undefined,
165
+ // ...line_items, mode, success_url, etc.
166
+ });
167
+ ```
168
+
169
+ 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`.
170
+
171
+ 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.
172
+
173
+ 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.
174
+ - **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.
175
+
176
+ 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.
177
+
178
+ 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
179
 
95
- OpenPartner's API has a built-in `/webhooks/stripe` handler that ingests `customer.created`, `customer.subscription.created`, and `invoice.paid` automatically. If you're already using Stripe with OpenPartner, point your Stripe webhook at OpenPartner's API directly — you don't need to call `trackEvent` from your own code.
180
+ 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
181
 
97
182
  Call `trackEvent` from your backend when:
98
183
 
99
- - You don't use Stripe (or Stripe isn't the source of the conversion).
184
+ - You're on hosted OpenPartner and haven't connected Stripe.
185
+ - You don't use Stripe, or the customer is linked neither by a Checkout `client_reference_id` nor by `openpartner_user_id` metadata.
186
+ - 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
187
  - You want to track custom event types (`trial_started`, `feature_activated`, etc.).
101
188
  - You want to override the event timestamp (`ts: '2026-04-20T12:00:00Z'`).
102
189
 
@@ -144,7 +231,7 @@ attribution engine ← Applies campaign model, emits Attribution + C
144
231
 
145
232
  - **click** — visitor lands on `https://vendor.example.com/r/<slug>`. The OpenPartner router sets `_cref` cookie and writes a `Click` row.
146
233
  - **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.
234
+ - **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
235
 
149
236
  ## License
150
237
 
package/dist/browser.cjs CHANGED
@@ -73,15 +73,22 @@ var DEFAULTS = {
73
73
  cookieName: "_cref",
74
74
  queryParam: "cref",
75
75
  storageKey: "openpartner:cref",
76
+ stripe: true,
77
+ referralCookie: false,
76
78
  onError: (err) => {
77
79
  console.warn("[openpartner] identify failed", err);
78
80
  }
79
81
  };
82
+ var STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;
83
+ var STRIPE_EMBED_SELECTOR = "stripe-pricing-table, stripe-buy-button";
80
84
  var OpenPartner = class _OpenPartner {
81
85
  constructor(config) {
82
86
  __publicField(this, "config");
87
+ __publicField(this, "observer", null);
83
88
  this.config = { ...DEFAULTS, ...config };
84
89
  this.captureCref();
90
+ this.writeReferralCookie();
91
+ if (this.config.stripe !== false) this.watchStripe();
85
92
  }
86
93
  static init(config) {
87
94
  return new _OpenPartner(config);
@@ -111,6 +118,84 @@ var OpenPartner = class _OpenPartner {
111
118
  if (typeof localStorage === "undefined") return;
112
119
  localStorage.removeItem(this.config.storageKey);
113
120
  }
121
+ /**
122
+ * Add the referral to every Stripe Payment Link and Pricing Table / Buy
123
+ * Button embed under `root` that doesn't carry a reference already.
124
+ * Runs on its own at init and for anything added later; call it yourself
125
+ * only if you build Stripe elements inside a shadow root. Returns how
126
+ * many it changed.
127
+ */
128
+ linkStripe(root = document) {
129
+ const referral = this.getReferral();
130
+ if (!referral || !STRIPE_REFERENCE_RE.test(referral) || typeof document === "undefined") return 0;
131
+ const hosts = /* @__PURE__ */ new Set(["buy.stripe.com", ...this.stripeLinkHosts()]);
132
+ let changed = 0;
133
+ const anchors = [];
134
+ if (root instanceof HTMLAnchorElement) anchors.push(root);
135
+ anchors.push(...Array.from(root.querySelectorAll("a[href]")));
136
+ for (const a of anchors) {
137
+ let url;
138
+ try {
139
+ url = new URL(a.href);
140
+ } catch {
141
+ continue;
142
+ }
143
+ if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has("client_reference_id")) continue;
144
+ url.searchParams.set("client_reference_id", referral);
145
+ a.href = url.toString();
146
+ changed += 1;
147
+ }
148
+ const embeds = [];
149
+ if (root instanceof Element && root.matches(STRIPE_EMBED_SELECTOR)) embeds.push(root);
150
+ embeds.push(...Array.from(root.querySelectorAll(STRIPE_EMBED_SELECTOR)));
151
+ for (const el of embeds) {
152
+ if (el.hasAttribute("client-reference-id")) continue;
153
+ el.setAttribute("client-reference-id", referral);
154
+ changed += 1;
155
+ }
156
+ return changed;
157
+ }
158
+ /** Stop watching the page for new Stripe links and embeds. */
159
+ stopLinkingStripe() {
160
+ this.observer?.disconnect();
161
+ this.observer = null;
162
+ }
163
+ stripeLinkHosts() {
164
+ const cfg = this.config.stripe;
165
+ return typeof cfg === "object" && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];
166
+ }
167
+ /**
168
+ * Link what's on the page now, then anything added or re-pointed later
169
+ * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor
170
+ * who didn't come from a partner link costs nothing.
171
+ *
172
+ * Pricing Table and Buy Button embeds read client-reference-id when
173
+ * Stripe's script renders them, so the script tag belongs in <head>,
174
+ * ahead of the embed: the observer then sets the attribute as the
175
+ * element is parsed, before Stripe upgrades it.
176
+ */
177
+ watchStripe() {
178
+ if (typeof document === "undefined" || !this.getReferral()) return;
179
+ this.linkStripe(document);
180
+ if (typeof MutationObserver === "undefined") return;
181
+ this.observer = new MutationObserver((records) => {
182
+ for (const record of records) {
183
+ if (record.type === "attributes") {
184
+ if (record.target instanceof HTMLAnchorElement) this.linkStripe(record.target);
185
+ continue;
186
+ }
187
+ for (const node of Array.from(record.addedNodes)) {
188
+ if (node instanceof Element) this.linkStripe(node);
189
+ }
190
+ }
191
+ });
192
+ this.observer.observe(document.documentElement, {
193
+ childList: true,
194
+ subtree: true,
195
+ attributes: true,
196
+ attributeFilter: ["href"]
197
+ });
198
+ }
114
199
  /**
115
200
  * Capture the cref from query string or cookie and stash in
116
201
  * localStorage. Runs on init so the value survives SPA navigations,
@@ -129,6 +214,19 @@ var OpenPartner = class _OpenPartner {
129
214
  localStorage.setItem(this.config.storageKey, cookieCref);
130
215
  }
131
216
  }
217
+ /** Mirror the referral into the brand's own first-party cookie (when
218
+ * configured), refreshed on every page load that has one. */
219
+ writeReferralCookie() {
220
+ const cfg = this.config.referralCookie;
221
+ if (!cfg || typeof document === "undefined") return;
222
+ const referral = this.getReferral();
223
+ if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;
224
+ const name = cfg.name ?? "op_ref";
225
+ const maxAge = Math.round((cfg.days ?? 90) * 86400);
226
+ const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
227
+ const domain = cfg.domain ? `; Domain=${cfg.domain}` : "";
228
+ document.cookie = `${name}=${encodeURIComponent(referral)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${domain}${secure}`;
229
+ }
132
230
  readCookie(name) {
133
231
  if (typeof document === "undefined") return null;
134
232
  const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/browser.ts","../src/shared.ts"],"sourcesContent":["/**\n * Browser SDK — runs inside a merchant's web app. Captures the `cref`\n * attribution token from landing-page URLs and the first-party cookie\n * the click router sets, stashes it in localStorage so it survives\n * Safari / ITP cookie expiry and SPA navigations, and stitches to the\n * authenticated user on signup or login.\n *\n * Typical integration:\n *\n * import { OpenPartner } from '@openpartner/sdk';\n *\n * const op = OpenPartner.init({ apiUrl: 'https://openpartner.example.com' });\n *\n * // After login/signup, once you know who the user is:\n * await op.identify(user.id);\n */\n\nimport { OpenPartnerError, requestJson, type IdentifyResponse } from './shared.js';\n\nexport { OpenPartnerError };\nexport type { IdentifyResponse };\n\nexport interface OpenPartnerConfig {\n /** Base URL of your OpenPartner API, e.g. \"https://openpartner.example.com\". */\n apiUrl: string;\n /** First-party cookie name set by the click router. Default `_cref`. */\n cookieName?: string;\n /** Query-string parameter that carries the cref on the landing URL. Default `cref`. */\n queryParam?: string;\n /** localStorage key the captured cref is stashed under. Default `openpartner:cref`. */\n storageKey?: string;\n /**\n * Called when identify() fails. Default: log to console. Pass a noop\n * to suppress entirely or hook into your observability stack.\n */\n onError?: (err: unknown) => void;\n}\n\nconst DEFAULTS = {\n cookieName: '_cref',\n queryParam: 'cref',\n storageKey: 'openpartner:cref',\n onError: (err: unknown) => {\n console.warn('[openpartner] identify failed', err);\n },\n};\n\nexport class OpenPartner {\n private config: Required<OpenPartnerConfig>;\n\n private constructor(config: OpenPartnerConfig) {\n this.config = { ...DEFAULTS, ...config } as Required<OpenPartnerConfig>;\n this.captureCref();\n }\n\n static init(config: OpenPartnerConfig): OpenPartner {\n return new OpenPartner(config);\n }\n\n /** The cref we're holding onto, if any. Useful for debugging. */\n getCref(): string | null {\n if (typeof localStorage === 'undefined') return null;\n return localStorage.getItem(this.config.storageKey);\n }\n\n /**\n * Captured referral token for this visitor. Pass to Stripe Checkout\n * as `client_reference_id` to enable webhook-based attribution\n * without an explicit identify() call:\n *\n * stripe.checkout.sessions.create({\n * client_reference_id: op.getReferral() ?? undefined,\n * // ...\n * });\n *\n * Returns null if the visitor didn't arrive via a partner link.\n */\n getReferral(): string | null {\n return this.getCref();\n }\n\n /** Explicitly discard the captured cref. */\n clear(): void {\n if (typeof localStorage === 'undefined') return;\n localStorage.removeItem(this.config.storageKey);\n }\n\n /**\n * Capture the cref from query string or cookie and stash in\n * localStorage. Runs on init so the value survives SPA navigations,\n * ITP cookie expiry, and subsequent page loads.\n */\n private captureCref(): void {\n if (typeof window === 'undefined' || typeof localStorage === 'undefined') return;\n\n const url = new URL(window.location.href);\n const queryCref = url.searchParams.get(this.config.queryParam);\n\n if (queryCref) {\n localStorage.setItem(this.config.storageKey, queryCref);\n return;\n }\n\n const cookieCref = this.readCookie(this.config.cookieName);\n if (cookieCref && !localStorage.getItem(this.config.storageKey)) {\n localStorage.setItem(this.config.storageKey, cookieCref);\n }\n }\n\n private readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null;\n const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));\n return match ? decodeURIComponent(match[2]!) : null;\n }\n\n /**\n * Stitch the current captured click to an authenticated user. Call\n * this on login, signup, or anywhere the user becomes known.\n *\n * No-op if no cref was captured. Errors are reported through\n * `config.onError` by default — attribution is best-effort from the\n * browser — but you can await this and try/catch if you want to\n * surface failures explicitly.\n */\n async identify(userId: string): Promise<IdentifyResponse | null> {\n const cref = this.getCref();\n if (!cref) return null;\n\n try {\n return await requestJson<IdentifyResponse>(`${this.config.apiUrl}/attribution/identify`, {\n method: 'POST',\n body: { cref, userId, ts: Date.now() },\n keepalive: true,\n });\n } catch (err) {\n this.config.onError(err);\n return null;\n }\n }\n}\n","/**\n * Shared types + error class for both the browser and server entry\n * points.\n *\n * Keeping these in their own module means browser imports won't drag in\n * `fetch` polyfills or server-only code, and server imports won't touch\n * anything that references `window` or `document`.\n */\n\nexport class OpenPartnerError extends Error {\n constructor(\n message: string,\n public readonly status: number,\n public readonly code?: string,\n public readonly detail?: unknown,\n ) {\n super(message);\n this.name = 'OpenPartnerError';\n }\n}\n\nexport interface IdentifyResponse {\n ok: boolean;\n identityId: string | null;\n firstStitch: boolean;\n backfilledAttributions?: number;\n}\n\nexport interface EventAttribution {\n status: 'attributed' | 'no_identity' | 'no_click' | 'outside_window' | 'already_attributed';\n model?: 'last_click' | 'first_click' | 'linear' | 'position';\n touches?: Array<{\n clickId: string;\n partnerId: string;\n weight: number;\n attributionId: string;\n commissionId?: string;\n }>;\n}\n\nexport interface EventResponse {\n ok: boolean;\n eventId: string;\n attribution: EventAttribution;\n}\n\n/**\n * Small fetch helper used by both entries. Normalizes non-2xx responses\n * into OpenPartnerError so callers can try/catch with a stable shape\n * instead of poking at Response objects themselves.\n */\nexport type JsonRequestInit = Omit<RequestInit, 'body'> & { body?: unknown };\n\nexport async function requestJson<T>(\n input: string,\n init: JsonRequestInit = {},\n fetchImpl: typeof fetch = globalThis.fetch,\n): Promise<T> {\n const headers = new Headers(init.headers);\n if (init.body !== undefined && !headers.has('content-type')) {\n headers.set('content-type', 'application/json');\n }\n const res = await fetchImpl(input, {\n ...init,\n headers,\n body:\n init.body === undefined\n ? undefined\n : typeof init.body === 'string'\n ? init.body\n : JSON.stringify(init.body),\n });\n\n const text = await res.text();\n let body: unknown = undefined;\n if (text) {\n try {\n body = JSON.parse(text);\n } catch {\n body = text;\n }\n }\n\n if (!res.ok) {\n const errBody = (body ?? {}) as { error?: string; detail?: unknown };\n throw new OpenPartnerError(\n errBody.error ?? `${res.status} ${res.statusText}`,\n res.status,\n errBody.error,\n errBody.detail ?? body,\n );\n }\n\n return body as T;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/browser.ts","../src/shared.ts"],"sourcesContent":["/**\n * Browser SDK — runs inside a merchant's web app. Captures the `cref`\n * attribution token from landing-page URLs and the first-party cookie\n * the click router sets, stashes it in localStorage so it survives\n * Safari / ITP cookie expiry and SPA navigations, and stitches to the\n * authenticated user on signup or login.\n *\n * Typical integration:\n *\n * import { OpenPartner } from '@openpartner/sdk';\n *\n * const op = OpenPartner.init({ apiUrl: 'https://openpartner.example.com' });\n *\n * // After login/signup, once you know who the user is:\n * await op.identify(user.id);\n *\n * With a captured referral it also hands the click to Stripe on its own:\n * Payment Links (buy.stripe.com) get `client_reference_id`, and Stripe\n * Pricing Table / Buy Button embeds get `client-reference-id`, including\n * ones added to the page later. A brand whose Stripe account is connected\n * to OpenPartner then needs no code for those checkouts. `stripe: false`\n * turns it off.\n *\n * No bundler? The same SDK ships as a script tag that starts itself\n * (auto.ts), served at /op.js by an OpenPartner portal.\n */\n\nimport { OpenPartnerError, requestJson, type IdentifyResponse } from './shared.js';\n\nexport { OpenPartnerError };\nexport type { IdentifyResponse };\n\nexport interface StripeLinkingConfig {\n /** Extra Payment Link hosts — a custom domain set up for Payment Links\n * in Stripe (e.g. \"pay.yourbrand.com\"). buy.stripe.com is always on. */\n linkHosts?: string[];\n}\n\nexport interface OpenPartnerConfig {\n /** Base URL of your OpenPartner API, e.g. \"https://openpartner.example.com\". */\n apiUrl: string;\n /** First-party cookie name set by the click router. Default `_cref`. */\n cookieName?: string;\n /** Query-string parameter that carries the cref on the landing URL. Default `cref`. */\n queryParam?: string;\n /** localStorage key the captured cref is stashed under. Default `openpartner:cref`. */\n storageKey?: string;\n /**\n * Hand the captured referral to Stripe Payment Links and Pricing Table /\n * Buy Button embeds on the page. Default on; `false` turns it off.\n */\n stripe?: boolean | StripeLinkingConfig;\n /**\n * Also keep the referral in a first-party cookie, so your server can read\n * it straight off the request when it creates a Stripe Checkout Session\n * (`client_reference_id: req.cookies.op_ref`). Off by default; the\n * script-tag build turns it on. `domain` shares it across subdomains\n * (\".yourbrand.com\") when visitors land on one and check out on another.\n */\n referralCookie?: false | { name?: string; domain?: string; days?: number };\n /**\n * Called when identify() fails. Default: log to console. Pass a noop\n * to suppress entirely or hook into your observability stack.\n */\n onError?: (err: unknown) => void;\n}\n\nconst DEFAULTS = {\n cookieName: '_cref',\n queryParam: 'cref',\n storageKey: 'openpartner:cref',\n stripe: true,\n referralCookie: false,\n onError: (err: unknown) => {\n console.warn('[openpartner] identify failed', err);\n },\n};\n\n/** Stripe accepts a client_reference_id of letters, digits, \"-\" and \"_\",\n * up to 200, and silently drops anything else. A click id always fits; a\n * value that doesn't came from somewhere else and isn't sent. */\nconst STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;\nconst STRIPE_EMBED_SELECTOR = 'stripe-pricing-table, stripe-buy-button';\n\nexport class OpenPartner {\n private config: Required<OpenPartnerConfig>;\n private observer: MutationObserver | null = null;\n\n private constructor(config: OpenPartnerConfig) {\n this.config = { ...DEFAULTS, ...config } as Required<OpenPartnerConfig>;\n this.captureCref();\n this.writeReferralCookie();\n if (this.config.stripe !== false) this.watchStripe();\n }\n\n static init(config: OpenPartnerConfig): OpenPartner {\n return new OpenPartner(config);\n }\n\n /** The cref we're holding onto, if any. Useful for debugging. */\n getCref(): string | null {\n if (typeof localStorage === 'undefined') return null;\n return localStorage.getItem(this.config.storageKey);\n }\n\n /**\n * Captured referral token for this visitor. Pass to Stripe Checkout\n * as `client_reference_id` to enable webhook-based attribution\n * without an explicit identify() call:\n *\n * stripe.checkout.sessions.create({\n * client_reference_id: op.getReferral() ?? undefined,\n * // ...\n * });\n *\n * Returns null if the visitor didn't arrive via a partner link.\n */\n getReferral(): string | null {\n return this.getCref();\n }\n\n /** Explicitly discard the captured cref. */\n clear(): void {\n if (typeof localStorage === 'undefined') return;\n localStorage.removeItem(this.config.storageKey);\n }\n\n /**\n * Add the referral to every Stripe Payment Link and Pricing Table / Buy\n * Button embed under `root` that doesn't carry a reference already.\n * Runs on its own at init and for anything added later; call it yourself\n * only if you build Stripe elements inside a shadow root. Returns how\n * many it changed.\n */\n linkStripe(root: ParentNode = document): number {\n const referral = this.getReferral();\n if (!referral || !STRIPE_REFERENCE_RE.test(referral) || typeof document === 'undefined') return 0;\n const hosts = new Set(['buy.stripe.com', ...this.stripeLinkHosts()]);\n let changed = 0;\n\n const anchors: HTMLAnchorElement[] = [];\n if (root instanceof HTMLAnchorElement) anchors.push(root);\n anchors.push(...Array.from(root.querySelectorAll<HTMLAnchorElement>('a[href]')));\n for (const a of anchors) {\n let url: URL;\n try {\n url = new URL(a.href);\n } catch {\n continue;\n }\n // One the brand set itself (or an earlier pass) stays as it is.\n if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has('client_reference_id')) continue;\n url.searchParams.set('client_reference_id', referral);\n a.href = url.toString();\n changed += 1;\n }\n\n const embeds: Element[] = [];\n if (root instanceof Element && root.matches(STRIPE_EMBED_SELECTOR)) embeds.push(root);\n embeds.push(...Array.from(root.querySelectorAll(STRIPE_EMBED_SELECTOR)));\n for (const el of embeds) {\n if (el.hasAttribute('client-reference-id')) continue;\n el.setAttribute('client-reference-id', referral);\n changed += 1;\n }\n return changed;\n }\n\n /** Stop watching the page for new Stripe links and embeds. */\n stopLinkingStripe(): void {\n this.observer?.disconnect();\n this.observer = null;\n }\n\n private stripeLinkHosts(): string[] {\n const cfg = this.config.stripe;\n return typeof cfg === 'object' && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];\n }\n\n /**\n * Link what's on the page now, then anything added or re-pointed later\n * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor\n * who didn't come from a partner link costs nothing.\n *\n * Pricing Table and Buy Button embeds read client-reference-id when\n * Stripe's script renders them, so the script tag belongs in <head>,\n * ahead of the embed: the observer then sets the attribute as the\n * element is parsed, before Stripe upgrades it.\n */\n private watchStripe(): void {\n if (typeof document === 'undefined' || !this.getReferral()) return;\n this.linkStripe(document);\n if (typeof MutationObserver === 'undefined') return;\n this.observer = new MutationObserver((records) => {\n for (const record of records) {\n if (record.type === 'attributes') {\n // An href re-pointed at a Payment Link.\n if (record.target instanceof HTMLAnchorElement) this.linkStripe(record.target);\n continue;\n }\n for (const node of Array.from(record.addedNodes)) {\n if (node instanceof Element) this.linkStripe(node);\n }\n }\n });\n // Our own edits only ever add a missing reference, so the second pass\n // they trigger finds nothing to change.\n this.observer.observe(document.documentElement, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: ['href'],\n });\n }\n\n /**\n * Capture the cref from query string or cookie and stash in\n * localStorage. Runs on init so the value survives SPA navigations,\n * ITP cookie expiry, and subsequent page loads.\n */\n private captureCref(): void {\n if (typeof window === 'undefined' || typeof localStorage === 'undefined') return;\n\n const url = new URL(window.location.href);\n const queryCref = url.searchParams.get(this.config.queryParam);\n\n if (queryCref) {\n localStorage.setItem(this.config.storageKey, queryCref);\n return;\n }\n\n const cookieCref = this.readCookie(this.config.cookieName);\n if (cookieCref && !localStorage.getItem(this.config.storageKey)) {\n localStorage.setItem(this.config.storageKey, cookieCref);\n }\n }\n\n /** Mirror the referral into the brand's own first-party cookie (when\n * configured), refreshed on every page load that has one. */\n private writeReferralCookie(): void {\n const cfg = this.config.referralCookie;\n if (!cfg || typeof document === 'undefined') return;\n const referral = this.getReferral();\n if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;\n const name = cfg.name ?? 'op_ref';\n const maxAge = Math.round((cfg.days ?? 90) * 86400);\n const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';\n const domain = cfg.domain ? `; Domain=${cfg.domain}` : '';\n document.cookie = `${name}=${encodeURIComponent(referral)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${domain}${secure}`;\n }\n\n private readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null;\n const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));\n return match ? decodeURIComponent(match[2]!) : null;\n }\n\n /**\n * Stitch the current captured click to an authenticated user. Call\n * this on login, signup, or anywhere the user becomes known.\n *\n * No-op if no cref was captured. Errors are reported through\n * `config.onError` by default — attribution is best-effort from the\n * browser — but you can await this and try/catch if you want to\n * surface failures explicitly.\n */\n async identify(userId: string): Promise<IdentifyResponse | null> {\n const cref = this.getCref();\n if (!cref) return null;\n\n try {\n return await requestJson<IdentifyResponse>(`${this.config.apiUrl}/attribution/identify`, {\n method: 'POST',\n body: { cref, userId, ts: Date.now() },\n keepalive: true,\n });\n } catch (err) {\n this.config.onError(err);\n return null;\n }\n }\n}\n","/**\n * Shared types + error class for both the browser and server entry\n * points.\n *\n * Keeping these in their own module means browser imports won't drag in\n * `fetch` polyfills or server-only code, and server imports won't touch\n * anything that references `window` or `document`.\n */\n\nexport class OpenPartnerError extends Error {\n constructor(\n message: string,\n public readonly status: number,\n public readonly code?: string,\n public readonly detail?: unknown,\n ) {\n super(message);\n this.name = 'OpenPartnerError';\n }\n}\n\nexport interface IdentifyResponse {\n ok: boolean;\n identityId: string | null;\n firstStitch: boolean;\n backfilledAttributions?: number;\n}\n\nexport interface EventAttribution {\n /**\n * 'not_attributed' only appears on a replay (`replayed: true`) of an event\n * that didn't attribute the first time; which miss it was isn't stored.\n * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —\n * recorded, never attributed.\n */\n status:\n | 'attributed'\n | 'no_identity'\n | 'no_click'\n | 'outside_window'\n | 'already_attributed'\n | 'not_attributed'\n | 'corrective';\n model?: 'last_click' | 'first_click' | 'linear' | 'position';\n touches?: Array<{\n clickId: string;\n partnerId: string;\n weight: number;\n attributionId: string;\n commissionId?: string;\n }>;\n}\n\nexport interface EventResponse {\n ok: boolean;\n eventId: string;\n attribution: EventAttribution;\n /** True when an earlier call with the same externalEventId already\n * recorded this event; eventId and attribution are that call's. */\n replayed?: boolean;\n}\n\nexport interface ConversionResponse extends EventResponse {\n /** The user the conversion was recorded under: the userId you passed, or\n * the click's own `click:<clickId>` user. */\n userId: string;\n}\n\n/**\n * Small fetch helper used by both entries. Normalizes non-2xx responses\n * into OpenPartnerError so callers can try/catch with a stable shape\n * instead of poking at Response objects themselves.\n */\nexport type JsonRequestInit = Omit<RequestInit, 'body'> & { body?: unknown };\n\nexport async function requestJson<T>(\n input: string,\n init: JsonRequestInit = {},\n fetchImpl: typeof fetch = globalThis.fetch,\n): Promise<T> {\n const headers = new Headers(init.headers);\n if (init.body !== undefined && !headers.has('content-type')) {\n headers.set('content-type', 'application/json');\n }\n const res = await fetchImpl(input, {\n ...init,\n headers,\n body:\n init.body === undefined\n ? undefined\n : typeof init.body === 'string'\n ? init.body\n : JSON.stringify(init.body),\n });\n\n const text = await res.text();\n let body: unknown = undefined;\n if (text) {\n try {\n body = JSON.parse(text);\n } catch {\n body = text;\n }\n }\n\n if (!res.ok) {\n const errBody = (body ?? {}) as { error?: string; detail?: unknown };\n throw new OpenPartnerError(\n errBody.error ?? `${res.status} ${res.statusText}`,\n res.status,\n errBody.error,\n errBody.detail ?? body,\n );\n }\n\n return body as T;\n}\n"],"mappings":"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","names":[]}
@@ -1,5 +1,5 @@
1
- import { I as IdentifyResponse } from './shared-CLfBjPeb.cjs';
2
- export { O as OpenPartnerError } from './shared-CLfBjPeb.cjs';
1
+ import { I as IdentifyResponse } from './shared-dh1vlldf.cjs';
2
+ export { O as OpenPartnerError } from './shared-dh1vlldf.cjs';
3
3
 
4
4
  /**
5
5
  * Browser SDK — runs inside a merchant's web app. Captures the `cref`
@@ -16,8 +16,23 @@ export { O as OpenPartnerError } from './shared-CLfBjPeb.cjs';
16
16
  *
17
17
  * // After login/signup, once you know who the user is:
18
18
  * await op.identify(user.id);
19
+ *
20
+ * With a captured referral it also hands the click to Stripe on its own:
21
+ * Payment Links (buy.stripe.com) get `client_reference_id`, and Stripe
22
+ * Pricing Table / Buy Button embeds get `client-reference-id`, including
23
+ * ones added to the page later. A brand whose Stripe account is connected
24
+ * to OpenPartner then needs no code for those checkouts. `stripe: false`
25
+ * turns it off.
26
+ *
27
+ * No bundler? The same SDK ships as a script tag that starts itself
28
+ * (auto.ts), served at /op.js by an OpenPartner portal.
19
29
  */
20
30
 
31
+ interface StripeLinkingConfig {
32
+ /** Extra Payment Link hosts — a custom domain set up for Payment Links
33
+ * in Stripe (e.g. "pay.yourbrand.com"). buy.stripe.com is always on. */
34
+ linkHosts?: string[];
35
+ }
21
36
  interface OpenPartnerConfig {
22
37
  /** Base URL of your OpenPartner API, e.g. "https://openpartner.example.com". */
23
38
  apiUrl: string;
@@ -27,6 +42,23 @@ interface OpenPartnerConfig {
27
42
  queryParam?: string;
28
43
  /** localStorage key the captured cref is stashed under. Default `openpartner:cref`. */
29
44
  storageKey?: string;
45
+ /**
46
+ * Hand the captured referral to Stripe Payment Links and Pricing Table /
47
+ * Buy Button embeds on the page. Default on; `false` turns it off.
48
+ */
49
+ stripe?: boolean | StripeLinkingConfig;
50
+ /**
51
+ * Also keep the referral in a first-party cookie, so your server can read
52
+ * it straight off the request when it creates a Stripe Checkout Session
53
+ * (`client_reference_id: req.cookies.op_ref`). Off by default; the
54
+ * script-tag build turns it on. `domain` shares it across subdomains
55
+ * (".yourbrand.com") when visitors land on one and check out on another.
56
+ */
57
+ referralCookie?: false | {
58
+ name?: string;
59
+ domain?: string;
60
+ days?: number;
61
+ };
30
62
  /**
31
63
  * Called when identify() fails. Default: log to console. Pass a noop
32
64
  * to suppress entirely or hook into your observability stack.
@@ -35,6 +67,7 @@ interface OpenPartnerConfig {
35
67
  }
36
68
  declare class OpenPartner {
37
69
  private config;
70
+ private observer;
38
71
  private constructor();
39
72
  static init(config: OpenPartnerConfig): OpenPartner;
40
73
  /** The cref we're holding onto, if any. Useful for debugging. */
@@ -54,12 +87,37 @@ declare class OpenPartner {
54
87
  getReferral(): string | null;
55
88
  /** Explicitly discard the captured cref. */
56
89
  clear(): void;
90
+ /**
91
+ * Add the referral to every Stripe Payment Link and Pricing Table / Buy
92
+ * Button embed under `root` that doesn't carry a reference already.
93
+ * Runs on its own at init and for anything added later; call it yourself
94
+ * only if you build Stripe elements inside a shadow root. Returns how
95
+ * many it changed.
96
+ */
97
+ linkStripe(root?: ParentNode): number;
98
+ /** Stop watching the page for new Stripe links and embeds. */
99
+ stopLinkingStripe(): void;
100
+ private stripeLinkHosts;
101
+ /**
102
+ * Link what's on the page now, then anything added or re-pointed later
103
+ * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor
104
+ * who didn't come from a partner link costs nothing.
105
+ *
106
+ * Pricing Table and Buy Button embeds read client-reference-id when
107
+ * Stripe's script renders them, so the script tag belongs in <head>,
108
+ * ahead of the embed: the observer then sets the attribute as the
109
+ * element is parsed, before Stripe upgrades it.
110
+ */
111
+ private watchStripe;
57
112
  /**
58
113
  * Capture the cref from query string or cookie and stash in
59
114
  * localStorage. Runs on init so the value survives SPA navigations,
60
115
  * ITP cookie expiry, and subsequent page loads.
61
116
  */
62
117
  private captureCref;
118
+ /** Mirror the referral into the brand's own first-party cookie (when
119
+ * configured), refreshed on every page load that has one. */
120
+ private writeReferralCookie;
63
121
  private readCookie;
64
122
  /**
65
123
  * Stitch the current captured click to an authenticated user. Call
@@ -73,4 +131,5 @@ declare class OpenPartner {
73
131
  identify(userId: string): Promise<IdentifyResponse | null>;
74
132
  }
75
133
 
76
- export { IdentifyResponse, OpenPartner, type OpenPartnerConfig };
134
+ export { IdentifyResponse, OpenPartner };
135
+ export type { OpenPartnerConfig, StripeLinkingConfig };
package/dist/browser.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { I as IdentifyResponse } from './shared-CLfBjPeb.js';
2
- export { O as OpenPartnerError } from './shared-CLfBjPeb.js';
1
+ import { I as IdentifyResponse } from './shared-dh1vlldf.js';
2
+ export { O as OpenPartnerError } from './shared-dh1vlldf.js';
3
3
 
4
4
  /**
5
5
  * Browser SDK — runs inside a merchant's web app. Captures the `cref`
@@ -16,8 +16,23 @@ export { O as OpenPartnerError } from './shared-CLfBjPeb.js';
16
16
  *
17
17
  * // After login/signup, once you know who the user is:
18
18
  * await op.identify(user.id);
19
+ *
20
+ * With a captured referral it also hands the click to Stripe on its own:
21
+ * Payment Links (buy.stripe.com) get `client_reference_id`, and Stripe
22
+ * Pricing Table / Buy Button embeds get `client-reference-id`, including
23
+ * ones added to the page later. A brand whose Stripe account is connected
24
+ * to OpenPartner then needs no code for those checkouts. `stripe: false`
25
+ * turns it off.
26
+ *
27
+ * No bundler? The same SDK ships as a script tag that starts itself
28
+ * (auto.ts), served at /op.js by an OpenPartner portal.
19
29
  */
20
30
 
31
+ interface StripeLinkingConfig {
32
+ /** Extra Payment Link hosts — a custom domain set up for Payment Links
33
+ * in Stripe (e.g. "pay.yourbrand.com"). buy.stripe.com is always on. */
34
+ linkHosts?: string[];
35
+ }
21
36
  interface OpenPartnerConfig {
22
37
  /** Base URL of your OpenPartner API, e.g. "https://openpartner.example.com". */
23
38
  apiUrl: string;
@@ -27,6 +42,23 @@ interface OpenPartnerConfig {
27
42
  queryParam?: string;
28
43
  /** localStorage key the captured cref is stashed under. Default `openpartner:cref`. */
29
44
  storageKey?: string;
45
+ /**
46
+ * Hand the captured referral to Stripe Payment Links and Pricing Table /
47
+ * Buy Button embeds on the page. Default on; `false` turns it off.
48
+ */
49
+ stripe?: boolean | StripeLinkingConfig;
50
+ /**
51
+ * Also keep the referral in a first-party cookie, so your server can read
52
+ * it straight off the request when it creates a Stripe Checkout Session
53
+ * (`client_reference_id: req.cookies.op_ref`). Off by default; the
54
+ * script-tag build turns it on. `domain` shares it across subdomains
55
+ * (".yourbrand.com") when visitors land on one and check out on another.
56
+ */
57
+ referralCookie?: false | {
58
+ name?: string;
59
+ domain?: string;
60
+ days?: number;
61
+ };
30
62
  /**
31
63
  * Called when identify() fails. Default: log to console. Pass a noop
32
64
  * to suppress entirely or hook into your observability stack.
@@ -35,6 +67,7 @@ interface OpenPartnerConfig {
35
67
  }
36
68
  declare class OpenPartner {
37
69
  private config;
70
+ private observer;
38
71
  private constructor();
39
72
  static init(config: OpenPartnerConfig): OpenPartner;
40
73
  /** The cref we're holding onto, if any. Useful for debugging. */
@@ -54,12 +87,37 @@ declare class OpenPartner {
54
87
  getReferral(): string | null;
55
88
  /** Explicitly discard the captured cref. */
56
89
  clear(): void;
90
+ /**
91
+ * Add the referral to every Stripe Payment Link and Pricing Table / Buy
92
+ * Button embed under `root` that doesn't carry a reference already.
93
+ * Runs on its own at init and for anything added later; call it yourself
94
+ * only if you build Stripe elements inside a shadow root. Returns how
95
+ * many it changed.
96
+ */
97
+ linkStripe(root?: ParentNode): number;
98
+ /** Stop watching the page for new Stripe links and embeds. */
99
+ stopLinkingStripe(): void;
100
+ private stripeLinkHosts;
101
+ /**
102
+ * Link what's on the page now, then anything added or re-pointed later
103
+ * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor
104
+ * who didn't come from a partner link costs nothing.
105
+ *
106
+ * Pricing Table and Buy Button embeds read client-reference-id when
107
+ * Stripe's script renders them, so the script tag belongs in <head>,
108
+ * ahead of the embed: the observer then sets the attribute as the
109
+ * element is parsed, before Stripe upgrades it.
110
+ */
111
+ private watchStripe;
57
112
  /**
58
113
  * Capture the cref from query string or cookie and stash in
59
114
  * localStorage. Runs on init so the value survives SPA navigations,
60
115
  * ITP cookie expiry, and subsequent page loads.
61
116
  */
62
117
  private captureCref;
118
+ /** Mirror the referral into the brand's own first-party cookie (when
119
+ * configured), refreshed on every page load that has one. */
120
+ private writeReferralCookie;
63
121
  private readCookie;
64
122
  /**
65
123
  * Stitch the current captured click to an authenticated user. Call
@@ -73,4 +131,5 @@ declare class OpenPartner {
73
131
  identify(userId: string): Promise<IdentifyResponse | null>;
74
132
  }
75
133
 
76
- export { IdentifyResponse, OpenPartner, type OpenPartnerConfig };
134
+ export { IdentifyResponse, OpenPartner };
135
+ export type { OpenPartnerConfig, StripeLinkingConfig };