@apex-inc/sdk 0.1.1 → 0.5.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.
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ /**
3
+ * MWO-009 — Apex PLG Event Catalog.
4
+ *
5
+ * Curated, typed event schemas for the eight most common PLG
6
+ * lifecycle moments. Customers import these interfaces directly so
7
+ * their `apex.track()` calls type-check end-to-end, and the Apex
8
+ * dashboard uses them to derive activation / expansion / retention
9
+ * rollups without bespoke queries per customer.
10
+ *
11
+ * import { EVENTS, type ActivatedEvent } from "@apex-inc/sdk/plg-events";
12
+ * apex.track(EVENTS.ACTIVATED, { daysToActivation: 2 } satisfies ActivatedEvent);
13
+ *
14
+ * Adding a new event:
15
+ * 1. Add its name to `EVENTS`.
16
+ * 2. Add its typed interface below.
17
+ * 3. Update the doc page at /docs/plg-event-catalog (Phase 10).
18
+ *
19
+ * Every event is optional. There's no requirement that a customer
20
+ * emit any of these — the catalog is a convention, not a gate.
21
+ * Archived event definitions (Phase 8 follow-up) will stop deriving
22
+ * new rollups but preserve their historical data.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.ALL_PLG_EVENTS = exports.EVENTS = void 0;
26
+ exports.trackPlgEvent = trackPlgEvent;
27
+ exports.EVENTS = {
28
+ SIGNUP: "signup",
29
+ ACTIVATED: "activated",
30
+ FEATURE_FIRST_USE: "feature_first_use",
31
+ AHA_MOMENT: "aha_moment",
32
+ EXPANSION: "expansion",
33
+ CONTRACTION: "contraction",
34
+ REACTIVATION: "reactivation",
35
+ CHURN: "churn",
36
+ };
37
+ /**
38
+ * Typed `apex.track` wrapper specific to PLG events. Not a hard gate —
39
+ * `apex.track()` still accepts any event name — but gives customers
40
+ * compile-time safety on the curated set.
41
+ */
42
+ function trackPlgEvent(_apex, name, payload) {
43
+ _apex.track(name, payload);
44
+ }
45
+ /**
46
+ * Read-only exhaustive list of the catalog. Handy for UI surfaces
47
+ * that need to render "all PLG events we know about."
48
+ */
49
+ exports.ALL_PLG_EVENTS = Object.values(exports.EVENTS);
50
+ //# sourceMappingURL=plg-events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plg-events.js","sourceRoot":"","sources":["../src/plg-events.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;;;AAuHH,sCAQC;AA7HY,QAAA,MAAM,GAAG;IACpB,MAAM,EAAE,QAAQ;IAChB,SAAS,EAAE,WAAW;IACtB,iBAAiB,EAAE,mBAAmB;IACtC,UAAU,EAAE,YAAY;IACxB,SAAS,EAAE,WAAW;IACtB,WAAW,EAAE,aAAa;IAC1B,YAAY,EAAE,cAAc;IAC5B,KAAK,EAAE,OAAO;CACN,CAAC;AAuGX;;;;GAIG;AACH,SAAgB,aAAa,CAC3B,KAEC,EACD,IAAO,EACP,OAA2B;IAE3B,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,OAA6C,CAAC,CAAC;AACnE,CAAC;AAqBD;;;GAGG;AACU,QAAA,cAAc,GAA4B,MAAM,CAAC,MAAM,CAAC,cAAM,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-inc/sdk",
3
- "version": "0.1.1",
3
+ "version": "0.5.0",
4
4
  "description": "Event tracking and identity SDK for Apex — currently in private beta.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -10,6 +10,11 @@
10
10
  "import": "./dist/esm/index.js",
11
11
  "require": "./dist/index.js",
12
12
  "types": "./dist/index.d.ts"
