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