@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/dist/browser.js CHANGED
@@ -48,15 +48,22 @@ var DEFAULTS = {
48
48
  cookieName: "_cref",
49
49
  queryParam: "cref",
50
50
  storageKey: "openpartner:cref",
51
+ stripe: true,
52
+ referralCookie: false,
51
53
  onError: (err) => {
52
54
  console.warn("[openpartner] identify failed", err);
53
55
  }
54
56
  };
57
+ var STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;
58
+ var STRIPE_EMBED_SELECTOR = "stripe-pricing-table, stripe-buy-button";
55
59
  var OpenPartner = class _OpenPartner {
56
60
  constructor(config) {
57
61
  __publicField(this, "config");
62
+ __publicField(this, "observer", null);
58
63
  this.config = { ...DEFAULTS, ...config };
59
64
  this.captureCref();
65
+ this.writeReferralCookie();
66
+ if (this.config.stripe !== false) this.watchStripe();
60
67
  }
61
68
  static init(config) {
62
69
  return new _OpenPartner(config);
@@ -86,6 +93,84 @@ var OpenPartner = class _OpenPartner {
86
93
  if (typeof localStorage === "undefined") return;
87
94
  localStorage.removeItem(this.config.storageKey);
88
95
  }
96
+ /**
97
+ * Add the referral to every Stripe Payment Link and Pricing Table / Buy
98
+ * Button embed under `root` that doesn't carry a reference already.
99
+ * Runs on its own at init and for anything added later; call it yourself
100
+ * only if you build Stripe elements inside a shadow root. Returns how
101
+ * many it changed.
102
+ */
103
+ linkStripe(root = document) {
104
+ const referral = this.getReferral();
105
+ if (!referral || !STRIPE_REFERENCE_RE.test(referral) || typeof document === "undefined") return 0;
106
+ const hosts = /* @__PURE__ */ new Set(["buy.stripe.com", ...this.stripeLinkHosts()]);
107
+ let changed = 0;
108
+ const anchors = [];
109
+ if (root instanceof HTMLAnchorElement) anchors.push(root);
110
+ anchors.push(...Array.from(root.querySelectorAll("a[href]")));
111
+ for (const a of anchors) {
112
+ let url;
113
+ try {
114
+ url = new URL(a.href);
115
+ } catch {
116
+ continue;
117
+ }
118
+ if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has("client_reference_id")) continue;
119
+ url.searchParams.set("client_reference_id", referral);
120
+ a.href = url.toString();
121
+ changed += 1;
122
+ }
123
+ const embeds = [];
124
+ if (root instanceof Element && root.matches(STRIPE_EMBED_SELECTOR)) embeds.push(root);
125
+ embeds.push(...Array.from(root.querySelectorAll(STRIPE_EMBED_SELECTOR)));
126
+ for (const el of embeds) {
127
+ if (el.hasAttribute("client-reference-id")) continue;
128
+ el.setAttribute("client-reference-id", referral);
129
+ changed += 1;
130
+ }
131
+ return changed;
132
+ }
133
+ /** Stop watching the page for new Stripe links and embeds. */
134
+ stopLinkingStripe() {
135
+ this.observer?.disconnect();
136
+ this.observer = null;
137
+ }
138
+ stripeLinkHosts() {
139
+ const cfg = this.config.stripe;
140
+ return typeof cfg === "object" && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];
141
+ }
142
+ /**
143
+ * Link what's on the page now, then anything added or re-pointed later
144
+ * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor
145
+ * who didn't come from a partner link costs nothing.
146
+ *
147
+ * Pricing Table and Buy Button embeds read client-reference-id when
148
+ * Stripe's script renders them, so the script tag belongs in <head>,
149
+ * ahead of the embed: the observer then sets the attribute as the
150
+ * element is parsed, before Stripe upgrades it.
151
+ */
152
+ watchStripe() {
153
+ if (typeof document === "undefined" || !this.getReferral()) return;
154
+ this.linkStripe(document);
155
+ if (typeof MutationObserver === "undefined") return;
156
+ this.observer = new MutationObserver((records) => {
157
+ for (const record of records) {
158
+ if (record.type === "attributes") {
159
+ if (record.target instanceof HTMLAnchorElement) this.linkStripe(record.target);
160
+ continue;
161
+ }
162
+ for (const node of Array.from(record.addedNodes)) {
163
+ if (node instanceof Element) this.linkStripe(node);
164
+ }
165
+ }
166
+ });
167
+ this.observer.observe(document.documentElement, {
168
+ childList: true,
169
+ subtree: true,
170
+ attributes: true,
171
+ attributeFilter: ["href"]
172
+ });
173
+ }
89
174
  /**
90
175
  * Capture the cref from query string or cookie and stash in
91
176
  * localStorage. Runs on init so the value survives SPA navigations,
@@ -104,6 +189,19 @@ var OpenPartner = class _OpenPartner {
104
189
  localStorage.setItem(this.config.storageKey, cookieCref);
105
190
  }
106
191
  }
192
+ /** Mirror the referral into the brand's own first-party cookie (when
193
+ * configured), refreshed on every page load that has one. */
194
+ writeReferralCookie() {
195
+ const cfg = this.config.referralCookie;
196
+ if (!cfg || typeof document === "undefined") return;
197
+ const referral = this.getReferral();
198
+ if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;
199
+ const name = cfg.name ?? "op_ref";
200
+ const maxAge = Math.round((cfg.days ?? 90) * 86400);
201
+ const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
202
+ const domain = cfg.domain ? `; Domain=${cfg.domain}` : "";
203
+ document.cookie = `${name}=${encodeURIComponent(referral)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${domain}${secure}`;
204
+ }
107
205
  readCookie(name) {
108
206
  if (typeof document === "undefined") return null;
109
207
  const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/shared.ts","../src/browser.ts"],"sourcesContent":["/**\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","/**\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"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/shared.ts","../src/browser.ts"],"sourcesContent":["/**\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","/**\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"],"mappings":"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","names":[]}
package/dist/server.cjs CHANGED
@@ -88,6 +88,16 @@ var OpenPartnerServer = class {
88
88
  async trackEvent(params) {
89
89
  return this.request("/attribution/events", { method: "POST", body: params });
90
90
  }
91
+ /**
92
+ * Record a conversion against a click in one call, for when you have the
93
+ * click id but no identify()'d user: a lender reporting a funded loan, a
94
+ * CRM closing a deal. Stitches the click and records the event together.
95
+ * Throws `click_not_found` (404) for a click outside your program and
96
+ * `click_flagged` (422) for one flagged as fraud.
97
+ */
98
+ async trackConversion(params) {
99
+ return this.request("/attribution/conversions", { method: "POST", body: params });
100
+ }
91
101
  /**
92
102
  * Stitch an attribution token (cref) to a known userId. Normally the
93
103
  * browser SDK does this; use this overload if your backend receives
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server.ts","../src/shared.ts"],"sourcesContent":["/**\n * Server SDK — runs inside a merchant's backend. Used for the\n * server-to-server pieces of the attribution pipeline: posting\n * conversion events (signup / invoice_paid / custom), stitching\n * clicks to users from the backend, and reading dashboard snapshots.\n *\n * Typical integration:\n *\n * import { OpenPartnerServer } from '@openpartner/sdk/server';\n *\n * const op = new OpenPartnerServer({\n * apiUrl: process.env.OPENPARTNER_API_URL!,\n * apiKey: process.env.OPENPARTNER_API_KEY!,\n * });\n *\n * // On signup:\n * await op.trackEvent({ userId, type: 'signup' });\n *\n * // On invoice paid:\n * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD' });\n */\n\nimport { OpenPartnerError, requestJson, type EventResponse, type IdentifyResponse, type JsonRequestInit } from './shared.js';\n\nexport { OpenPartnerError };\nexport type { EventResponse, IdentifyResponse };\nexport type { EventAttribution } from './shared.js';\n\nexport interface OpenPartnerServerConfig {\n /** Base URL of the OpenPartner API. */\n apiUrl: string;\n /**\n * Admin or scoped API key. For event ingest you need admin or a key\n * with the right scope (the core API currently uses admin-only for\n * /attribution/events). Never expose this to the browser.\n */\n apiKey: string;\n /** Optional fetch override for tests or custom transports. */\n fetch?: typeof fetch;\n}\n\nexport interface TrackEventParams {\n /** The user the event pertains to. Must match how the browser SDK called identify(). */\n userId: string;\n /** Event type — typically 'signup' | 'trial_started' | 'subscription_created' | 'invoice_paid' | custom. */\n type: string;\n /** Dollar (or currency) value of the event, for percent-commission rules. */\n value?: number;\n /** ISO 4217 currency code. Defaults to USD on the server if omitted. */\n currency?: string;\n /** Arbitrary metadata you want to attach (stripeEventId, invoiceId, etc). */\n metadata?: Record<string, unknown>;\n /** Event time. Defaults to now on the server. Pass ISO 8601 to override. */\n ts?: string;\n}\n\nexport class OpenPartnerServer {\n private readonly apiUrl: string;\n private readonly apiKey: string;\n private readonly fetchImpl: typeof fetch;\n\n constructor(config: OpenPartnerServerConfig) {\n if (!config.apiUrl) throw new OpenPartnerError('apiUrl is required', 400, 'config_missing');\n if (!config.apiKey) throw new OpenPartnerError('apiKey is required', 400, 'config_missing');\n this.apiUrl = config.apiUrl.replace(/\\/$/, '');\n this.apiKey = config.apiKey;\n this.fetchImpl = config.fetch ?? globalThis.fetch;\n }\n\n /**\n * Record a conversion event. The server walks the userId back to a\n * click, applies the campaign's attribution model, and writes the\n * resulting attribution + commission rows.\n */\n async trackEvent(params: TrackEventParams): Promise<EventResponse> {\n return this.request<EventResponse>('/attribution/events', { method: 'POST', body: params });\n }\n\n /**\n * Stitch an attribution token (cref) to a known userId. Normally the\n * browser SDK does this; use this overload if your backend receives\n * the cref from the browser (e.g. as part of a signup payload) and\n * wants to own the stitch call.\n */\n async identify(params: { cref: string; userId: string; ts?: number }): Promise<IdentifyResponse> {\n return this.request<IdentifyResponse>('/attribution/identify', {\n method: 'POST',\n body: params,\n });\n }\n\n private async request<T>(path: string, init: JsonRequestInit): Promise<T> {\n const headers = new Headers(init.headers);\n headers.set('authorization', `Bearer ${this.apiKey}`);\n return requestJson<T>(\n `${this.apiUrl}${path}`,\n { ...init, headers },\n // Inject the configured fetch so tests (or alternate transports)\n // can intercept.\n this.fetchImpl,\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":";;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACSO,IAAM,mBAAN,cAA+B,MAAM;AAAA,EAC1C,YACE,SACgB,QACA,MACA,QAChB;AACA,UAAM,OAAO;AAJG;AACA;AACA;AAGhB,SAAK,OAAO;AAAA,EACd;AACF;AAkCA,eAAsB,YACpB,OACA,OAAwB,CAAC,GACzB,YAA0B,WAAW,OACzB;AACZ,QAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,MAAI,KAAK,SAAS,UAAa,CAAC,QAAQ,IAAI,cAAc,GAAG;AAC3D,YAAQ,IAAI,gBAAgB,kBAAkB;AAAA,EAChD;AACA,QAAM,MAAM,MAAM,UAAU,OAAO;AAAA,IACjC,GAAG;AAAA,IACH;AAAA,IACA,MACE,KAAK,SAAS,SACV,SACA,OAAO,KAAK,SAAS,WACnB,KAAK,OACL,KAAK,UAAU,KAAK,IAAI;AAAA,EAClC,CAAC;AAED,QAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,MAAI,OAAgB;AACpB,MAAI,MAAM;AACR,QAAI;AACF,aAAO,KAAK,MAAM,IAAI;AAAA,IACxB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,UAAW,QAAQ,CAAC;AAC1B,UAAM,IAAI;AAAA,MACR,QAAQ,SAAS,GAAG,IAAI,MAAM,IAAI,IAAI,UAAU;AAAA,MAChD,IAAI;AAAA,MACJ,QAAQ;AAAA,MACR,QAAQ,UAAU;AAAA,IACpB;AAAA,EACF;AAEA,SAAO;AACT;;;ADtCO,IAAM,oBAAN,MAAwB;AAAA,EAK7B,YAAY,QAAiC;AAJ7C,wBAAiB;AACjB,wBAAiB;AACjB,wBAAiB;AAGf,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,iBAAiB,sBAAsB,KAAK,gBAAgB;AAC1F,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,iBAAiB,sBAAsB,KAAK,gBAAgB;AAC1F,SAAK,SAAS,OAAO,OAAO,QAAQ,OAAO,EAAE;AAC7C,SAAK,SAAS,OAAO;AACrB,SAAK,YAAY,OAAO,SAAS,WAAW;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,WAAW,QAAkD;AACjE,WAAO,KAAK,QAAuB,uBAAuB,EAAE,QAAQ,QAAQ,MAAM,OAAO,CAAC;AAAA,EAC5F;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,SAAS,QAAkF;AAC/F,WAAO,KAAK,QAA0B,yBAAyB;AAAA,MAC7D,QAAQ;AAAA,MACR,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA,EAEA,MAAc,QAAW,MAAc,MAAmC;AACxE,UAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,YAAQ,IAAI,iBAAiB,UAAU,KAAK,MAAM,EAAE;AACpD,WAAO;AAAA,MACL,GAAG,KAAK,MAAM,GAAG,IAAI;AAAA,MACrB,EAAE,GAAG,MAAM,QAAQ;AAAA;AAAA;AAAA,MAGnB,KAAK;AAAA,IACP;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/server.ts","../src/shared.ts"],"sourcesContent":["/**\n * Server SDK — runs inside a merchant's backend. Used for the\n * server-to-server pieces of the attribution pipeline: posting\n * conversion events (signup / invoice_paid / custom), stitching\n * clicks to users from the backend, and reading dashboard snapshots.\n *\n * Typical integration:\n *\n * import { OpenPartnerServer } from '@openpartner/sdk/server';\n *\n * const op = new OpenPartnerServer({\n * apiUrl: process.env.OPENPARTNER_API_URL!,\n * apiKey: process.env.OPENPARTNER_API_KEY!,\n * });\n *\n * // On signup:\n * await op.trackEvent({ userId, type: 'signup' });\n *\n * // On invoice paid — externalEventId makes retries safe:\n * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD', externalEventId: invoice.id });\n *\n * // Holding only the click id (from the link's {click_id} macro)? One call:\n * await op.trackConversion({ clickId, type: 'loan_funded', value: 5000, externalEventId: `${loan.id}:funded` });\n */\n\nimport {\n OpenPartnerError,\n requestJson,\n type ConversionResponse,\n type EventResponse,\n type IdentifyResponse,\n type JsonRequestInit,\n} from './shared.js';\n\nexport { OpenPartnerError };\nexport type { ConversionResponse, EventResponse, IdentifyResponse };\nexport type { EventAttribution } from './shared.js';\n\nexport interface OpenPartnerServerConfig {\n /** Base URL of the OpenPartner API. */\n apiUrl: string;\n /**\n * Admin or scoped API key. Event ingest (/attribution/events) accepts\n * an admin key or a scoped key with `events:write`. Never expose this\n * to the browser.\n */\n apiKey: string;\n /** Optional fetch override for tests or custom transports. */\n fetch?: typeof fetch;\n}\n\nexport interface TrackEventParams {\n /** The user the event pertains to. Must match how the browser SDK called identify(). */\n userId: string;\n /** Event type — typically 'signup' | 'trial_started' | 'subscription_created' | 'invoice_paid' | custom. */\n type: string;\n /** Dollar (or currency) value of the event, for percent-commission rules. */\n value?: number;\n /** ISO 4217 currency code. Defaults to USD on the server if omitted. */\n currency?: string;\n /** Arbitrary metadata you want to attach (stripeEventId, invoiceId, etc). */\n metadata?: Record<string, unknown>;\n /** Event time. Defaults to now on the server. Pass ISO 8601 to override. */\n ts?: string;\n /**\n * Idempotency key: your system's stable id for this one event (order id,\n * invoice id, CRM deal-stage change id), max 120 chars. Strongly\n * recommended. A retried call with the same key returns the original\n * event and attribution (`replayed: true`) instead of recording and\n * commissioning it twice. Unique per event, not per order: an order's\n * `subscription_created` and its `invoice_paid` need different keys.\n * Reusing a key for a different userId / type / value / currency throws\n * an `OpenPartnerError` with code `external_event_id_conflict` (409).\n */\n externalEventId?: string;\n}\n\nexport interface TrackConversionParams {\n /** The click id the partner link handed you: the `{click_id}` macro in the\n * destination URL, or the `cref` query parameter. */\n clickId: string;\n /** Event type, e.g. 'loan_funded', 'opportunity_won', 'invoice_paid'. */\n type: string;\n /** Value of the conversion, for percent-commission rules. */\n value?: number;\n /** ISO 4217 currency code. Defaults to USD on the server if omitted. */\n currency?: string;\n /**\n * Required idempotency key: your stable id for this one conversion (loan\n * id plus stage, order id), max 120 chars. A retry returns the original\n * result (`replayed: true`); reusing it for a different click or\n * conversion throws `external_event_id_conflict` (409).\n */\n externalEventId: string;\n /**\n * Your own id for the user, if you have one. Omit it and the conversion is\n * recorded under the click's own user, credited to that click alone.\n */\n userId?: string;\n metadata?: Record<string, unknown>;\n /** Event time. Defaults to now on the server. ISO 8601. */\n ts?: string;\n}\n\nexport class OpenPartnerServer {\n private readonly apiUrl: string;\n private readonly apiKey: string;\n private readonly fetchImpl: typeof fetch;\n\n constructor(config: OpenPartnerServerConfig) {\n if (!config.apiUrl) throw new OpenPartnerError('apiUrl is required', 400, 'config_missing');\n if (!config.apiKey) throw new OpenPartnerError('apiKey is required', 400, 'config_missing');\n this.apiUrl = config.apiUrl.replace(/\\/$/, '');\n this.apiKey = config.apiKey;\n this.fetchImpl = config.fetch ?? globalThis.fetch;\n }\n\n /**\n * Record a conversion event. The server walks the userId back to a\n * click, applies the campaign's attribution model, and writes the\n * resulting attribution + commission rows.\n */\n async trackEvent(params: TrackEventParams): Promise<EventResponse> {\n return this.request<EventResponse>('/attribution/events', { method: 'POST', body: params });\n }\n\n /**\n * Record a conversion against a click in one call, for when you have the\n * click id but no identify()'d user: a lender reporting a funded loan, a\n * CRM closing a deal. Stitches the click and records the event together.\n * Throws `click_not_found` (404) for a click outside your program and\n * `click_flagged` (422) for one flagged as fraud.\n */\n async trackConversion(params: TrackConversionParams): Promise<ConversionResponse> {\n return this.request<ConversionResponse>('/attribution/conversions', { method: 'POST', body: params });\n }\n\n /**\n * Stitch an attribution token (cref) to a known userId. Normally the\n * browser SDK does this; use this overload if your backend receives\n * the cref from the browser (e.g. as part of a signup payload) and\n * wants to own the stitch call.\n */\n async identify(params: { cref: string; userId: string; ts?: number }): Promise<IdentifyResponse> {\n return this.request<IdentifyResponse>('/attribution/identify', {\n method: 'POST',\n body: params,\n });\n }\n\n private async request<T>(path: string, init: JsonRequestInit): Promise<T> {\n const headers = new Headers(init.headers);\n headers.set('authorization', `Bearer ${this.apiKey}`);\n return requestJson<T>(\n `${this.apiUrl}${path}`,\n { ...init, headers },\n // Inject the configured fetch so tests (or alternate transports)\n // can intercept.\n this.fetchImpl,\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":";;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACSO,IAAM,mBAAN,cAA+B,MAAM;AAAA,EAC1C,YACE,SACgB,QACA,MACA,QAChB;AACA,UAAM,OAAO;AAJG;AACA;AACA;AAGhB,SAAK,OAAO;AAAA,EACd;AACF;AAwDA,eAAsB,YACpB,OACA,OAAwB,CAAC,GACzB,YAA0B,WAAW,OACzB;AACZ,QAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,MAAI,KAAK,SAAS,UAAa,CAAC,QAAQ,IAAI,cAAc,GAAG;AAC3D,YAAQ,IAAI,gBAAgB,kBAAkB;AAAA,EAChD;AACA,QAAM,MAAM,MAAM,UAAU,OAAO;AAAA,IACjC,GAAG;AAAA,IACH;AAAA,IACA,MACE,KAAK,SAAS,SACV,SACA,OAAO,KAAK,SAAS,WACnB,KAAK,OACL,KAAK,UAAU,KAAK,IAAI;AAAA,EAClC,CAAC;AAED,QAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,MAAI,OAAgB;AACpB,MAAI,MAAM;AACR,QAAI;AACF,aAAO,KAAK,MAAM,IAAI;AAAA,IACxB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,UAAW,QAAQ,CAAC;AAC1B,UAAM,IAAI;AAAA,MACR,QAAQ,SAAS,GAAG,IAAI,MAAM,IAAI,IAAI,UAAU;AAAA,MAChD,IAAI;AAAA,MACJ,QAAQ;AAAA,MACR,QAAQ,UAAU;AAAA,IACpB;AAAA,EACF;AAEA,SAAO;AACT;;;ADZO,IAAM,oBAAN,MAAwB;AAAA,EAK7B,YAAY,QAAiC;AAJ7C,wBAAiB;AACjB,wBAAiB;AACjB,wBAAiB;AAGf,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,iBAAiB,sBAAsB,KAAK,gBAAgB;AAC1F,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,iBAAiB,sBAAsB,KAAK,gBAAgB;AAC1F,SAAK,SAAS,OAAO,OAAO,QAAQ,OAAO,EAAE;AAC7C,SAAK,SAAS,OAAO;AACrB,SAAK,YAAY,OAAO,SAAS,WAAW;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,WAAW,QAAkD;AACjE,WAAO,KAAK,QAAuB,uBAAuB,EAAE,QAAQ,QAAQ,MAAM,OAAO,CAAC;AAAA,EAC5F;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,gBAAgB,QAA4D;AAChF,WAAO,KAAK,QAA4B,4BAA4B,EAAE,QAAQ,QAAQ,MAAM,OAAO,CAAC;AAAA,EACtG;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,SAAS,QAAkF;AAC/F,WAAO,KAAK,QAA0B,yBAAyB;AAAA,MAC7D,QAAQ;AAAA,MACR,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA,EAEA,MAAc,QAAW,MAAc,MAAmC;AACxE,UAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,YAAQ,IAAI,iBAAiB,UAAU,KAAK,MAAM,EAAE;AACpD,WAAO;AAAA,MACL,GAAG,KAAK,MAAM,GAAG,IAAI;AAAA,MACrB,EAAE,GAAG,MAAM,QAAQ;AAAA;AAAA;AAAA,MAGnB,KAAK;AAAA,IACP;AAAA,EACF;AACF;","names":[]}
package/dist/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { E as EventResponse, I as IdentifyResponse } from './shared-CLfBjPeb.cjs';
2
- export { a as EventAttribution, O as OpenPartnerError } from './shared-CLfBjPeb.cjs';
1
+ import { E as EventResponse, C as ConversionResponse, I as IdentifyResponse } from './shared-dh1vlldf.cjs';
2
+ export { a as EventAttribution, O as OpenPartnerError } from './shared-dh1vlldf.cjs';
3
3
 
4
4
  /**
5
5
  * Server SDK — runs inside a merchant's backend. Used for the
@@ -19,17 +19,20 @@ export { a as EventAttribution, O as OpenPartnerError } from './shared-CLfBjPeb.
19
19
  * // On signup:
20
20
  * await op.trackEvent({ userId, type: 'signup' });
21
21
  *
22
- * // On invoice paid:
23
- * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD' });
22
+ * // On invoice paid — externalEventId makes retries safe:
23
+ * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD', externalEventId: invoice.id });
24
+ *
25
+ * // Holding only the click id (from the link's {click_id} macro)? One call:
26
+ * await op.trackConversion({ clickId, type: 'loan_funded', value: 5000, externalEventId: `${loan.id}:funded` });
24
27
  */
25
28
 
26
29
  interface OpenPartnerServerConfig {
27
30
  /** Base URL of the OpenPartner API. */
28
31
  apiUrl: string;
29
32
  /**
30
- * Admin or scoped API key. For event ingest you need admin or a key
31
- * with the right scope (the core API currently uses admin-only for
32
- * /attribution/events). Never expose this to the browser.
33
+ * Admin or scoped API key. Event ingest (/attribution/events) accepts
34
+ * an admin key or a scoped key with `events:write`. Never expose this
35
+ * to the browser.
33
36
  */
34
37
  apiKey: string;
35
38
  /** Optional fetch override for tests or custom transports. */
@@ -48,6 +51,43 @@ interface TrackEventParams {
48
51
  metadata?: Record<string, unknown>;
49
52
  /** Event time. Defaults to now on the server. Pass ISO 8601 to override. */
50
53
  ts?: string;
54
+ /**
55
+ * Idempotency key: your system's stable id for this one event (order id,
56
+ * invoice id, CRM deal-stage change id), max 120 chars. Strongly
57
+ * recommended. A retried call with the same key returns the original
58
+ * event and attribution (`replayed: true`) instead of recording and
59
+ * commissioning it twice. Unique per event, not per order: an order's
60
+ * `subscription_created` and its `invoice_paid` need different keys.
61
+ * Reusing a key for a different userId / type / value / currency throws
62
+ * an `OpenPartnerError` with code `external_event_id_conflict` (409).
63
+ */
64
+ externalEventId?: string;
65
+ }
66
+ interface TrackConversionParams {
67
+ /** The click id the partner link handed you: the `{click_id}` macro in the
68
+ * destination URL, or the `cref` query parameter. */
69
+ clickId: string;
70
+ /** Event type, e.g. 'loan_funded', 'opportunity_won', 'invoice_paid'. */
71
+ type: string;
72
+ /** Value of the conversion, for percent-commission rules. */
73
+ value?: number;
74
+ /** ISO 4217 currency code. Defaults to USD on the server if omitted. */
75
+ currency?: string;
76
+ /**
77
+ * Required idempotency key: your stable id for this one conversion (loan
78
+ * id plus stage, order id), max 120 chars. A retry returns the original
79
+ * result (`replayed: true`); reusing it for a different click or
80
+ * conversion throws `external_event_id_conflict` (409).
81
+ */
82
+ externalEventId: string;
83
+ /**
84
+ * Your own id for the user, if you have one. Omit it and the conversion is
85
+ * recorded under the click's own user, credited to that click alone.
86
+ */
87
+ userId?: string;
88
+ metadata?: Record<string, unknown>;
89
+ /** Event time. Defaults to now on the server. ISO 8601. */
90
+ ts?: string;
51
91
  }
52
92
  declare class OpenPartnerServer {
53
93
  private readonly apiUrl;
@@ -60,6 +100,14 @@ declare class OpenPartnerServer {
60
100
  * resulting attribution + commission rows.
61
101
  */
62
102
  trackEvent(params: TrackEventParams): Promise<EventResponse>;
103
+ /**
104
+ * Record a conversion against a click in one call, for when you have the
105
+ * click id but no identify()'d user: a lender reporting a funded loan, a
106
+ * CRM closing a deal. Stitches the click and records the event together.
107
+ * Throws `click_not_found` (404) for a click outside your program and
108
+ * `click_flagged` (422) for one flagged as fraud.
109
+ */
110
+ trackConversion(params: TrackConversionParams): Promise<ConversionResponse>;
63
111
  /**
64
112
  * Stitch an attribution token (cref) to a known userId. Normally the
65
113
  * browser SDK does this; use this overload if your backend receives
@@ -74,4 +122,5 @@ declare class OpenPartnerServer {
74
122
  private request;
75
123
  }
76
124
 
77
- export { EventResponse, IdentifyResponse, OpenPartnerServer, type OpenPartnerServerConfig, type TrackEventParams };
125
+ export { ConversionResponse, EventResponse, IdentifyResponse, OpenPartnerServer };
126
+ export type { OpenPartnerServerConfig, TrackConversionParams, TrackEventParams };
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { E as EventResponse, I as IdentifyResponse } from './shared-CLfBjPeb.js';
2
- export { a as EventAttribution, O as OpenPartnerError } from './shared-CLfBjPeb.js';
1
+ import { E as EventResponse, C as ConversionResponse, I as IdentifyResponse } from './shared-dh1vlldf.js';
2
+ export { a as EventAttribution, O as OpenPartnerError } from './shared-dh1vlldf.js';
3
3
 
4
4
  /**
5
5
  * Server SDK — runs inside a merchant's backend. Used for the
@@ -19,17 +19,20 @@ export { a as EventAttribution, O as OpenPartnerError } from './shared-CLfBjPeb.
19
19
  * // On signup:
20
20
  * await op.trackEvent({ userId, type: 'signup' });
21
21
  *
22
- * // On invoice paid:
23
- * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD' });
22
+ * // On invoice paid — externalEventId makes retries safe:
23
+ * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD', externalEventId: invoice.id });
24
+ *
25
+ * // Holding only the click id (from the link's {click_id} macro)? One call:
26
+ * await op.trackConversion({ clickId, type: 'loan_funded', value: 5000, externalEventId: `${loan.id}:funded` });
24
27
  */
25
28
 
26
29
  interface OpenPartnerServerConfig {
27
30
  /** Base URL of the OpenPartner API. */
28
31
  apiUrl: string;
29
32
  /**
30
- * Admin or scoped API key. For event ingest you need admin or a key
31
- * with the right scope (the core API currently uses admin-only for
32
- * /attribution/events). Never expose this to the browser.
33
+ * Admin or scoped API key. Event ingest (/attribution/events) accepts
34
+ * an admin key or a scoped key with `events:write`. Never expose this
35
+ * to the browser.
33
36
  */
34
37
  apiKey: string;
35
38
  /** Optional fetch override for tests or custom transports. */
@@ -48,6 +51,43 @@ interface TrackEventParams {
48
51
  metadata?: Record<string, unknown>;
49
52
  /** Event time. Defaults to now on the server. Pass ISO 8601 to override. */
50
53
  ts?: string;
54
+ /**
55
+ * Idempotency key: your system's stable id for this one event (order id,
56
+ * invoice id, CRM deal-stage change id), max 120 chars. Strongly
57
+ * recommended. A retried call with the same key returns the original
58
+ * event and attribution (`replayed: true`) instead of recording and
59
+ * commissioning it twice. Unique per event, not per order: an order's
60
+ * `subscription_created` and its `invoice_paid` need different keys.
61
+ * Reusing a key for a different userId / type / value / currency throws
62
+ * an `OpenPartnerError` with code `external_event_id_conflict` (409).
63
+ */
64
+ externalEventId?: string;
65
+ }
66
+ interface TrackConversionParams {
67
+ /** The click id the partner link handed you: the `{click_id}` macro in the
68
+ * destination URL, or the `cref` query parameter. */
69
+ clickId: string;
70
+ /** Event type, e.g. 'loan_funded', 'opportunity_won', 'invoice_paid'. */
71
+ type: string;
72
+ /** Value of the conversion, for percent-commission rules. */
73
+ value?: number;
74
+ /** ISO 4217 currency code. Defaults to USD on the server if omitted. */
75
+ currency?: string;
76
+ /**
77
+ * Required idempotency key: your stable id for this one conversion (loan
78
+ * id plus stage, order id), max 120 chars. A retry returns the original
79
+ * result (`replayed: true`); reusing it for a different click or
80
+ * conversion throws `external_event_id_conflict` (409).
81
+ */
82
+ externalEventId: string;
83
+ /**
84
+ * Your own id for the user, if you have one. Omit it and the conversion is
85
+ * recorded under the click's own user, credited to that click alone.
86
+ */
87
+ userId?: string;
88
+ metadata?: Record<string, unknown>;
89
+ /** Event time. Defaults to now on the server. ISO 8601. */
90
+ ts?: string;
51
91
  }
52
92
  declare class OpenPartnerServer {
53
93
  private readonly apiUrl;
@@ -60,6 +100,14 @@ declare class OpenPartnerServer {
60
100
  * resulting attribution + commission rows.
61
101
  */
62
102
  trackEvent(params: TrackEventParams): Promise<EventResponse>;
103
+ /**
104
+ * Record a conversion against a click in one call, for when you have the
105
+ * click id but no identify()'d user: a lender reporting a funded loan, a
106
+ * CRM closing a deal. Stitches the click and records the event together.
107
+ * Throws `click_not_found` (404) for a click outside your program and
108
+ * `click_flagged` (422) for one flagged as fraud.
109
+ */
110
+ trackConversion(params: TrackConversionParams): Promise<ConversionResponse>;
63
111
  /**
64
112
  * Stitch an attribution token (cref) to a known userId. Normally the
65
113
  * browser SDK does this; use this overload if your backend receives
@@ -74,4 +122,5 @@ declare class OpenPartnerServer {
74
122
  private request;
75
123
  }
76
124
 
77
- export { EventResponse, IdentifyResponse, OpenPartnerServer, type OpenPartnerServerConfig, type TrackEventParams };
125
+ export { ConversionResponse, EventResponse, IdentifyResponse, OpenPartnerServer };
126
+ export type { OpenPartnerServerConfig, TrackConversionParams, TrackEventParams };
package/dist/server.js CHANGED
@@ -63,6 +63,16 @@ var OpenPartnerServer = class {
63
63
  async trackEvent(params) {
64
64
  return this.request("/attribution/events", { method: "POST", body: params });
65
65
  }
66
+ /**
67
+ * Record a conversion against a click in one call, for when you have the
68
+ * click id but no identify()'d user: a lender reporting a funded loan, a
69
+ * CRM closing a deal. Stitches the click and records the event together.
70
+ * Throws `click_not_found` (404) for a click outside your program and
71
+ * `click_flagged` (422) for one flagged as fraud.
72
+ */
73
+ async trackConversion(params) {
74
+ return this.request("/attribution/conversions", { method: "POST", body: params });
75
+ }
66
76
  /**
67
77
  * Stitch an attribution token (cref) to a known userId. Normally the
68
78
  * browser SDK does this; use this overload if your backend receives
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/shared.ts","../src/server.ts"],"sourcesContent":["/**\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","/**\n * Server SDK — runs inside a merchant's backend. Used for the\n * server-to-server pieces of the attribution pipeline: posting\n * conversion events (signup / invoice_paid / custom), stitching\n * clicks to users from the backend, and reading dashboard snapshots.\n *\n * Typical integration:\n *\n * import { OpenPartnerServer } from '@openpartner/sdk/server';\n *\n * const op = new OpenPartnerServer({\n * apiUrl: process.env.OPENPARTNER_API_URL!,\n * apiKey: process.env.OPENPARTNER_API_KEY!,\n * });\n *\n * // On signup:\n * await op.trackEvent({ userId, type: 'signup' });\n *\n * // On invoice paid:\n * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD' });\n */\n\nimport { OpenPartnerError, requestJson, type EventResponse, type IdentifyResponse, type JsonRequestInit } from './shared.js';\n\nexport { OpenPartnerError };\nexport type { EventResponse, IdentifyResponse };\nexport type { EventAttribution } from './shared.js';\n\nexport interface OpenPartnerServerConfig {\n /** Base URL of the OpenPartner API. */\n apiUrl: string;\n /**\n * Admin or scoped API key. For event ingest you need admin or a key\n * with the right scope (the core API currently uses admin-only for\n * /attribution/events). Never expose this to the browser.\n */\n apiKey: string;\n /** Optional fetch override for tests or custom transports. */\n fetch?: typeof fetch;\n}\n\nexport interface TrackEventParams {\n /** The user the event pertains to. Must match how the browser SDK called identify(). */\n userId: string;\n /** Event type — typically 'signup' | 'trial_started' | 'subscription_created' | 'invoice_paid' | custom. */\n type: string;\n /** Dollar (or currency) value of the event, for percent-commission rules. */\n value?: number;\n /** ISO 4217 currency code. Defaults to USD on the server if omitted. */\n currency?: string;\n /** Arbitrary metadata you want to attach (stripeEventId, invoiceId, etc). */\n metadata?: Record<string, unknown>;\n /** Event time. Defaults to now on the server. Pass ISO 8601 to override. */\n ts?: string;\n}\n\nexport class OpenPartnerServer {\n private readonly apiUrl: string;\n private readonly apiKey: string;\n private readonly fetchImpl: typeof fetch;\n\n constructor(config: OpenPartnerServerConfig) {\n if (!config.apiUrl) throw new OpenPartnerError('apiUrl is required', 400, 'config_missing');\n if (!config.apiKey) throw new OpenPartnerError('apiKey is required', 400, 'config_missing');\n this.apiUrl = config.apiUrl.replace(/\\/$/, '');\n this.apiKey = config.apiKey;\n this.fetchImpl = config.fetch ?? globalThis.fetch;\n }\n\n /**\n * Record a conversion event. The server walks the userId back to a\n * click, applies the campaign's attribution model, and writes the\n * resulting attribution + commission rows.\n */\n async trackEvent(params: TrackEventParams): Promise<EventResponse> {\n return this.request<EventResponse>('/attribution/events', { method: 'POST', body: params });\n }\n\n /**\n * Stitch an attribution token (cref) to a known userId. Normally the\n * browser SDK does this; use this overload if your backend receives\n * the cref from the browser (e.g. as part of a signup payload) and\n * wants to own the stitch call.\n */\n async identify(params: { cref: string; userId: string; ts?: number }): Promise<IdentifyResponse> {\n return this.request<IdentifyResponse>('/attribution/identify', {\n method: 'POST',\n body: params,\n });\n }\n\n private async request<T>(path: string, init: JsonRequestInit): Promise<T> {\n const headers = new Headers(init.headers);\n headers.set('authorization', `Bearer ${this.apiKey}`);\n return requestJson<T>(\n `${this.apiUrl}${path}`,\n { ...init, headers },\n // Inject the configured fetch so tests (or alternate transports)\n // can intercept.\n this.fetchImpl,\n );\n }\n}\n"],"mappings":";;;;;AASO,IAAM,mBAAN,cAA+B,MAAM;AAAA,EAC1C,YACE,SACgB,QACA,MACA,QAChB;AACA,UAAM,OAAO;AAJG;AACA;AACA;AAGhB,SAAK,OAAO;AAAA,EACd;AACF;AAkCA,eAAsB,YACpB,OACA,OAAwB,CAAC,GACzB,YAA0B,WAAW,OACzB;AACZ,QAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,MAAI,KAAK,SAAS,UAAa,CAAC,QAAQ,IAAI,cAAc,GAAG;AAC3D,YAAQ,IAAI,gBAAgB,kBAAkB;AAAA,EAChD;AACA,QAAM,MAAM,MAAM,UAAU,OAAO;AAAA,IACjC,GAAG;AAAA,IACH;AAAA,IACA,MACE,KAAK,SAAS,SACV,SACA,OAAO,KAAK,SAAS,WACnB,KAAK,OACL,KAAK,UAAU,KAAK,IAAI;AAAA,EAClC,CAAC;AAED,QAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,MAAI,OAAgB;AACpB,MAAI,MAAM;AACR,QAAI;AACF,aAAO,KAAK,MAAM,IAAI;AAAA,IACxB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,UAAW,QAAQ,CAAC;AAC1B,UAAM,IAAI;AAAA,MACR,QAAQ,SAAS,GAAG,IAAI,MAAM,IAAI,IAAI,UAAU;AAAA,MAChD,IAAI;AAAA,MACJ,QAAQ;AAAA,MACR,QAAQ,UAAU;AAAA,IACpB;AAAA,EACF;AAEA,SAAO;AACT;;;ACtCO,IAAM,oBAAN,MAAwB;AAAA,EAK7B,YAAY,QAAiC;AAJ7C,wBAAiB;AACjB,wBAAiB;AACjB,wBAAiB;AAGf,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,iBAAiB,sBAAsB,KAAK,gBAAgB;AAC1F,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,iBAAiB,sBAAsB,KAAK,gBAAgB;AAC1F,SAAK,SAAS,OAAO,OAAO,QAAQ,OAAO,EAAE;AAC7C,SAAK,SAAS,OAAO;AACrB,SAAK,YAAY,OAAO,SAAS,WAAW;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,WAAW,QAAkD;AACjE,WAAO,KAAK,QAAuB,uBAAuB,EAAE,QAAQ,QAAQ,MAAM,OAAO,CAAC;AAAA,EAC5F;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,SAAS,QAAkF;AAC/F,WAAO,KAAK,QAA0B,yBAAyB;AAAA,MAC7D,QAAQ;AAAA,MACR,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA,EAEA,MAAc,QAAW,MAAc,MAAmC;AACxE,UAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,YAAQ,IAAI,iBAAiB,UAAU,KAAK,MAAM,EAAE;AACpD,WAAO;AAAA,MACL,GAAG,KAAK,MAAM,GAAG,IAAI;AAAA,MACrB,EAAE,GAAG,MAAM,QAAQ;AAAA;AAAA;AAAA,MAGnB,KAAK;AAAA,IACP;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/shared.ts","../src/server.ts"],"sourcesContent":["/**\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","/**\n * Server SDK — runs inside a merchant's backend. Used for the\n * server-to-server pieces of the attribution pipeline: posting\n * conversion events (signup / invoice_paid / custom), stitching\n * clicks to users from the backend, and reading dashboard snapshots.\n *\n * Typical integration:\n *\n * import { OpenPartnerServer } from '@openpartner/sdk/server';\n *\n * const op = new OpenPartnerServer({\n * apiUrl: process.env.OPENPARTNER_API_URL!,\n * apiKey: process.env.OPENPARTNER_API_KEY!,\n * });\n *\n * // On signup:\n * await op.trackEvent({ userId, type: 'signup' });\n *\n * // On invoice paid — externalEventId makes retries safe:\n * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD', externalEventId: invoice.id });\n *\n * // Holding only the click id (from the link's {click_id} macro)? One call:\n * await op.trackConversion({ clickId, type: 'loan_funded', value: 5000, externalEventId: `${loan.id}:funded` });\n */\n\nimport {\n OpenPartnerError,\n requestJson,\n type ConversionResponse,\n type EventResponse,\n type IdentifyResponse,\n type JsonRequestInit,\n} from './shared.js';\n\nexport { OpenPartnerError };\nexport type { ConversionResponse, EventResponse, IdentifyResponse };\nexport type { EventAttribution } from './shared.js';\n\nexport interface OpenPartnerServerConfig {\n /** Base URL of the OpenPartner API. */\n apiUrl: string;\n /**\n * Admin or scoped API key. Event ingest (/attribution/events) accepts\n * an admin key or a scoped key with `events:write`. Never expose this\n * to the browser.\n */\n apiKey: string;\n /** Optional fetch override for tests or custom transports. */\n fetch?: typeof fetch;\n}\n\nexport interface TrackEventParams {\n /** The user the event pertains to. Must match how the browser SDK called identify(). */\n userId: string;\n /** Event type — typically 'signup' | 'trial_started' | 'subscription_created' | 'invoice_paid' | custom. */\n type: string;\n /** Dollar (or currency) value of the event, for percent-commission rules. */\n value?: number;\n /** ISO 4217 currency code. Defaults to USD on the server if omitted. */\n currency?: string;\n /** Arbitrary metadata you want to attach (stripeEventId, invoiceId, etc). */\n metadata?: Record<string, unknown>;\n /** Event time. Defaults to now on the server. Pass ISO 8601 to override. */\n ts?: string;\n /**\n * Idempotency key: your system's stable id for this one event (order id,\n * invoice id, CRM deal-stage change id), max 120 chars. Strongly\n * recommended. A retried call with the same key returns the original\n * event and attribution (`replayed: true`) instead of recording and\n * commissioning it twice. Unique per event, not per order: an order's\n * `subscription_created` and its `invoice_paid` need different keys.\n * Reusing a key for a different userId / type / value / currency throws\n * an `OpenPartnerError` with code `external_event_id_conflict` (409).\n */\n externalEventId?: string;\n}\n\nexport interface TrackConversionParams {\n /** The click id the partner link handed you: the `{click_id}` macro in the\n * destination URL, or the `cref` query parameter. */\n clickId: string;\n /** Event type, e.g. 'loan_funded', 'opportunity_won', 'invoice_paid'. */\n type: string;\n /** Value of the conversion, for percent-commission rules. */\n value?: number;\n /** ISO 4217 currency code. Defaults to USD on the server if omitted. */\n currency?: string;\n /**\n * Required idempotency key: your stable id for this one conversion (loan\n * id plus stage, order id), max 120 chars. A retry returns the original\n * result (`replayed: true`); reusing it for a different click or\n * conversion throws `external_event_id_conflict` (409).\n */\n externalEventId: string;\n /**\n * Your own id for the user, if you have one. Omit it and the conversion is\n * recorded under the click's own user, credited to that click alone.\n */\n userId?: string;\n metadata?: Record<string, unknown>;\n /** Event time. Defaults to now on the server. ISO 8601. */\n ts?: string;\n}\n\nexport class OpenPartnerServer {\n private readonly apiUrl: string;\n private readonly apiKey: string;\n private readonly fetchImpl: typeof fetch;\n\n constructor(config: OpenPartnerServerConfig) {\n if (!config.apiUrl) throw new OpenPartnerError('apiUrl is required', 400, 'config_missing');\n if (!config.apiKey) throw new OpenPartnerError('apiKey is required', 400, 'config_missing');\n this.apiUrl = config.apiUrl.replace(/\\/$/, '');\n this.apiKey = config.apiKey;\n this.fetchImpl = config.fetch ?? globalThis.fetch;\n }\n\n /**\n * Record a conversion event. The server walks the userId back to a\n * click, applies the campaign's attribution model, and writes the\n * resulting attribution + commission rows.\n */\n async trackEvent(params: TrackEventParams): Promise<EventResponse> {\n return this.request<EventResponse>('/attribution/events', { method: 'POST', body: params });\n }\n\n /**\n * Record a conversion against a click in one call, for when you have the\n * click id but no identify()'d user: a lender reporting a funded loan, a\n * CRM closing a deal. Stitches the click and records the event together.\n * Throws `click_not_found` (404) for a click outside your program and\n * `click_flagged` (422) for one flagged as fraud.\n */\n async trackConversion(params: TrackConversionParams): Promise<ConversionResponse> {\n return this.request<ConversionResponse>('/attribution/conversions', { method: 'POST', body: params });\n }\n\n /**\n * Stitch an attribution token (cref) to a known userId. Normally the\n * browser SDK does this; use this overload if your backend receives\n * the cref from the browser (e.g. as part of a signup payload) and\n * wants to own the stitch call.\n */\n async identify(params: { cref: string; userId: string; ts?: number }): Promise<IdentifyResponse> {\n return this.request<IdentifyResponse>('/attribution/identify', {\n method: 'POST',\n body: params,\n });\n }\n\n private async request<T>(path: string, init: JsonRequestInit): Promise<T> {\n const headers = new Headers(init.headers);\n headers.set('authorization', `Bearer ${this.apiKey}`);\n return requestJson<T>(\n `${this.apiUrl}${path}`,\n { ...init, headers },\n // Inject the configured fetch so tests (or alternate transports)\n // can intercept.\n this.fetchImpl,\n );\n }\n}\n"],"mappings":";;;;;AASO,IAAM,mBAAN,cAA+B,MAAM;AAAA,EAC1C,YACE,SACgB,QACA,MACA,QAChB;AACA,UAAM,OAAO;AAJG;AACA;AACA;AAGhB,SAAK,OAAO;AAAA,EACd;AACF;AAwDA,eAAsB,YACpB,OACA,OAAwB,CAAC,GACzB,YAA0B,WAAW,OACzB;AACZ,QAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,MAAI,KAAK,SAAS,UAAa,CAAC,QAAQ,IAAI,cAAc,GAAG;AAC3D,YAAQ,IAAI,gBAAgB,kBAAkB;AAAA,EAChD;AACA,QAAM,MAAM,MAAM,UAAU,OAAO;AAAA,IACjC,GAAG;AAAA,IACH;AAAA,IACA,MACE,KAAK,SAAS,SACV,SACA,OAAO,KAAK,SAAS,WACnB,KAAK,OACL,KAAK,UAAU,KAAK,IAAI;AAAA,EAClC,CAAC;AAED,QAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,MAAI,OAAgB;AACpB,MAAI,MAAM;AACR,QAAI;AACF,aAAO,KAAK,MAAM,IAAI;AAAA,IACxB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,UAAW,QAAQ,CAAC;AAC1B,UAAM,IAAI;AAAA,MACR,QAAQ,SAAS,GAAG,IAAI,MAAM,IAAI,IAAI,UAAU;AAAA,MAChD,IAAI;AAAA,MACJ,QAAQ;AAAA,MACR,QAAQ,UAAU;AAAA,IACpB;AAAA,EACF;AAEA,SAAO;AACT;;;ACZO,IAAM,oBAAN,MAAwB;AAAA,EAK7B,YAAY,QAAiC;AAJ7C,wBAAiB;AACjB,wBAAiB;AACjB,wBAAiB;AAGf,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,iBAAiB,sBAAsB,KAAK,gBAAgB;AAC1F,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,iBAAiB,sBAAsB,KAAK,gBAAgB;AAC1F,SAAK,SAAS,OAAO,OAAO,QAAQ,OAAO,EAAE;AAC7C,SAAK,SAAS,OAAO;AACrB,SAAK,YAAY,OAAO,SAAS,WAAW;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,WAAW,QAAkD;AACjE,WAAO,KAAK,QAAuB,uBAAuB,EAAE,QAAQ,QAAQ,MAAM,OAAO,CAAC;AAAA,EAC5F;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,gBAAgB,QAA4D;AAChF,WAAO,KAAK,QAA4B,4BAA4B,EAAE,QAAQ,QAAQ,MAAM,OAAO,CAAC;AAAA,EACtG;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,SAAS,QAAkF;AAC/F,WAAO,KAAK,QAA0B,yBAAyB;AAAA,MAC7D,QAAQ;AAAA,MACR,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA,EAEA,MAAc,QAAW,MAAc,MAAmC;AACxE,UAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,YAAQ,IAAI,iBAAiB,UAAU,KAAK,MAAM,EAAE;AACpD,WAAO;AAAA,MACL,GAAG,KAAK,MAAM,GAAG,IAAI;AAAA,MACrB,EAAE,GAAG,MAAM,QAAQ;AAAA;AAAA;AAAA,MAGnB,KAAK;AAAA,IACP;AAAA,EACF;AACF;","names":[]}
@@ -9,8 +9,8 @@
9
9
  declare class OpenPartnerError extends Error {
10
10
  readonly status: number;
11
11
  readonly code?: string | undefined;
12
- readonly detail?: unknown;
13
- constructor(message: string, status: number, code?: string | undefined, detail?: unknown);
12
+ readonly detail?: unknown | undefined;
13
+ constructor(message: string, status: number, code?: string | undefined, detail?: unknown | undefined);
14
14
  }
15
15
  interface IdentifyResponse {
16
16
  ok: boolean;
@@ -19,7 +19,13 @@ interface IdentifyResponse {
19
19
  backfilledAttributions?: number;
20
20
  }
21
21
  interface EventAttribution {
22
- status: 'attributed' | 'no_identity' | 'no_click' | 'outside_window' | 'already_attributed';
22
+ /**
23
+ * 'not_attributed' only appears on a replay (`replayed: true`) of an event
24
+ * that didn't attribute the first time; which miss it was isn't stored.
25
+ * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —
26
+ * recorded, never attributed.
27
+ */
28
+ status: 'attributed' | 'no_identity' | 'no_click' | 'outside_window' | 'already_attributed' | 'not_attributed' | 'corrective';
23
29
  model?: 'last_click' | 'first_click' | 'linear' | 'position';
24
30
  touches?: Array<{
25
31
  clickId: string;
@@ -33,6 +39,15 @@ interface EventResponse {
33
39
  ok: boolean;
34
40
  eventId: string;
35
41
  attribution: EventAttribution;
42
+ /** True when an earlier call with the same externalEventId already
43
+ * recorded this event; eventId and attribution are that call's. */
44
+ replayed?: boolean;
45
+ }
46
+ interface ConversionResponse extends EventResponse {
47
+ /** The user the conversion was recorded under: the userId you passed, or
48
+ * the click's own `click:<clickId>` user. */
49
+ userId: string;
36
50
  }
37
51
 
38
- export { type EventResponse as E, type IdentifyResponse as I, OpenPartnerError as O, type EventAttribution as a };
52
+ export { OpenPartnerError as O };
53
+ export type { ConversionResponse as C, EventResponse as E, IdentifyResponse as I, EventAttribution as a };
@@ -9,8 +9,8 @@
9
9
  declare class OpenPartnerError extends Error {
10
10
  readonly status: number;
11
11
  readonly code?: string | undefined;
12
- readonly detail?: unknown;
13
- constructor(message: string, status: number, code?: string | undefined, detail?: unknown);
12
+ readonly detail?: unknown | undefined;
13
+ constructor(message: string, status: number, code?: string | undefined, detail?: unknown | undefined);
14
14
  }
15
15
  interface IdentifyResponse {
16
16
  ok: boolean;
@@ -19,7 +19,13 @@ interface IdentifyResponse {
19
19
  backfilledAttributions?: number;
20
20
  }
21
21
  interface EventAttribution {
22
- status: 'attributed' | 'no_identity' | 'no_click' | 'outside_window' | 'already_attributed';
22
+ /**
23
+ * 'not_attributed' only appears on a replay (`replayed: true`) of an event
24
+ * that didn't attribute the first time; which miss it was isn't stored.
25
+ * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —
26
+ * recorded, never attributed.
27
+ */
28
+ status: 'attributed' | 'no_identity' | 'no_click' | 'outside_window' | 'already_attributed' | 'not_attributed' | 'corrective';
23
29
  model?: 'last_click' | 'first_click' | 'linear' | 'position';
24
30
  touches?: Array<{
25
31
  clickId: string;
@@ -33,6 +39,15 @@ interface EventResponse {
33
39
  ok: boolean;
34
40
  eventId: string;
35
41
  attribution: EventAttribution;
42
+ /** True when an earlier call with the same externalEventId already
43
+ * recorded this event; eventId and attribution are that call's. */
44
+ replayed?: boolean;
45
+ }
46
+ interface ConversionResponse extends EventResponse {
47
+ /** The user the conversion was recorded under: the userId you passed, or
48
+ * the click's own `click:<clickId>` user. */
49
+ userId: string;
36
50
  }
37
51
 
38
- export { type EventResponse as E, type IdentifyResponse as I, OpenPartnerError as O, type EventAttribution as a };
52
+ export { OpenPartnerError as O };
53
+ export type { ConversionResponse as C, EventResponse as E, IdentifyResponse as I, EventAttribution as a };