@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 +264 -55
- package/dist/cjs/index.d.ts +189 -0
- package/dist/cjs/index.js +246 -0
- package/dist/cjs/jwt.d.ts +54 -0
- package/dist/cjs/jwt.js +80 -0
- package/dist/cjs/origin.d.ts +11 -0
- package/dist/cjs/origin.js +20 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/server.d.ts +121 -0
- package/dist/cjs/server.js +140 -0
- package/dist/index.d.ts +164 -64
- package/dist/index.js +205 -121
- package/dist/jwt.d.ts +3 -2
- package/dist/jwt.js +2 -1
- package/dist/origin.d.ts +11 -0
- package/dist/origin.js +15 -0
- package/dist/server.d.ts +17 -4
- package/dist/server.js +19 -5
- package/package.json +18 -9
- package/test/vectors.json +1 -24
- package/dist/signing.d.ts +0 -19
- package/dist/signing.js +0 -10
package/README.md
CHANGED
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
# @yougrowai/node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
29
|
-
yg.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
//
|
|
37
|
-
yg.
|
|
60
|
+
// They turned lifecycle email off in your product's settings
|
|
61
|
+
await yg.users.update(user.id, { subscribed: false });
|
|
38
62
|
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
###
|
|
74
|
+
### A scheduled sync
|
|
47
75
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
milestones.
|
|
168
|
+
## Errors, retries and timeouts
|
|
58
169
|
|
|
59
|
-
|
|
170
|
+
Every method returns a promise; await it.
|
|
60
171
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 `<
|
|
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
|
|
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
|
|
133
|
-
|
|
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
|
+
}
|