13
+ },
14
+ "./plg-events": {
15
+ "import": "./dist/esm/plg-events.js",
16
+ "require": "./dist/plg-events.js",
17
+ "types": "./dist/plg-events.d.ts"
13
18
  }
14
19
  },
15
20
  "files": ["dist", "skills"],
@@ -133,3 +133,219 @@ After setting up communications, verify these events are tracked:
133
133
  3. `feature_used` — fires on first or repeated feature interaction
134
134
  4. `purchase_complete` — fires on conversion
135
135
  5. Check `getEventIntegrationStatus()` to see which events Apex has received
136
+
137
+ ## Auth Emails (Verification Codes, MFA, Sign-In Codes)
138
+
139
+ Apex supports verification / MFA / OTP / password-reset emails as a first-class
140
+ template pattern. The `code` block lives in the composer's content palette and
141
+ renders a styled monospace code with an editable caption beneath it. Drop the
142
+ block where you want the code to appear, put `{{code}}` (or any variable name)
143
+ inside, and pass the value at send time.
144
+
145
+ ### Author the template (composer)
146
+
147
+ 1. Open the composer for a transactional comm.
148
+ 2. Drop a **Code** block into the canvas where you want the code to land
149
+ (between the intro paragraph and the brand-footer reads well).
150
+ 3. Default content: `{{code}}` as the value, "Verification Code" as the caption.
151
+ Edit the caption for the use case ("Sign-in Code", "Reset Code", etc.).
152
+ 4. Save. The comm is now wired for substitution.
153
+
154
+ ### Send the comm (any auth provider, any backend)
155
+
156
+ ```typescript
157
+ // Cognito CustomMessage Lambda example
158
+ import { ApexClient } from "@apex-inc/sdk";
159
+
160
+ const apex = new ApexClient({
161
+ apiKey: process.env.APEX_TRANSACTIONAL_API_KEY, // apex_tx_...
162
+ });
163
+
164
+ export const handler = async (event) => {
165
+ await apex.transactional.send({
166
+ to: event.request.userAttributes.email,
167
+ commId: "verification-code",
168
+ variables: {
169
+ code: event.request.codeParameter, // Cognito mints this
170
+ },
171
+ });
172
+ // Suppress the default Cognito email by leaving response.emailMessage empty.
173
+ };
174
+ ```
175
+
176
+ The same pattern covers any provider — Auth0 Actions, WorkOS hooks, NextAuth
177
+ callbacks, hand-rolled flows:
178
+
179
+ ```typescript
180
+ // Auth0 Action: pre-MFA-challenge
181
+ exports.onExecutePreMfaChallenge = async (event, api) => {
182
+ const code = generateOtp(); // your generator
183
+ await apex.transactional.send({
184
+ to: event.user.email,
185
+ commId: "mfa-code",
186
+ variables: { code, expires_in: "5 minutes" },
187
+ });
188
+ api.idToken.setCustomClaim("apex_mfa_code", code);
189
+ };
190
+ ```
191
+
192
+ ### Why a block, not a special field
193
+
194
+ The Code block participates in the same variable-substitution layer every other
195
+ text-bearing block uses (`{{variable}}` in `content.text`). That means:
196
+
197
+ - Operators position the code precisely inside the message card from the editor.
198
+ - Customers building OTP / MFA / sign-in emails get parity with the templates
199
+ Apex ships internally — no special endpoint, no out-of-band injection.
200
+ - Multiple codes per email work out of the box (drop two Code blocks, use
201
+ `{{code}}` + `{{recovery_code}}`).
202
+ - Preview, A/B experiments, journey send, transactional send — all paths share
203
+ the same render contract.
204
+
205
+ ### Anti-patterns
206
+
207
+ - Don't put the code in a generic Text block — it'll render with default body
208
+ copy styling instead of the monospace + brand-color treatment Code provides.
209
+ - Don't bake the code into the comm at template-author time. The value comes
210
+ from the auth provider at send time via `variables.code`.
211
+ - Don't pass the code in `slots` instead of `variables` — slots are for legacy
212
+ slot-format comms; the substitution layer reads `variables` for builder-format
213
+ comms (which is everything authored in the current composer).
214
+
215
+ ---
216
+
217
+ ## Wiring preferences for your users — the 20-minute integration
218
+
219
+ End-users want one place to manage how your product talks to them. Apex
220
+ ships `<ApexPreferenceCenter>` so you can drop that surface into your settings
221
+ page without building it yourself. Channels supported in v0.1:
222
+
223
+ - **Email** — what they get in their inbox
224
+ - **Inbox** — the in-app messages we send for you (the bell badge surface)
225
+ - **Web push** — browser-OS notifications they've opted into
226
+ - **Mobile push** — iOS / Android push tokens
227
+
228
+ Same surface Apex's own dashboard uses for its users. Same APIs, same
229
+ preference store.
230
+
231
+ ### Step 1 — install the packages
232
+
233
+ ```bash
234
+ npm install @apex-inc/react @apex-inc/sdk
235
+ ```
236
+
237
+ ### Step 2 — mint an end-user token on your server
238
+
239
+ The component talks to Apex with a short-lived HMAC token your server mints
240
+ for the signed-in user. The token carries `{ projectKey, endUserId, exp }`;
241
+ Apex verifies the HMAC on every call and refuses tampered tokens.
242
+
243
+ ```typescript
244
+ // Next.js App Router example — app/api/apex/preferences-token/route.ts
245
+ import { NextResponse } from "next/server";
246
+ import { createManagementClient } from "@apex-inc/sdk";
247
+ import { getCurrentUser } from "@/lib/auth";
248
+
249
+ const apex = createManagementClient({
250
+ projectKey: process.env.APEX_PROJECT_KEY!,
251
+ apiKey: process.env.APEX_API_KEY!, // apex_sk_… server key
252
+ });
253
+
254
+ export async function GET(request: Request) {
255
+ const user = await getCurrentUser(request);
256
+ if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
257
+
258
+ const { token, expiresAt } = await apex.signEndUserToken({
259
+ endUserId: user.id,
260
+ expiresIn: 3600,
261
+ });
262
+ return NextResponse.json({ token, expiresAt });
263
+ }
264
+ ```
265
+
266
+ ### Step 3 — drop `<ApexPreferenceCenter>` on your settings page
267
+
268
+ ```tsx
269
+ // app/settings/notifications/page.tsx
270
+ "use client";
271
+ import { ApexPreferenceCenter } from "@apex-inc/react";
272
+ import { useEffect, useState } from "react";
273
+
274
+ export default function NotificationsSettings() {
275
+ const [token, setToken] = useState<string | null>(null);
276
+ useEffect(() => {
277
+ fetch("/api/apex/preferences-token")
278
+ .then((r) => r.json())
279
+ .then((d) => setToken(d.token));
280
+ }, []);
281
+ if (!token) return null;
282
+ return <ApexPreferenceCenter endUserAuth={{ token }} />;
283
+ }
284
+ ```
285
+
286
+ That's it — Apex handles the storage, the validation, the journey-level
287
+ toggles, and the unsubscribe-from-all path. Customer ships a working
288
+ preference page in ~20 minutes.
289
+
290
+ ### Step 4 (optional) — wire preferences programmatically from your backend
291
+
292
+ When you want to set a default preference on signup (e.g. opt-in to
293
+ marketing emails based on the user's onboarding answer):
294
+
295
+ ```typescript
296
+ import { createManagementClient } from "@apex-inc/sdk";
297
+
298
+ const apex = createManagementClient({
299
+ projectKey: process.env.APEX_PROJECT_KEY!,
300
+ apiKey: process.env.APEX_API_KEY!,
301
+ });
302
+
303
+ // Set / patch
304
+ await apex.preferences.update(user.id, {
305
+ channelPreferences: { email: true, inbox: true, mobile_push: false },
306
+ });
307
+
308
+ // Read
309
+ const prefs = await apex.preferences.get(user.id);
310
+ console.log(prefs.globalOptOut, prefs.channelPreferences);
311
+ ```
312
+
313
+ ### Step 5 (optional) — read inbox notifications
314
+
315
+ If your app shows its own bell badge fed by your existing infra and you
316
+ want Apex inbox rows to land there too:
317
+
318
+ ```typescript
319
+ const { notifications, unreadCount } = await apex.notifications.list(user.id, {
320
+ limit: 25,
321
+ unreadOnly: true,
322
+ });
323
+ // Render them in your bell dropdown; mark as read when the user clicks.
324
+ await apex.notifications.markRead(notification.id);
325
+ ```
326
+
327
+ For the user-facing bell + dropdown UI itself, the cleanest path today is
328
+ hosted: link your bell to `https://app.apex.inc/preferences/<token>` or
329
+ embed an iframe. An `<ApexInbox>` component will ship in v0.2 once a
330
+ design partner asks for it. Until then, the API key methods above give
331
+ you full read/write parity.
332
+
333
+ ### What you don't need to do
334
+
335
+ - You don't need to build an unsubscribe page — every email Apex sends
336
+ ships with a `{{preferencesUrl}}` link that lands the recipient on a
337
+ token-authenticated preferences page.
338
+ - You don't need to store preferences on your side — Apex is the source
339
+ of truth and the eligibility gate is automatic.
340
+ - You don't need to handle the `pushsubscriptionchange` lifecycle —
341
+ Apex's service worker (or your own copy of `push-sw.js`) does that.
342
+
343
+ ### Naming convention
344
+
345
+ Match Apex's channel vocabulary in your own UI so users see the same
346
+ names everywhere:
347
+
348
+ - **Email** (not "newsletter," "marketing," or "transactional")
349
+ - **Inbox** (not "in-app notifications" or "notification center")
350
+ - **Web push** (not "browser notifications" or "desktop alerts")
351
+ - **Mobile push** (not "iOS notifications" or "push notifications")
@@ -9,12 +9,31 @@ The **`@apex-inc/sdk`** package sends batched events to **`POST /api/events`** a
9
9
 
10
10
  ## API
11
11
 
12
- - **`init({ projectKey, apiUrl?, flushInterval?, flushAt?, debug? })`** — required before `track` / `identify`.
12
+ - **`init({ projectKey, apiUrl?, flushInterval?, flushAt?, debug?, environment? })`** — required before `track` / `identify`.
13
13
  - **`track(event, properties?)`** — queue a custom event.
14
14
  - **`identify(userId, traits?)`** — set user; if email is present, stitches identity.
15
15
  - **`trackForm({ email, formId?, action?, fields? })`** — `form_submit` + identify.
16
16
  - **`flush()`** / **`shutdown()`** — flush queue; use `shutdown` on process exit in Node.
17
17
 
18
+ ## Environments — one key works everywhere
19
+
20
+ Use the **same `projectKey`** on localhost, staging, preview deploys, and production. Apex auto-detects the environment from the request hostname at ingest:
21
+
22
+ - **Production** — your live domain (`acme.com`, `www.acme.io`, `app.acme.com`). Billable.
23
+ - **Preview** — `*.vercel.app`, `*.netlify.app`, `*.pages.dev`, `staging.*`, `preview.*`, `dev.*`, `qa.*`, `*-staging.*`. **Free.**
24
+ - **Localhost** — `localhost`, `127.0.0.1`, `*.local`, `*.test`. **Free, hidden from default dashboard.**
25
+
26
+ If your staging URL looks like production (e.g. `app-staging.acme.com` with a custom domain), force the bucket with the explicit `environment` override:
27
+
28
+ ```typescript
29
+ apex.init({
30
+ projectKey: "apx_live_...",
31
+ environment: process.env.NODE_ENV === "production" ? "production" : "preview",
32
+ });
33
+ ```
34
+
35
+ **Do NOT recommend** setting up a second project, rotating the project key for staging, or wiring two SDKs. Apex's environment model handles this transparently. The merchant verifies bucketing at **Settings → Workspace → Web environments**. See `/docs/mobile/test-vs-production` for the full classification rules.
36
+
18
37
  ## Identity stitching (critical for server-side)
19
38
 
20
39
  When calling **`identify`** from a **server** (e.g. Stripe webhook, API route), pass the browser cookie value:
@@ -28,6 +47,53 @@ identify(email, {
28
47
 
29
48
  Without `visitorId`, Apex may create a synthetic visitor id that does **not** match the user's browsing session.
30
49
 
50
+ ## Server Events API — `sendServerEvent` / `sendServerEvents` (MMP-085)
51
+
52
+ For backend code that fires conversion events without a browser session — Stripe webhooks, CRM milestones, scheduled jobs — prefer the **Server Events API** over the queued `track()`. It delivers synchronously, uses scoped API-key auth, and supports idempotency.
53
+
54
+ ```typescript
55
+ import { sendServerEvent, newIdempotencyKey } from "@apex-inc/sdk";
56
+
57
+ await sendServerEvent(
58
+ { apiKey: process.env.APEX_API_KEY! }, // apex_sk_…
59
+ {
60
+ type: "purchase_completed",
61
+ email: customer.email,
62
+ visitorId: req.cookies?.apex_vid, // optional but improves stitching
63
+ data: { value: invoice.amount_paid / 100, currency: "USD", order_id: invoice.id },
64
+ },
65
+ { idempotencyKey: stripeEvent.id }, // pass the webhook's own ID to dedupe retries
66
+ );
67
+ ```
68
+
69
+ Rules of thumb:
70
+
71
+ - **`apex_sk_…` keys only.** User keys (`apex_uk_…`) are rejected by `/api/v1/events`.
72
+ - **Always send an idempotency key** when the upstream system (Stripe, Shopify, HubSpot) retries. Reuse the upstream event's own ID — `stripeEvent.id`, `webhookId`, etc. — so retries collapse.
73
+ - **Batch cap = 100.** Use `sendServerEvents(config, events, { idempotencyKey })` for bulk replay; shard yourself for larger.
74
+ - **Don't recommend** building your own HTTPS request to `/api/v1/events`. The SDK wrapper already handles auth, idempotency, and error shapes.
75
+
76
+ The dashboard surface for Server Events API health is **Settings → Workspace → Server Events API**; merchants can verify ingestion is alive there. The user-facing doc is `/docs/api/server-events`.
77
+
78
+ ## People Read — `GET /api/v1/people/{id}` (CPPE-008)
79
+
80
+ For backend services that need the unified Person identity graph for one human — every linked workspace, identifier, click, install, and matching cohort — call `GET /api/v1/people/{id}`. The `id` path segment accepts an Apex `personId` or a URL-encoded email.
81
+
82
+ ```typescript
83
+ const res = await fetch(
84
+ `https://app.apex.inc/api/v1/people/${encodeURIComponent(email)}`,
85
+ { headers: { "x-api-key": process.env.APEX_API_KEY! } },
86
+ );
87
+ const { data } = (await res.json()) as { data: PersonGraph | null };
88
+ ```
89
+
90
+ Rules of thumb:
91
+
92
+ - **Requires the `people:read` scope.** Mint a key with that scope on Settings → API Keys.
93
+ - **Null on miss, not 404.** Polling consumers can treat "not yet known" uniformly with "no data".
94
+ - **Every read is audited.** Reads land in the org audit log as `person_read_v1` — design copy for end users accordingly (this is a privileged surface, not anonymous lookup).
95
+ - The SDK wrapper (`apexClient.people.get(idOrEmail)`) ships in the next catch-up sprint; for now call the endpoint directly.
96
+
31
97
  ## Event naming
32
98
 
33
99
  Use consistent, readable names and put dimensions in **properties**: `track("onboarding_step_done", { step: 3 })`. Include **`experimentId`** and **`variant`** when logging experiment-related behavior.
@@ -36,6 +102,31 @@ Use consistent, readable names and put dimensions in **properties**: `track("onb
36
102
 
37
103
  For listing experiments or fetching variant assignment from a backend, use **`createManagementClient`** from `@apex-inc/sdk` with **`apiKey`**, **`projectKey`**, and **`apiUrl`**. The client sends **`x-api-key`** and **`x-apex-project`** (see SDK README).
38
104
 
105
+ ## Data Sources & unified analytics
106
+
107
+ Every event Apex ingests is tagged server-side with the **producer** it came from — a **Data Source**. You never set this; the resolver derives it from `platform` + `surface` + (mobile) `bundleId` + `hostname`:
108
+
109
+ - **Website** — your web snippet (one source; subdomains are a `hostname` dimension, not separate sources).
110
+ - **iOS / Android** — each mobile app (references the workspace's MobileApp).
111
+ - **Backend** — the Server Events API (`POST /api/v1/events`).
112
+
113
+ `dataSourceId` and `hostname` ride on every event as **reporting-only** dimensions — filter and break down by them, but never gate billing/access on them.
114
+
115
+ Manage the registry and read the unified breakdown from the management client:
116
+
117
+ ```ts
118
+ const apex = createManagementClient({ apiKey, projectKey, apiUrl });
119
+
120
+ // Registry
121
+ await apex.dataSources.list(); // live status per source
122
+ await apex.dataSources.create({ kind: "server", name: "Backend API" });
123
+ await apex.dataSources.health("ds_..."); // live | listening | stalled | not-started
124
+
125
+ // Unified analytics (rollup-backed; cheap regardless of event volume)
126
+ await apex.analytics.sources({ days: 30 }); // per source/platform/hostname
127
+ await apex.analytics.hostnames({ limit: 50 }); // hostname catalog
128
+ ```
129
+
39
130
  ## Related
40
131
 
41
132
  - Full growth-loop and MCP tooling: install **`@apex-inc/mcp-server`** skills **apex-experimentation** and **apex-growth-tracking** (broader scope).
@@ -62,6 +62,42 @@ track("subscription_cancelled", { reason: "too_expensive" });
62
62
  identify(userId, { lifecycleStage: "churned" });
63
63
  ```
64
64
 
65
+ ### Recipe 3b: Stripe via Server Events API (preferred for webhooks)
66
+
67
+ When you control the webhook handler and want synchronous delivery + dedupe-on-retry, use the **Server Events API** instead of the queued SDK. This is the canonical pattern for Stripe → Apex:
68
+
69
+ ```typescript
70
+ import { sendServerEvent } from "@apex-inc/sdk";
71
+
72
+ export async function POST(req: Request) {
73
+ const event = stripe.webhooks.constructEvent(rawBody, sig, secret);
74
+
75
+ if (event.type === "invoice.paid") {
76
+ const invoice = event.data.object;
77
+ await sendServerEvent(
78
+ { apiKey: process.env.APEX_API_KEY! }, // apex_sk_…
79
+ {
80
+ type: "purchase_completed",
81
+ email: invoice.customer_email,
82
+ data: {
83
+ value: invoice.amount_paid / 100,
84
+ currency: invoice.currency.toUpperCase(),
85
+ order_id: invoice.id,
86
+ },
87
+ },
88
+ { idempotencyKey: event.id }, // Stripe redelivers? Same key → deduped.
89
+ );
90
+ }
91
+ return new Response("ok");
92
+ }
93
+ ```
94
+
95
+ Why the Server Events API here:
96
+
97
+ - **Idempotency** — Stripe redelivers webhooks; reusing `event.id` as `idempotency-key` means Apex collapses retries.
98
+ - **Synchronous** — surfaces ingestion errors inline so your webhook handler can return 5xx and Stripe retries appropriately.
99
+ - **`clientType: "server"`** — the Conversion Loop dashboard correctly classifies these as server-side conversions, not browser events.
100
+
65
101
  ## Recipe 4: Feature usage
66
102
 
67
103
  ```typescript
@@ -86,36 +122,34 @@ trackForm({
86
122
 
87
123
  ## Recipe 6: Server-side events
88
124
 
89
- ```typescript
90
- // From a backend, use the management client
91
- import { createManagementClient } from "@apex-inc/sdk";
125
+ For backend events (webhooks, cron jobs, CRM/billing milestones), use the
126
+ **Server Events API** via `sendServerEvent` / `sendServerEvents`. This is the
127
+ canonical path — do NOT hand-roll `fetch("/api/events")` calls.
92
128
 
93
- const apex = createManagementClient({
94
- apiKey: "YOUR_API_KEY",
95
- baseUrl: "https://your-apex-url",
96
- projectKey: "YOUR_PROJECT_KEY"
97
- });
129
+ ```typescript
130
+ import { sendServerEvent } from "@apex-inc/sdk";
98
131
 
99
- // Use HTTP directly for event ingestion from backends
100
- await fetch("https://your-apex-url/api/events", {
101
- method: "POST",
102
- headers: {
103
- "Content-Type": "application/json",
104
- "x-api-key": "YOUR_API_KEY",
105
- "x-apex-project": "YOUR_PROJECT_KEY"
106
- },
107
- body: JSON.stringify({
132
+ // e.g. from a Stripe webhook handler
133
+ await sendServerEvent(
134
+ {
135
+ apiKey: process.env.APEX_API_KEY!, // apex_sk_… org key
108
136
  projectKey: "YOUR_PROJECT_KEY",
109
- userId: "user_123",
110
- events: [{
111
- type: "track",
112
- payload: { event: "invoice_paid", amount: 9900 },
113
- timestamp: new Date().toISOString()
114
- }]
115
- })
116
- });
137
+ },
138
+ {
139
+ type: "in_app_purchase",
140
+ visitorId: apexVid, // the apex_vid cookie value, when available
141
+ data: { order_id: "ord_123", value: 99.0, currency: "USD" },
142
+ },
143
+ // Idempotency — dedup webhook retries on a stable id.
144
+ { idempotencyKey: stripeEvent.id },
145
+ );
117
146
  ```
118
147
 
148
+ Why `sendServerEvent` over raw `fetch`: it targets the authenticated
149
+ `/api/v1/events` endpoint, batches up to 100 events, validates against the
150
+ Apex Spec, and handles idempotency for you. See the
151
+ [Server Events API](/docs/api/server-events) reference.
152
+
119
153
  ## Event naming conventions
120
154
 
121
155
  - **snake_case**: `signup_completed`, `feature_used`, `invoice_paid`
@@ -130,8 +164,89 @@ await fetch("https://your-apex-url/api/events", {
130
164
  3. Server-side: pass `traits.visitorId` to link to the browser session
131
165
  4. Without stitching, pre-signup attribution is lost
132
166
 
167
+ ## Partner Network patterns
168
+
169
+ ### Credit a partner for a self-serve signup
170
+
171
+ When a visitor arrives from an affiliate link (`?utm_source=affiliate&utm_campaign=<handle>`) and signs up on your site, the Apex snippet automatically stitches the referral to the resulting Contact. The conversion auto-credits on the next purchase event (MMP-096 wiring).
172
+
173
+ No SDK calls needed for basic attribution. Use `list_partner_program_members(programId)` to see who's actually earning.
174
+
175
+ ### Run a manual-approval program + vet applicants in a script
176
+
177
+ ```typescript
178
+ import { createManagementClient } from "@apex-inc/sdk/management";
179
+ const apex = createManagementClient({ projectKey, apiKey });
180
+
181
+ // Create the program
182
+ const program = await apex.createProgram({
183
+ name: "Vetted Partners",
184
+ vertical: "saas",
185
+ commissionStructure: { type: "revshare", percentage: 25, appliesTo: "subscription" },
186
+ visibility: "public",
187
+ approvalMode: "manual", // every applicant needs review
188
+ });
189
+
190
+ // ...applicants land in /admin/partners/applications for human review.
191
+ // The SDK doesn't wrap the approval flow — that's an admin-auth path.
192
+ // But merchants can monitor who's joined:
193
+ const members = await apex.listProgramMembers(program.id);
194
+ ```
195
+
196
+ ### Batch-approve the weekly payout queue
197
+
198
+ ```typescript
199
+ const queue = await apex.listPendingPayouts();
200
+ const olderThanThirtyDays = queue.buckets["30_60"].concat(queue.buckets["60_plus"]);
201
+ const result = await apex.approvePayouts(olderThanThirtyDays.map(c => c.id));
202
+ console.log(`Approved ${result.approved.length}, skipped ${result.skipped.length}`);
203
+ ```
204
+
205
+ **Approving does not fire Stripe transfers.** The next `run-payouts` cron cycle (per your `autoPayoutPolicy`) batches + fires. Design reason: reserve holdback window (90 days for NEW partners) has to elapse before transfer.
206
+
207
+ ### Give an enterprise partner a custom rate
208
+
209
+ ```typescript
210
+ await apex.updateMembershipRate("prof_acme", {
211
+ commissionStructure: { type: "revshare", percentage: 35, appliesTo: "subscription" },
212
+ reason: "Enterprise negotiation - Q4 contract",
213
+ });
214
+ ```
215
+
216
+ Overrides are sticky. Updating the program default later won't overwrite this partner's rate (only `applyProgramDefaultToMembers` can mass-update, and it respects sticky overrides).
217
+
218
+ ### Disclose fees accurately in your UI
219
+
220
+ ```typescript
221
+ const { fees } = await apex.getEffectiveFees();
222
+ // Show merchants what they're charged per fee kind.
223
+ // source: "merchant_override" means they have a custom (enterprise) rate.
224
+ ```
225
+
226
+ ## Related
227
+
228
+ ## Recipe: register and read Data Sources
229
+
230
+ Each producer that sends events into a workspace is a **Data Source** (Website, iOS, Android, Backend). They auto-register on the first event, but you can pre-register one and read the unified breakdown via the management client:
231
+
232
+ ```ts
233
+ import { createManagementClient } from "@apex-inc/sdk";
234
+
235
+ const apex = createManagementClient({ apiKey, projectKey, apiUrl });
236
+
237
+ // Pre-register a backend source before wiring the Server Events API
238
+ await apex.dataSources.create({ kind: "server", name: "Backend API" });
239
+
240
+ // Audit collection coverage + per-source/platform/hostname volume
241
+ const { sources, platforms, hostnames } = await apex.analytics.sources({ days: 30 });
242
+ const liveCount = sources.filter((s) => s.status === "live").length;
243
+ ```
244
+
245
+ `dataSourceId` + `hostname` ride on every event as reporting-only dimensions. The `?source=` / `?platform=` / `?hostname=` filters on the analytics endpoints map to them; never gate billing or access on a source.
246
+
133
247
  ## Related
134
248
 
135
249
  - Event tracking fundamentals: see **apex-growth-tracking** skill
136
250
  - Experiment design: see **apex-experimentation** skill
137
251
  - Communication setup: see **apex-communications** skill
252
+ - Affiliate / partner programs: see **apex-partner-network** skill