@apex-inc/sdk 0.1.0 → 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.
- package/README.md +63 -125
- package/dist/esm/index.js +165 -2
- package/dist/esm/management.js +392 -0
- package/dist/esm/plg-events.js +45 -0
- package/dist/index.d.ts +242 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +172 -3
- package/dist/index.js.map +1 -1
- package/dist/management.d.ts +283 -0
- package/dist/management.d.ts.map +1 -1
- package/dist/management.js +392 -0
- package/dist/management.js.map +1 -1
- package/dist/plg-events.d.ts +131 -0
- package/dist/plg-events.d.ts.map +1 -0
- package/dist/plg-events.js +50 -0
- package/dist/plg-events.js.map +1 -0
- package/package.json +8 -7
- package/skills/apex-communications/SKILL.md +216 -0
- package/skills/apex-growth-tracking/SKILL.md +92 -1
- package/skills/apex-integration-cookbook/SKILL.md +140 -25
- package/skills/apex-partner-network/SKILL.md +191 -0
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apex-inc/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -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"],
|
|
@@ -17,12 +22,8 @@
|
|
|
17
22
|
"build": "tsc && tsc -p tsconfig.esm.json",
|
|
18
23
|
"dev": "tsc --watch"
|
|
19
24
|
},
|
|
20
|
-
"keywords": ["apex", "
|
|
25
|
+
"keywords": ["apex", "sdk"],
|
|
21
26
|
"license": "MIT",
|
|
22
|
-
"repository": {
|
|
23
|
-
"type": "git",
|
|
24
|
-
"url": "https://github.com/apex-inc/sdk"
|
|
25
|
-
},
|
|
26
27
|
"publishConfig": {
|
|
27
28
|
"access": "public"
|
|
28
29
|
},
|
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
baseUrl: "https://your-apex-url",
|
|
96
|
-
projectKey: "YOUR_PROJECT_KEY"
|
|
97
|
-
});
|
|
129
|
+
```typescript
|
|
130
|
+
import { sendServerEvent } from "@apex-inc/sdk";
|
|
98
131
|
|
|
99
|
-
//
|
|
100
|
-
await
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|