@yougrowai/node 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,36 @@
1
1
  # @yougrowai/node
2
2
 
3
- Connect your product to YouGrow lifecycle journeys. Your server sends YouGrow
4
- what your users do (sign-ups, onboarding steps). YouGrow then asks your server
5
- for fresh context just before it emails someone.
3
+ Keep your users' state in YouGrow lifecycle journeys, and verify the requests
4
+ YouGrow sends you. Your server tells YouGrow who each user is and how far
5
+ they've got: sign-up, onboarding steps, facts, opt-outs. YouGrow decides which
6
+ journeys they're in, and what to send them when.
7
+
8
+ **Full documentation: https://yougrow.ai/developers.** The published package and
9
+ https://yougrow.ai/developers are the contract. The source on GitHub isn't: its
10
+ main branch can be ahead of what's been released.
11
+
12
+ > **2026-09-25:** API v1 (`POST /api/v1/events`, HMAC signing) was removed.
13
+ > 0.3.0 is the client for API v2; 0.1.x only spoke v1, so upgrade to keep
14
+ > sending. See [Upgrading from 0.1.x](#upgrading-from-01x).
6
15
 
7
16
  ```sh
8
17
  npm install @yougrowai/node
9
18
  ```
10
19
 
11
- Node 18 or later, no dependencies. Server-side only: your secret must never
12
- reach a browser or app bundle.
20
+ Node 18 or later, no dependencies. It works with `import` (ESM) and `require`
21
+ (CommonJS), e.g. in Cloud Functions compiled to CommonJS:
22
+
23
+ ```js
24
+ const { YouGrow } = require("@yougrowai/node");
25
+ const { createVerifier, contextResponse } = require("@yougrowai/node/server");
26
+ ```
13
27
 
14
- > Status: 0.x. The wire protocol is stable; the SDK's API may still change
15
- > before 1.0. The same signing works from any language; see "Without the SDK"
16
- > below.
28
+ Server-side only: your secret must never reach a browser or app bundle.
17
29
 
18
- ## Send events
30
+ > Status: 0.x. The HTTP API is versioned (`/api/v2`); the SDK's own API may
31
+ > still change before 1.0.
32
+
33
+ ## Quick start
19
34
 
20
35
  ```ts
21
36
  import { YouGrow } from "@yougrowai/node";
@@ -23,45 +38,209 @@ import { YouGrow } from "@yougrowai/node";
23
38
  const yg = new YouGrow({
24
39
  keyId: process.env.YOUGROW_KEY_ID!, // from Products → your connection
25
40
  secret: process.env.YOUGROW_SECRET!, // shown once; keep it server-side
41
+ origin: process.env.YOUGROW_ORIGIN, // optional; defaults to https://yougrow.ai
42
+ });
43
+
44
+ // At sign-up: who they are, and the basis for emailing them
45
+ await yg.users.update(user.id, {
46
+ email: user.email,
47
+ firstName: user.firstName,
48
+ timezone: user.timezone, // IANA, e.g. "Europe/London"
49
+ signedUpAt: user.createdAt.toISOString(),
50
+ consent: "soft_opt_in", // consent | soft_opt_in | corporate_subscriber | none
26
51
  });
27
52
 
28
- // On sign-up
29
- yg.identify({
30
- userId: user.id,
31
- traits: { email: user.email, firstName: user.firstName, timezone: "Europe/London", plan: "free" },
32
- consent: { basis: "soft_opt_in" }, // consent | soft_opt_in | corporate_subscriber | none
53
+ // As they get going: onboarding steps, facts and traits, in the same kind of update
54
+ await yg.users.update(user.id, {
55
+ steps: { create_brand: new Date().toISOString() },
56
+ facts: { competitors_tracked: 3 },
57
+ traits: { plan: "pro" },
33
58
  });
34
- yg.track({ userId: user.id, event: "user.signed_up" });
35
59
 
36
- // As onboarding progresses
37
- yg.stepCompleted(user.id, "create_brand");
60
+ // They turned lifecycle email off in your product's settings
61
+ await yg.users.update(user.id, { subscribed: false });
38
62
 
39
- await yg.flush(); // or let it flush automatically (every 20 messages / 5 s)
63
+ // Staff, test accounts, invited teammates: never email them
64
+ await yg.users.update(user.id, { excluded: { reason: "staff" } });
65
+
66
+ // They deleted their account: erase them from YouGrow too
67
+ await yg.users.delete(user.id);
40
68
  ```
41
69
 
42
- Messages are batched (at most 100 per request) and retried with backoff on
43
- network errors, 429 and 5xx. Every message has a `messageId`, so a retry is
44
- never double-counted.
70
+ Each call resolves once YouGrow has answered; nothing is sent in the
71
+ background. For `userId`, use your own stable user id, not an email address
72
+ (which can change).
45
73
 
46
- ### Reserved events
74
+ ### A scheduled sync
47
75
 
48
- | Event | Meaning |
49
- |---|---|
50
- | `user.signed_up` | Starts sign-up journeys |
51
- | `onboarding.step_completed` | `properties.step` is the step id from your catalog |
52
- | `onboarding.completed` | Every onboarding step is done |
53
- | `email_preferences.updated` | `properties.category` + `properties.subscribed` |
54
- | `user.deleted` | Erase this user and their history |
76
+ `users.batch` takes any number of users. It sends them 100 per request, one
77
+ request after another, and returns one result for the lot:
78
+
79
+ ```ts
80
+ const result = await yg.users.batch(
81
+ accounts.map((a) => ({
82
+ userId: a.id,
83
+ steps: { create_brand: a.brandCreatedAt?.toISOString() ?? null },
84
+ facts: { competitors_tracked: a.competitorCount },
85
+ updatedAt: a.updatedAt.toISOString(),
86
+ })),
87
+ );
88
+ // { applied: 248, ignored: 1, failed: 1,
89
+ // results: [{ index: 17, userId: "u_42", status: "failed", reason: "invalid", fields: [{ path, message }] }, …] }
90
+ ```
91
+
92
+ One bad item never fails the rest. `results` lists only the ignored and failed
93
+ items; `index` is the item's position in the array you passed. If any item
94
+ fails, one `console.warn` line says how many, with the first few user ids and
95
+ reasons (never the data you sent). Pass `{ quiet: true }` to skip that line, or
96
+ `{ throwOnItemError: true }` to get a `YouGrowBatchError` instead, carrying the
97
+ result as `err.result`, once every item has been sent.
98
+
99
+ If a request fails outright (a 401, say, or a 5xx that outlasts the retries),
100
+ `users.batch` rejects there. The requests before it were applied, and sending
101
+ the whole batch again is harmless. Requests are also kept under the API's
102
+ 512 KB body limit; an item too big for any request fails as `body_too_large`.
103
+
104
+ ### Events (optional)
105
+
106
+ Journeys run on state, so events are optional. Use them for milestones worth
107
+ branching on:
108
+
109
+ ```ts
110
+ await yg.events.track(user.id, "report.exported", {
111
+ properties: { format: "pdf" }, // optional, up to 4 KB
112
+ occurredAt: new Date().toISOString(), // optional
113
+ idempotencyKey: `report.exported:${report.id}`, // optional: the same key is recorded once
114
+ });
115
+ // { recorded: true, duplicate: false }
116
+ ```
117
+
118
+ Event names are lower-case and dotted. The user must exist first: an event for
119
+ a user YouGrow hasn't seen rejects with a 404 (`not_found`). Without an
120
+ `idempotencyKey`, the SDK sends a random one per call, so its own retries never
121
+ record an event twice. A key that was already recorded resolves
122
+ `{ recorded: false, duplicate: true }`. The v1 lifecycle events (`user.signed_up`,
123
+ `onboarding.step_completed`, …) are state now, and sending one is refused
124
+ (400); see [Upgrading from 0.1.x](#upgrading-from-01x).
125
+
126
+ ## How an update merges
127
+
128
+ `users.update` sends the user's state as a JSON Merge Patch (RFC 7396):
129
+
130
+ - Fields you send replace YouGrow's; fields you leave out stay as they are.
131
+ - `null` clears a field: `{ lastName: null }`.
132
+ - `steps`, `facts` and `traits` merge key by key. `{ steps: { invite_team: "…" } }`
133
+ adds that step and keeps the others; `{ traits: { plan: null } }` removes
134
+ just `plan`.
135
+ - A step's value is when it was done (`null`: not done). Timestamps are ISO 8601
136
+ with a zone (`Z` or `+01:00`), e.g. from `toISOString()`.
137
+ - `email`, `firstName`, `lastName`, `timezone` and `locale` are fields of their
138
+ own, never traits. Unknown fields are refused (400), so a typo can't be
139
+ silently dropped.
140
+ - Up to 50 steps, 50 facts and 50 traits per user. Step ids are lower-case
141
+ letters, digits, `_` and `-`; trait keys start with a letter.
142
+
143
+ A user YouGrow hasn't seen is created by their first update.
144
+ `users.get(userId)` returns the state as YouGrow holds it, plus what YouGrow
145
+ decided: journey `enrolments`, and `optOuts` (unsubscribes made in YouGrow's
146
+ emails, which hold until the person lifts them; the API can't). It returns
147
+ `null` for a user YouGrow doesn't know.
148
+
149
+ ## Stale writes
150
+
151
+ Set `updatedAt` to when you read the state you're sending. YouGrow ignores a
152
+ write older than the newest one it applied, so a slow retry or an out-of-order
153
+ job can't overwrite fresher state:
154
+
155
+ ```ts
156
+ const r = await yg.users.update(user.id, { traits: { plan: user.plan }, updatedAt: user.updatedAt.toISOString() });
157
+ if (!r.applied) console.info(r.reason); // "stale_write" (with storedUpdatedAt) or "deleted_later"
158
+ ```
159
+
160
+ An ignored write isn't an error: the call resolves with `applied: false` (in a
161
+ batch, the item is `ignored`). Writes without `updatedAt` always apply.
162
+
163
+ `users.delete` erases the user and their history. It always succeeds, even for
164
+ a user YouGrow never saw, so it's safe to repeat. A later write whose
165
+ `updatedAt` is from before the deletion is ignored (`deleted_later`); any other
166
+ write starts a fresh person, so stop sending a user once you've deleted them.
55
167
 
56
- Other event names (lower-case, dotted, e.g. `report.exported`) are recorded as
57
- milestones.
168
+ ## Errors, retries and timeouts
58
169
 
59
- ## Answer context requests
170
+ Every method returns a promise; await it.
60
171
 
61
- Before an email, YouGrow sends your context endpoint a `POST` about one user.
62
- It carries `Authorization: Bearer <JWT>`, signed with **YouGrow's** private key.
63
- You verify it against YouGrow's published public keys. Your secret isn't
64
- involved, so nothing you store can be used to forge a request from YouGrow.
172
+ - A request that takes more than 10 s is abandoned. Timeouts, network errors,
173
+ 429 and 5xx are retried (3 times by default) with jittered backoff, waiting
174
+ at most 5 s between tries; a 429's `Retry-After` is honoured up to that cap.
175
+ Retries are safe: updates and deletes are idempotent, and events carry an
176
+ idempotency key.
177
+ - Any other error status rejects straight away with a `YouGrowError`: `status`,
178
+ `code` (the API's `error`, e.g. `invalid`, `unauthorized`, `body_too_large`),
179
+ `fields` for a 400, and a message such as
180
+ `YouGrow API 400 invalid: traits.plan: …`. A 429 or 5xx that outlasts the
181
+ retries rejects the same way; a network error or timeout rejects with that
182
+ error.
183
+ - A user id the API can't take (empty, over 256 characters, `"batch"`, `"."`
184
+ or `".."`) rejects with a `TypeError` before anything is sent.
185
+
186
+ ```ts
187
+ import { YouGrowError } from "@yougrowai/node";
188
+
189
+ try {
190
+ await yg.users.update(user.id, patch);
191
+ } catch (err) {
192
+ if (err instanceof YouGrowError && err.status === 400) console.error(err.message, err.fields); // [{ path, message }]
193
+ throw err;
194
+ }
195
+ ```
196
+
197
+ | Option | Default | |
198
+ |---|---|---|
199
+ | `origin` | `https://yougrow.ai` | YouGrow's origin. Set it (e.g. from `YOUGROW_ORIGIN`) when you're connected to another YouGrow instance, such as staging or a self-hosted one. |
200
+ | `timeoutMs` | `10000` | Abandon a request after this long; it's retried like a network error. |
201
+ | `maxRetries` | `3` | Retries per request for network errors, timeouts, 429 and 5xx. |
202
+ | `maxRetryWaitMs` | `5000` | The longest wait between retries. `Retry-After` is honoured up to this. |
203
+ | `fetch` | global `fetch` | A custom fetch (tests, proxies). |
204
+
205
+ A request that keeps failing takes at most `(maxRetries + 1) × timeoutMs`, plus
206
+ the waits between retries. For a function with a short time limit, lower
207
+ `timeoutMs` or `maxRetries`. If a large sync is rate-limited (429), raise
208
+ `maxRetryWaitMs` (e.g. to `60000`) so its retries wait for the limit to reset.
209
+
210
+ ## Serverless
211
+
212
+ Every call is awaited: nothing is queued, nothing is sent in the background,
213
+ and there's nothing to flush or close. Once the promise resolves, YouGrow has
214
+ the update, so your function can return straight after:
215
+
216
+ ```ts
217
+ const yg = new YouGrow({ keyId: process.env.YOUGROW_KEY_ID!, secret: process.env.YOUGROW_SECRET! }); // once, outside the handler
218
+
219
+ export const onSignup = functions.auth.user().onCreate(async (user) => {
220
+ await yg.users.update(user.uid, {
221
+ email: user.email ?? null,
222
+ signedUpAt: new Date(user.metadata.creationTime).toISOString(),
223
+ });
224
+ });
225
+ ```
226
+
227
+ ## Runtimes
228
+
229
+ Node 18 or later, as ESM or CommonJS. Edge runtimes (Vercel Edge Functions,
230
+ Next.js edge routes and middleware, Cloudflare Workers) aren't supported yet,
231
+ because the SDK uses `node:crypto`. Use a Node runtime route or function.
232
+
233
+ ## Verify YouGrow's requests
234
+
235
+ YouGrow calls your server too: your context endpoint, just before it emails
236
+ someone, and your webhook endpoint. Every such request carries
237
+ `Authorization: Bearer <JWT>`, signed with **YouGrow's** private key. You
238
+ verify it against YouGrow's published public keys. Your secret isn't involved,
239
+ so nothing you store can be used to forge a request from YouGrow.
240
+
241
+ ### Answer context requests
242
+
243
+ Before an email, YouGrow sends your context endpoint a `POST` about one user:
65
244
 
66
245
  ```ts
67
246
  import { createVerifier, contextResponse } from "@yougrowai/node/server";
@@ -69,15 +248,15 @@ import { createVerifier, contextResponse } from "@yougrowai/node/server";
69
248
  // Once, at startup. keyId is the token's audience: tokens for other connections fail.
70
249
  const verifier = createVerifier({
71
250
  keyId: process.env.YOUGROW_KEY_ID!,
72
- // issuer: "https://<dev origin>" // staging only; defaults to https://yougrow.ai
251
+ origin: process.env.YOUGROW_ORIGIN, // optional; the same value as the client's
73
252
  });
74
253
 
75
254
  app.post("/yougrow/context", express.raw({ type: "application/json" }), async (req, res) => {
76
- const rawBody = req.body.toString("utf8");
77
- const v = await verifier.verify({ headers: req.headers, rawBody, direction: "context" });
255
+ // Verify the RAW body (here a Buffer) before parsing it.
256
+ const v = await verifier.verify({ headers: req.headers, rawBody: req.body, direction: "context" });
78
257
  if (!v.ok) return res.status(401).end();
79
258
 
80
- const { userId } = JSON.parse(rawBody);
259
+ const { userId } = JSON.parse(req.body.toString("utf8"));
81
260
  const u = await loadOnboardingState(userId);
82
261
  res.type("json").send(
83
262
  contextResponse({
@@ -91,15 +270,20 @@ app.post("/yougrow/context", express.raw({ type: "application/json" }), async (r
91
270
  });
92
271
  ```
93
272
 
94
- The verifier fetches `https://yougrow.ai/.well-known/jwks.json` once. It caches
95
- the keys for as long as their `Cache-Control` allows, and refetches early when
96
- a token names a key it hasn't seen. That means YouGrow can rotate its keys
97
- without any change on your side.
273
+ `verify` takes the body exactly as received, as a string or a Buffer (e.g.
274
+ `req.rawBody` in Firebase Cloud Functions), never re-serialised JSON. Plain
275
+ header objects match in any case (e.g. API Gateway's `event.headers`). How to
276
+ read the raw body in each framework: https://yougrow.ai/developers/security.
277
+
278
+ The verifier fetches `<origin>/.well-known/jwks.json` once. It caches the keys
279
+ for as long as their `Cache-Control` allows, and refetches early when a token
280
+ names a key it hasn't seen. That means YouGrow can rotate its keys without any
281
+ change on your side.
98
282
 
99
283
  Numbers about the user appear only in your facts and insight sentences. YouGrow
100
284
  never invents them.
101
285
 
102
- ## Webhooks
286
+ ### Webhooks
103
287
 
104
288
  YouGrow tells your webhook endpoint about preference changes, e.g. an
105
289
  unsubscribe from onboarding tips. Verify it with
@@ -108,29 +292,50 @@ token, `id` in the body) are unique, so you can drop duplicates.
108
292
 
109
293
  ## Without the SDK
110
294
 
111
- **Events you send** are signed with your secret, over the exact request body:
295
+ **The API** is HTTPS and JSON, with HTTP Basic auth: your key id as the
296
+ username, your secret as the password.
112
297
 
113
- ```
114
- X-YouGrow-Key-Id: <key id>
115
- X-YouGrow-Timestamp: <unix seconds>
116
- X-YouGrow-Signature: v1=<hex HMAC-SHA256(secret, "events:<timestamp>.<raw body>")>
298
+ ```sh
299
+ curl -X PATCH "https://yougrow.ai/api/v2/users/u_123" \
300
+ -u "$YOUGROW_KEY_ID:$YOUGROW_SECRET" \
301
+ -H "content-type: application/json" \
302
+ -d '{"email":"alex@example.com","signedUpAt":"2026-09-25T10:00:00Z","consent":"soft_opt_in"}'
117
303
  ```
118
304
 
119
- Requests more than five minutes out are refused.
305
+ `PATCH`, `GET` and `DELETE /api/v2/users/{userId}` (URL-encode the id),
306
+ `POST /api/v2/users/batch` with `{ "users": [{ "userId": "…", …patch }] }`
307
+ (1–100 users), and `POST /api/v2/users/{userId}/events`. The full reference is
308
+ at https://yougrow.ai/developers.
120
309
 
121
310
  **Requests YouGrow sends you** carry a JWT. Use any JWT library, then check:
122
311
 
123
312
  1. `alg` is `ES256` (reject anything else, including `none`). Verify the
124
- signature with the key from `<issuer>/.well-known/jwks.json` whose `kid`
313
+ signature with the key from `<origin>/.well-known/jwks.json` whose `kid`
125
314
  matches. Cache that file per its `Cache-Control`.
126
- 2. `iss` is `https://yougrow.ai`, and `aud` is your key id.
315
+ 2. `iss` is YouGrow's origin (`https://yougrow.ai` unless you're connected to
316
+ another instance), and `aud` is your key id.
127
317
  3. `dir` is `context` or `webhook`, matching the endpoint that received it.
128
318
  4. `exp` hasn't passed, allowing about 60 s of clock skew. `exp − iat` is at
129
319
  most 300.
130
320
  5. `body_sha256` is the base64url SHA-256 of the raw body you received.
131
321
 
132
- `test/vectors.json` (included in this package) holds reference signatures and
133
- tokens for checking your own implementation.
322
+ `test/vectors.json` (included in this package) holds reference tokens for
323
+ checking your own verifier.
324
+
325
+ ## Upgrading from 0.1.x
326
+
327
+ | 0.1.x (API v1) | 0.3.0 (API v2) |
328
+ |---|---|
329
+ | `identify({ userId, traits, consent })` | `users.update(userId, { email, firstName, …, consent, traits })`: profile fields are top-level, and `consent` is the basis itself, e.g. `"soft_opt_in"` |
330
+ | `track({ event: "user.signed_up" })` | `signedUpAt` in `users.update` |
331
+ | `stepCompleted(userId, step)`, `onboarding.completed` | `steps: { [step]: doneAt }` in `users.update` |
332
+ | `email_preferences.updated` | `subscribed` in `users.update` |
333
+ | `user.deleted` | `users.delete(userId)` |
334
+ | Any other `track()` | `events.track(userId, event, { properties })` |
335
+ | `flush()`, `close()`, `flushAt`, `flushIntervalMs`, `onError` | Gone with the queue: every method is awaited, and rejects if it fails |
336
+ | `sign`, `HEADERS`, `endpoint` | Gone: requests use HTTP Basic auth, to paths under `origin` |
337
+
338
+ `@yougrowai/node/server` (`createVerifier`, `contextResponse`) is unchanged.
134
339
 
135
340
  ## Credentials
136
341
 
@@ -138,3 +343,7 @@ Create a connection in YouGrow (**Products → Connect a product**) to get a key
138
343
  id and a secret. Store them in your server's environment or secret manager as
139
344
  `YOUGROW_KEY_ID` and `YOUGROW_SECRET`. Use a separate connection, with its own
140
345
  key and secret, for each environment (e.g. staging and production).
346
+
347
+ If your connection is on a YouGrow instance other than https://yougrow.ai (e.g.
348
+ a staging or self-hosted one), also set `YOUGROW_ORIGIN` to its origin, and
349
+ pass it as `origin` to both `new YouGrow()` and `createVerifier()`.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * @yougrowai/node — keep your users' state in YouGrow lifecycle journeys (API v2).
3
+ *
4
+ * const yg = new YouGrow({ keyId: process.env.YOUGROW_KEY_ID!, secret: process.env.YOUGROW_SECRET! });
5
+ * await yg.users.update(user.id, { email: user.email, signedUpAt: user.createdAt.toISOString(), consent: "soft_opt_in" });
6
+ *
7
+ * Server-side only: the secret authenticates every request (HTTP Basic). Each
8
+ * method resolves once its request is done (a batch's, one per 100 users), with
9
+ * nothing queued or sent in the background, so it's safe in serverless
10
+ * functions. A request is abandoned after `timeoutMs` and retried with capped,
11
+ * jittered backoff on network errors, timeouts, 429 and 5xx; any other error
12
+ * status throws a YouGrowError straight away.
13
+ */
14
+ export type ConsentBasis = "consent" | "soft_opt_in" | "corporate_subscriber" | "none";
15
+ /**
16
+ * Any subset of a user's state, as a JSON Merge Patch (RFC 7396): fields sent
17
+ * replace YouGrow's, fields left out stay, `null` clears, and `steps`, `facts`
18
+ * and `traits` merge key by key. Timestamps are ISO 8601 with a zone, e.g.
19
+ * `new Date().toISOString()`.
20
+ */
21
+ export interface UserPatch {
22
+ email?: string | null;
23
+ firstName?: string | null;
24
+ lastName?: string | null;
25
+ /** An IANA time zone, e.g. "Europe/London". */
26
+ timezone?: string | null;
27
+ /** A BCP 47 locale, e.g. "en-GB". */
28
+ locale?: string | null;
29
+ /** When the account was created. Starts sign-up journeys while inside their window. */
30
+ signedUpAt?: string;
31
+ /** The legal basis for marketing email. */
32
+ consent?: ConsentBasis | null;
33
+ /** false = the person opted out of lifecycle email in your product. */
34
+ subscribed?: boolean;
35
+ /** Never email this person, in any journey (staff, test accounts, invited teammates). */
36
+ excluded?: {
37
+ reason: string;
38
+ } | null;
39
+ /** Onboarding step id → when it was done (null: not done). */
40
+ steps?: Record<string, string | null>;
41
+ /** Fact id → its latest value (null removes it). */
42
+ facts?: Record<string, number | string | boolean | null>;
43
+ /** Anything else journeys branch on (null removes a key). */
44
+ traits?: Record<string, string | number | boolean | null>;
45
+ /** When you read this state. A write older than the stored one is ignored. */
46
+ updatedAt?: string;
47
+ }
48
+ /** A user's state as YouGrow holds it. */
49
+ export interface UserState {
50
+ userId: string;
51
+ email: string | null;
52
+ firstName: string | null;
53
+ lastName: string | null;
54
+ timezone: string | null;
55
+ locale: string | null;
56
+ signedUpAt: string | null;
57
+ consent: ConsentBasis | null;
58
+ subscribed: boolean;
59
+ excluded: {
60
+ reason: string;
61
+ } | null;
62
+ steps: Record<string, string>;
63
+ facts: Record<string, string | number | boolean>;
64
+ traits: Record<string, string | number | boolean>;
65
+ updatedAt: string | null;
66
+ }
67
+ /** `users.get` adds what YouGrow decided: journeys and opt-outs. */
68
+ export interface UserView extends UserState {
69
+ enrolments: Array<{
70
+ journeyId: string;
71
+ status: string;
72
+ mode: string;
73
+ enrolledAt: string;
74
+ }>;
75
+ /** Unsubscribes made in YouGrow's emails. They hold until the person lifts them; the API can't. */
76
+ optOuts: Array<{
77
+ scope: "all" | "category";
78
+ category: string | null;
79
+ at: string | null;
80
+ }>;
81
+ }
82
+ /** Why a write was ignored: older than the stored state, or than the user's deletion. */
83
+ export type SkipReason = "stale_write" | "deleted_later";
84
+ export type PatchResponse = {
85
+ applied: true;
86
+ user: UserState;
87
+ } | {
88
+ applied: false;
89
+ reason: SkipReason;
90
+ storedUpdatedAt?: string | null;
91
+ user?: UserState;
92
+ };
93
+ /** What was wrong with one field, e.g. `{ path: "traits.plan", message: "…" }`. */
94
+ export interface FieldError {
95
+ path: string;
96
+ message: string;
97
+ }
98
+ /** One user in `users.batch`: their id plus a patch. */
99
+ export interface BatchItem extends UserPatch {
100
+ userId: string;
101
+ }
102
+ export interface BatchResponse {
103
+ applied: number;
104
+ ignored: number;
105
+ failed: number;
106
+ /** Only the ignored and failed items; applied ones are counted. `index` is the item's position in your array. */
107
+ results: Array<{
108
+ index: number;
109
+ userId: string | null;
110
+ status: "ignored" | "failed";
111
+ reason: string;
112
+ fields?: FieldError[];
113
+ }>;
114
+ }
115
+ export interface EventResult {
116
+ recorded: boolean;
117
+ duplicate: boolean;
118
+ }
119
+ export interface YouGrowOptions {
120
+ /** Your connection's key id (`ygk_…`). */
121
+ keyId: string;
122
+ /** Its secret (`ygs_…`). Server-side only. */
123
+ secret: string;
124
+ /**
125
+ * YouGrow's origin, e.g. from YOUGROW_ORIGIN. Defaults to https://yougrow.ai;
126
+ * set it when you're connected to another YouGrow instance (staging, self-hosted).
127
+ */
128
+ origin?: string;
129
+ /** Abandon a request after this long (default 10000); it's retried like a network error. */
130
+ timeoutMs?: number;
131
+ /** Retries per request for network errors, timeouts, 429 and 5xx (default 3). */
132
+ maxRetries?: number;
133
+ /** Longest wait between retries (default 5000), even when Retry-After asks for more. */
134
+ maxRetryWaitMs?: number;
135
+ /** Custom fetch (tests, proxies). */
136
+ fetch?: typeof fetch;
137
+ }
138
+ export interface BatchOptions {
139
+ /** Don't log the console.warn line about failed items. */
140
+ quiet?: boolean;
141
+ /** Throw a YouGrowBatchError, carrying the result, if any item failed. Every item is still sent. */
142
+ throwOnItemError?: boolean;
143
+ }
144
+ export interface TrackOptions {
145
+ properties?: Record<string, unknown>;
146
+ /** When it happened (ISO 8601 with a zone). */
147
+ occurredAt?: string;
148
+ /** The same key is recorded once. Defaults to a random key per call, so the SDK's own retries never count twice. */
149
+ idempotencyKey?: string;
150
+ }
151
+ export interface UsersApi {
152
+ /** Merge a patch into one user's state; creates the user if YouGrow hasn't seen them. */
153
+ update(userId: string, patch: UserPatch): Promise<PatchResponse>;
154
+ /**
155
+ * Patch any number of users, 100 per request, one request after another. One
156
+ * bad item never fails the rest: the result lists the ignored and failed ones,
157
+ * and failures are logged in one console.warn line (see BatchOptions).
158
+ */
159
+ batch(items: readonly BatchItem[], opts?: BatchOptions): Promise<BatchResponse>;
160
+ /** The user's state, journeys and opt-outs, or null if YouGrow doesn't know them. */
161
+ get(userId: string): Promise<UserView | null>;
162
+ /** Erase the user and their history. Safe to repeat, and for users YouGrow never saw. */
163
+ delete(userId: string): Promise<void>;
164
+ }
165
+ export interface EventsApi {
166
+ /** Record a milestone, e.g. "report.exported". Optional: journeys run on state. */
167
+ track(userId: string, event: string, opts?: TrackOptions): Promise<EventResult>;
168
+ }
169
+ /** The API refused a request, or still failed (429, 5xx) after the retries. */
170
+ export declare class YouGrowError extends Error {
171
+ readonly status: number;
172
+ readonly body?: unknown | undefined;
173
+ /** The API's error code (`invalid`, `unauthorized`, `rate_limited`…), or `http_<status>` when the body has none. */
174
+ readonly code: string;
175
+ /** For a 400: which fields were wrong, and why. */
176
+ readonly fields?: FieldError[];
177
+ constructor(message: string, status: number, body?: unknown | undefined);
178
+ }
179
+ /** From `users.batch` with `throwOnItemError`: some items failed. The rest were applied; `result` says which. */
180
+ export declare class YouGrowBatchError extends Error {
181
+ readonly result: BatchResponse;
182
+ constructor(message: string, result: BatchResponse);
183
+ }
184
+ export declare class YouGrow {
185
+ #private;
186
+ readonly users: UsersApi;
187
+ readonly events: EventsApi;
188
+ constructor(opts: YouGrowOptions);
189
+ }