# Connect your product Lifecycle journeys email each of your users based on where they are in your product — nudging people who haven't finished getting started, and teaching the ones who have. To do that, your server tells YouGrow about each user, and YouGrow does the rest. > **Removed on 2026-09-25:** API v1 (`POST /api/v1/events` and its HMAC signing). Use API v2 — [sending users](https://yougrow.ai/developers/users.md). YouGrow is open source. To run it on your laptop, host your own copy or build on it with Claude Code or Cursor, see [Open source](https://yougrow.ai/developers/open-source.md). ## How it works **The parts of a connection** ``` Your product ── each user's state (HTTP Basic, your key) ──▶ PATCH https://yougrow.ai/api/v2/users/{userId} ◀── context request (signed by YouGrow) ────── optional: before an email, about one user ──── live steps, facts, insights ────────────▶ ◀── webhooks (signed by YouGrow) ───────────── optional: e.g. "this user unsubscribed" ``` - **Your users' state** — your server tells us about each user: who they are, when they signed up, which onboarding steps they've done, facts about their account, and whether they've opted out. You send their current state; we keep it, and journeys decide who gets which email, and when. [Sending users →](https://yougrow.ai/developers/users.md) - **The context endpoint** — how an email tells someone what their own results mean. Just before we email them, we ask your server for true sentences about their numbers, and it can hold or stop the email. [Context endpoint →](https://yougrow.ai/developers/context-endpoint.md) - **Webhooks (optional)** — we tell your server about changes it should mirror, like an unsubscribe. [Webhooks →](https://yougrow.ai/developers/webhooks.md) ## Getting started 1. In YouGrow, go to **Products → Connect a product**. You get a **key id** (public) and a **secret** (shown once — store it in your server's secret manager, never in code or the browser). 2. Recommended: open **Learn from repo** on the connection and connect GitHub or GitLab — read-only, about a minute ([how connecting works](https://yougrow.ai/developers/connect-your-code.md)). We read your code and propose your catalog: your onboarding steps and how each is done, and the facts you can report. You review and keep what's right. Your connection's **Integration guide** then lists exactly what to build, in priority order and pointing at your own files — with a **Copy prompt** button that hands the whole job to your coding agent (Claude Code, Cursor, …). 3. When someone signs up, send their state: `PATCH /api/v2/users/{userId}` with `signedUpAt`, `email`, `firstName`, `timezone` and `consent`. Add `steps` as they get started. Watch the writes arrive in the connection's **Events** tab. 4. If your emails should tell people what their results mean, build a context endpoint, add its URL in the connection's **Settings**, and press **Test connection**. 5. Build a journey, publish it in **test** mode (only your listed test users get email), then shadow, then live. > Use separate connections for separate environments — for example *Acme (staging)* for your staging app and *Acme (production)* for your live one. Each has its own key and secret. Once a journey works on staging, **Copy to…** puts it on production as a draft. ## The Node SDK [`@yougrowai/node`](https://www.npmjs.com/package/@yougrowai/node) sends your users' state, retries what's safe to retry, splits big batches, and verifies our requests for your endpoints. Node 18 or later, no dependencies, from ES modules (`import`) or CommonJS (`require`). Version 0.3.0 is the first for API v2 — earlier versions sent v1 events. Every part of the API is documented here too, so any language works. **Install** ``` npm install @yougrowai/node ``` **Node** ``` import { YouGrow } from "@yougrowai/node"; // or: const { YouGrow } = require("@yougrowai/node"); import { createVerifier, contextResponse } from "@yougrowai/node/server"; const yg = new YouGrow({ keyId: process.env.YOUGROW_KEY_ID!, secret: process.env.YOUGROW_SECRET! }); await yg.users.update("user_123", { signedUpAt: new Date().toISOString(), email: "alex@example.com" }); await yg.users.batch([{ userId: "user_123", steps: { create_project: new Date().toISOString() } }]); // any number const alex = await yg.users.get("user_123"); // null if YouGrow doesn't hold them await yg.users.delete("user_123"); await yg.events.track("user_123", "report.exported"); // For your context endpoint and webhooks: const verifier = createVerifier({ keyId: process.env.YOUGROW_KEY_ID! }); ``` Every call is awaited and sent straight away — there's no queue, so nothing is lost when a serverless function returns. The SDK needs a Node runtime. On an edge runtime (Vercel Edge Functions, Next.js middleware, Cloudflare Workers), call the API with `fetch` — it's plain HTTPS with Basic auth. ## For coding agents Handing the integration to Claude Code, Cursor or another coding agent? Everything here is also plain Markdown, and the API is machine-readable: - [llms.txt](https://yougrow.ai/llms.txt) is the index, and [llms-full.txt](https://yougrow.ai/developers/llms-full.txt) has every page in one file. Each page also has a `.md` twin, e.g. [/developers/users.md](https://yougrow.ai/developers/users.md). - The OpenAPI 3.1 spec, [/developers/openapi.json](https://yougrow.ai/developers/openapi.json): every endpoint, field, response and error, plus what we send you. People can browse it in the [API reference](https://yougrow.ai/developers/api). - JSON Schemas, generated from the validators our API uses: [user patch](https://yougrow.ai/developers/schema/user-patch.json), [batch request](https://yougrow.ai/developers/schema/batch-request.json), [event](https://yougrow.ai/developers/schema/event.json), [user](https://yougrow.ai/developers/schema/user.json), [patch response](https://yougrow.ai/developers/schema/patch-response.json), [batch response](https://yougrow.ai/developers/schema/batch-response.json), [context request](https://yougrow.ai/developers/schema/context-request.json), [context response](https://yougrow.ai/developers/schema/context-response.json), [webhook](https://yougrow.ai/developers/schema/webhook.json). - [Test vectors](https://yougrow.ai/developers/test-vectors.json) for verifying our tokens in any language, and our public keys at `https://yougrow.ai/.well-known/jwks.json`. - Working on YouGrow itself, or hosting your own copy? Start at [Open source](https://yougrow.ai/developers/open-source.md): one prompt gets it running on your laptop. > The contract is these docs, the OpenAPI spec, the schemas and the published `@yougrowai/node` package (0.3.0 and later). YouGrow's own source code isn't: its main branch can be ahead of what's deployed. --- # Connecting your code YouGrow reads your product's code to learn how it works — where people sign up, what getting started means, which numbers it can report about each user — so setting up lifecycle email takes minutes, not a project. Access is **read-only**, limited to the repositories you choose, and you can remove it at any time. ## What YouGrow can and can't do | Field | Notes | | --- | --- | | `Can` | Read the code in the repositories you choose, when you ask it to learn from them. | | `Can't` | Change anything: no pushing code, no pull requests, issues, comments or settings. GitHub enforces this — the app is only granted read access. | | `Can't` | See any repository you haven't chosen. | | `Keeps` | A short list of findings, each with a few lines of code as evidence. Secrets and keys are removed before anything is read or stored. The copy of your code is deleted as soon as the analysis finishes. | | `Tokens` | We store no password or long-lived token for GitHub. Each analysis uses a read-only access token that expires within an hour. | ## Connect GitHub (about a minute) 1. In YouGrow, open **Products → your product → Learn from repo** and press **Connect GitHub** (or use **Account → Connections**). 2. GitHub shows the **YouGrow** app ([github.com/apps/yougrowapp](https://github.com/apps/yougrowapp)) asking for **read access to code and metadata** — nothing else. 3. Choose where to install it: your personal account, or the organisation that owns your product's code. 4. Under **Repository access**, pick **Only select repositories** and choose your product's repos — for example your web app and your backend. (You can change this later.) 5. Press **Install**. You're returned to YouGrow, where your repositories are listed — tick them and press **Learn from repo**. ### If your organisation needs approval Only an organisation **owner** can install apps on an organisation. If you're a member, GitHub shows **Request** instead of Install — press it, and YouGrow tells you the request is on its way. Your owners get a notification from GitHub; once one approves it (**Organisation settings → GitHub Apps → Pending requests**), press **Connect GitHub** again in YouGrow to finish. Here's a message you can send them: **For your GitHub organisation owner** ``` Hi — we're setting up YouGrow for lifecycle email. It needs READ-ONLY access to our product's code so it can learn our sign-up and onboarding steps. Please approve the "YouGrow" GitHub app request (https://github.com/apps/yougrowapp). It asks only for read access to code and metadata, and only for the repositories we choose — it can't push, open pull requests or change settings. Details: https://yougrow.ai/developers/connect-your-code ``` ### Change which repositories YouGrow can read In YouGrow, use **Add or remove repositories on GitHub** (on Learn from repo, or Account → Connections). That opens GitHub's page for the app — choose **Configure**, change **Repository access**, and save. The change applies immediately; YouGrow's list refreshes when you come back. ### Disconnect - In YouGrow, **Account → Connections → Disconnect** removes the connection on our side. - To remove the app from GitHub entirely: **Settings → Applications → Installed GitHub Apps → YouGrow → Uninstall** (for an organisation, **Organisation settings → GitHub Apps**). YouGrow can no longer read anything from that moment. ## Connect GitLab 1. In YouGrow, open **Account → Connections** and press **Connect** next to GitLab. 2. GitLab asks you to authorise YouGrow with `read_repository` and `read_user` — both read-only. YouGrow can't push or change anything. 3. Back in YouGrow, on **Learn from repo**, add your project by its address. Only the projects you add are ever read. GitLab access follows your own project permissions, so there's no separate group approval. ## Public repositories Nothing to connect: on **Learn from repo**, choose **Add a repository by address** and paste it (for example `github.com/your-org/your-app`). ## Questions ### Why an app, not a token? A GitHub App is the only way GitHub lets you grant *read-only* access to *chosen* repositories, and it shows your organisation exactly what's granted. Personal access tokens and older OAuth apps can't be limited that way. ### Something isn't in the list The list shows exactly the repositories the app was granted. Add the missing one with **Add or remove repositories on GitHub**. > Learning from your code is always a proposal: you review each finding — with the code it came from — and choose what YouGrow keeps. Nothing changes until you do. --- # Sending users Your server tells YouGrow about each of your users: who they are, when they signed up, which onboarding steps they've done, and a few facts about their account. You send their current state and YouGrow keeps it. Journeys do the rest — who gets which email, and when. > **Only sign-ups are required** for a journey to run. Steps and facts make it personal. Your product's **Integration guide** (Products → your product) lists what to send from your own code, with file pointers — and a **Copy prompt** button that hands the whole job to your coding agent. ## Before you start 1. In YouGrow, **Products → Connect a product** gives you a **key id** (`ygk_…`, public) and a **secret** (`ygs_…`, shown once). 2. Put them in your **server's** environment or secret manager as `YOUGROW_KEY_ID` and `YOUGROW_SECRET`, with `YOUGROW_ORIGIN` set to `https://yougrow.ai`. Never in source control, a browser or a mobile app — anyone with the secret can change your users' state. 3. Call the API over HTTPS with HTTP Basic auth: the key id is the username and the secret is the password. There's nothing to sign. See [authentication](https://yougrow.ai/developers/security.md) for rotating the secret. ## One call: send the user's state `PATCH /api/v2/users/{userId}` with the fields you know. `userId` is **your** id for the user — use the same one everywhere. The first write for an id creates the user. **Request** ``` PATCH https://yougrow.ai/api/v2/users/user_123 Authorization: Basic Content-Type: application/json { "signedUpAt": "2026-09-25T09:30:00Z", "email": "alex@example.com", "firstName": "Alex", "timezone": "Europe/London", "consent": "soft_opt_in", "traits": { "plan": "pro" } } ``` The body is a JSON Merge Patch (RFC 7396): - Fields you send replace ours. Fields you leave out stay as they are. - `null` clears a field. - `steps`, `facts` and `traits` merge key by key: `{"traits": {"plan": "pro"}}` sets one trait and keeps the rest, and `{"traits": {"role": null}}` removes one. Sending the same state twice is harmless. So you can retry any write, and a scheduled job can send everything it knows every time it runs. ### With curl ``` curl -u "$YOUGROW_KEY_ID:$YOUGROW_SECRET" -X PATCH "https://yougrow.ai/api/v2/users/user_123" \ -H "Content-Type: application/json" \ -d '{"signedUpAt": "2026-09-25T09:30:00Z", "email": "alex@example.com", "firstName": "Alex"}' ``` ### With the Node SDK `@yougrowai/node` 0.3.0 and later (0.5.0 for `entities`): it handles the auth, retries network errors, `429` and `5xx` responses, and splits big batches. It works from ES modules and CommonJS (`require`). **yougrow.ts** ``` // npm install @yougrowai/node import { YouGrow } from "@yougrowai/node"; // Once, at module scope. export const yg = new YouGrow({ keyId: process.env.YOUGROW_KEY_ID!, secret: process.env.YOUGROW_SECRET! }); // Anywhere on your server: await yg.users.update("user_123", { email: "alex@example.com", firstName: "Alex" }); ``` ### With Python **yougrow.py** ``` import os from urllib.parse import quote import requests ORIGIN = os.environ["YOUGROW_ORIGIN"] # https://yougrow.ai AUTH = (os.environ["YOUGROW_KEY_ID"], os.environ["YOUGROW_SECRET"]) def update_user(user_id, patch): url = f"{ORIGIN}/api/v2/users/{quote(user_id, safe='')}" r = requests.patch(url, json=patch, auth=AUTH, timeout=10) r.raise_for_status() return r.json() ``` ### Without the SDK (any runtime with fetch) **yougrow.ts** ``` const AUTH = "Basic " + btoa(`${process.env.YOUGROW_KEY_ID}:${process.env.YOUGROW_SECRET}`); export async function updateUser(userId: string, patch: object) { const res = await fetch(`${process.env.YOUGROW_ORIGIN}/api/v2/users/${encodeURIComponent(userId)}`, { method: "PATCH", headers: { authorization: AUTH, "content-type": "application/json" }, body: JSON.stringify(patch), signal: AbortSignal.timeout(10_000), }); if (!res.ok) throw new Error(`YouGrow ${res.status}: ${await res.text()}`); return res.json(); // { applied, user } — see "Responses" } ``` ## 1. Sign-ups — required ### What When someone creates an account, send `signedUpAt` and who they are: `email`, `firstName`, `timezone` and `consent`. ### Where in your code The one place your server creates a new user — **after** the account exists. Wherever that is in your stack, for example: - right after the insert in your own sign-up / registration handler; - a Firebase Auth `onCreate` trigger, a Supabase or database trigger on your users table, an Auth0 post-registration action, NextAuth's `createUser` event, or a Django `post_save` signal on your user model. Not in the browser. Once per new account is enough, and sending it again is harmless. ### How **In your sign-up handler (Node)** ``` import { yg } from "./yougrow"; // after the user is created… try { await yg.users.update(user.id, { // YOUR id for this user — use it everywhere signedUpAt: user.createdAt.toISOString(), email: user.email, firstName: user.firstName, timezone: user.timezone, // IANA name, e.g. "Europe/London" — see below consent: "soft_opt_in", // your legal basis for marketing email traits: { plan: user.plan }, // anything else journeys can branch on }); } catch (err) { console.error("YouGrow sign-up failed", err); // never block sign-up } ``` **In a serverless function (e.g. Firebase Auth onCreate)** ``` const yg = new YouGrow({ keyId: process.env.YOUGROW_KEY_ID!, secret: process.env.YOUGROW_SECRET! }); export const yougrowSignup = functions.auth.user().onCreate(async (user) => { await yg.users.update(user.uid, { signedUpAt: new Date(user.metadata.creationTime).toISOString(), email: user.email ?? null, firstName: user.displayName?.split(" ")[0] ?? null, }); // awaited: nothing is left running when the function returns }); ``` ### Timezone Emails go out in each person's morning, so send their IANA timezone (e.g. `America/New_York`). If you don't store one, capture it in the browser at sign-up with `Intl.DateTimeFormat().resolvedOptions().timeZone` and pass it to your server. Without it we use your connection's default timezone. ### What happens on our side We create the person's profile, and `signedUpAt` enrols them in every published journey that starts on sign-up — as long as they're inside the journey's sign-up window (72 hours by default), so importing past users won't email them all. We check on every write, so a retry or a late write still enrols them while they're inside the window; and when a journey goes live, we enrol everyone still inside it ourselves (see test mode). People who are `excluded`, or have `subscribed: false`, never enrol. ### Check it works Sign up a test account, then open **Products → your product → Events**: the write appears within seconds. A refused write gets a `400` that says what was wrong. Then look the account up in **Users** by your user id, or read it back with GET. ### Invited from a waitlist (optional) When you invite your waitlist into your product, each invite link lands on your sign-up page with `?yg_invite=…` added. We already match sign-ups to invites by email. If you keep that value through sign-up and send it back as a trait (`traits: { yg_invite: "…" }`), the match also works when someone signs up with a different email. The value is an opaque code: it carries no personal data and grants nothing on its own. Keeping it through sign-up can mean storing it in the browser, and your consent rules may treat that as a cookie that needs consent. You can carry it in the URL instead, through your sign-in redirect, or skip it: matching by email still works. People can edit it — a code we don't know is ignored, and at worst an edited one credits the wrong invite in your invite numbers. ## 2. Onboarding steps and facts — personalisation ### What - `steps` — which onboarding steps each person has done, and when. Use the exact ids from your product's **Catalog** (e.g. `create_project`, `invite_team`). A step you haven't sent isn't done; `null` marks one not done again. - `facts` — numbers (or short values) about their account that journeys can branch on and emails can mention, e.g. `"projects": 3`. Send the latest value; `null` removes one. If someone doesn't have one — a feature they haven't set up, a score that isn't measured yet — leave it out rather than sending `0`: an email that needs it is then skipped for them instead of saying something untrue. - `traits` — anything else journeys branch on, such as `plan` or `company`. ``` { "steps": { "create_project": "2026-09-25T10:02:00Z", "invite_team": null }, "facts": { "projects": 3 }, "traits": { "plan": "pro" } } ``` ### Dates A fact can be a date: when someone was last active, when their trial ends. Send it as text — ISO 8601 with a timezone, or a plain day — and set the fact's type to **date** in your product's Catalog (Products → your product → Catalog → Facts). Until it's marked as a date it is only text to a journey. ``` { "facts": { "last_active_at": "2026-10-08T09:12:00Z", "trial_ends_on": "2026-11-01" } } ``` - **Send the date itself, never a count of days.** Journeys read it as whole days since, or until, worked out at the moment they check, so the value never goes stale between your writes. - **Send it once for everyone you already hold, then whenever it changes.** Someone who has gone quiet has a date that never changes, so without that first send we'd never have one for them. A daily re-sync covers both. - **Leave it out for someone who has no such date** (never signed in, no trial). They're then left alone, rather than treated as if it happened long ago. - A value that isn't a date is left as it was and listed in `ignoredFields`, like an invalid profile field; the rest of the write applies. A journey can start from a date (“last active 14 or more days ago”): we check everyone's stored date once a day, and stop the journey for someone whose date has moved on. To have a same-day return noticed before the next email, also return the fact from your [context endpoint](https://yougrow.ai/developers/context-endpoint.md). Name the date someone last used your product `last_active_at`, a date kept per person. Audience then shows it as their **Last active** and marks them quiet once it is 14 days old. Without it we only know when you last sent us their state, which says nothing about whether they came back. ### Where in your code — two ways Your Integration guide says which fits each step: **A. At a clear server moment** — the step is a thing your server does: a project is created, a job finishes, an integration is connected. Add the call right after it succeeds: **e.g. in your create-project handler** ``` await db.projects.insert(project); await yg.users .update(user.id, { steps: { create_project: project.createdAt.toISOString() }, facts: { projects: await db.projects.count(user.id) }, }) .catch((err) => console.error("YouGrow update failed", err)); ``` **B. From a scheduled sync** — the step is a *state* rather than a moment (“has at least 3 competitors”, “has invited a teammate”), or it's only worked out in the browser today. Run a small job every 15 minutes or so that reads recent users from your database and sends what's true now. Sending the same state again is harmless, so it can run as often as you like: **e.g. a job every 15 minutes** ``` const readAt = new Date().toISOString(); // before reading, so a newer write wins const users = await db.users.createdInLastDays(14); const items = []; for (const u of users) { const invite = await db.invites.firstSentBy(u.id); // null until they've invited someone items.push({ userId: u.id, steps: { invite_team: invite ? invite.createdAt.toISOString() : null }, facts: { teammates: await db.members.count(u.teamId) }, updatedAt: readAt, // see "Out-of-order writes" }); } await yg.users.batch(items); // any number: the SDK sends 100 per request ``` A step's time is when it happened, not when your job ran: send the time you stored (such as the first invite's `createdAt`). ### What happens on our side We keep the steps and facts on the person's profile. Journeys branch on them — someone who's created a project is nudged towards the next step instead, and people who've finished get education instead of reminders — and the checklist in your emails ticks them off. ### And the context endpoint? Journeys run on the state you send, and a template can print a fact you've sent. What only a [context endpoint](https://yougrow.ai/developers/context-endpoint.md) can do: supply insight sentences — what someone's own results mean, in your words, the only way an email says it — and hold or stop an email at the moment it would go. Build it if your emails should show people their results; skip it only if they'll only ever nudge steps. Use the same step and fact ids in both. ## When people have several of something If one person can have several workspaces, brands or projects, send them all as `entities`, by your own id. Each has a `kind` (your word for it — required the first time), a `name`, the `parentId` it sits inside, the person's `role` (`owner`, `member` or `invited`), and its own `steps` and `facts`. Each merges like the user, and `null` removes one. ``` await yg.users.update(user.id, { entities: { brand_123: { kind: "brand", name: "Acme", parentId: "ws_9", role: "owner", steps: { run_audit: audit.finishedAt } }, brand_456: { kind: "brand", name: "Beta", role: "owner", facts: { share_of_voice: 12 } }, brand_old: null, // deleted in your product }, }); await yg.events.track(user.id, "audit.completed", { entityId: "brand_123" }); ``` - Send every one, not the one you think matters: each journey chooses which of them its emails are about — the one they're setting up, the one a milestone happened to, all of them in one email, or each separately. - Someone counts as onboarded once any they own is finished. A half-done second one doesn't undo that, and onboarding emails follow the one they're furthest along with. - Only what they own counts toward their onboarding. Ones they belong to or were invited to are there for journeys that include them. - A milestone about one of them takes its `entityId`. A new one fires `entity.created`, which a journey can start from. - Name the kinds once in YouGrow — **Products → your product → Catalog** — or let Learn from repo find them. Up to 50 per user. ## 3. Consent, opt-outs, exclusions and deletion — compliance ``` await yg.users.update(user.id, { consent: "consent" }); // they ticked "send me tips" await yg.users.update(user.id, { subscribed: false }); // opted out in your settings await yg.users.update(user.id, { excluded: { reason: "staff" } }); // never email this person await yg.users.delete(user.id); // the account is erased ``` ### Consent: consent Your legal basis for marketing email: `consent` (they opted in), `soft_opt_in` (an existing customer, told at sign-up and given a way to refuse), `corporate_subscriber` (a business address) or `none`. Send it at sign-up, and again if it changes. Service emails, like a welcome, don't need one; marketing emails do. `corporate_subscriber` counts as `none` for free-mail addresses (gmail.com, …). ### Service and marketing email Each email in a journey is **service** (a welcome, help getting started: no consent needed) or **marketing** (it needs a basis your connection accepts: `consent`, `soft_opt_in` or `corporate_subscriber` by default). We check when each email is due. Without consent, a marketing email is skipped, not held, so someone who opts in later never gets a stale one. - A sequence that's all marketing can admit **only people with marketing consent** (a journey setting). It uses the `consent` you send, and someone who opts in while its trigger is still recent joins then, from the start. - For people who opt in later, start a sequence on **Marketing consent granted**. It fires when a write moves someone we already hold onto a basis your connection accepts, once their sign-up window has passed (your longest sign-up journey's, 72 hours by default). Consent given during sign-up — with the first write, or minutes later when your first write comes before your consent step — is for the sign-up journeys, which still take them, so nobody gets both. A backfill never fires it. ### Opt-outs: subscribed When someone turns off this kind of email in your product's settings, send `"subscribed": false`. They get no more lifecycle email, and no new journey starts for them. Send `true` when they turn it back on. `true` only lifts **your** opt-out. When someone unsubscribes from one of **our** emails, the API can't lift it, and `subscribed: true` doesn't either. You'll see those as `optOuts` when you read the user back, and a [webhook](https://yougrow.ai/developers/webhooks.md) tells your server when one happens. ### Exclusions: excluded For people who must never get lifecycle email — staff, test accounts, invited teammates — send `"excluded": {"reason": "staff"}`. It applies to every journey straight away: a journey they're in stops before its next email. Clearing it (`"excluded": null`): - never puts them back into a journey they left; - enrols them in a sign-up journey they never joined, if they're still inside its window — the write that clears it enrols them, with the journey timed from their `signedUpAt`; - otherwise lets them into journeys that start from then on. If what decides it lives outside the record your sync watches — a membership, an invite — your daily re-sync is what sends the change. ### Deletion: DELETE When an account is erased (after any grace period you have), send `DELETE /api/v2/users/{userId}`. It always answers `204`, even for a user we never saw, so it's safe to repeat. While deletion is pending, set `excluded` (e.g. `{"reason": "deletion_pending"}`) so they get no more email in the meantime. - **Erased:** the profile, its events, journey progress, AI drafts and invite links. - **Kept:** opt-outs, with the email address removed. A one-way hash still matches it, so someone who unsubscribed stays unsubscribed if they sign up again. - **For 30 days:** a tombstone holding only a one-way hash of your connection and user ids, and when they were deleted. A later write for the same id starts a fresh person — unless its `updatedAt` is older than the deletion. That write is ignored (`deleted_later`), so a slow sync can't bring back someone you erased. ## Milestones (optional) Journeys run on state, so most products never need events. When a moment matters in itself — a report exported, a first invoice paid — you can record it as a milestone, and a journey can start from it or branch on it. ``` POST https://yougrow.ai/api/v2/users/user_123/events { "event": "report.exported", "properties": { "format": "pdf" }, "occurredAt": "2026-09-25T10:15:00Z", "idempotencyKey": "export_8812" } 200 OK { "recorded": true, "duplicate": false } ``` **Node SDK** ``` await yg.events.track(user.id, "report.exported", { properties: { format: "pdf" } }); ``` - `event`: lower-case, dot-separated, ≤ 80 chars. `onboarding.completed` marks the user activated — for a product without a step checklist, or to say they're done. - Sign-ups, steps, consent, entities, opt-outs and deletion are state, so `user.signed_up`, `onboarding.step_completed`, `user.marketing_consent_granted`, `entity.created`, `email_preferences.updated` and `user.deleted` are refused: send `signedUpAt`, `steps`, `consent`, `entities` or `subscribed`, or DELETE the user. - `properties`: optional, ≤ 4 KB serialised. `occurredAt`: optional, when it happened (defaults to now). - `idempotencyKey`: optional. The same key is recorded once — a repeat answers `"duplicate": true`. The SDK sets a random one per call, so its own retries never count twice. - The user must exist: send their state first. An event for a user we don't hold gets `404`. ## Out-of-order writes: updatedAt If more than one place writes the same user — a sign-up handler and a scheduled sync, say — an older write can arrive after a newer one. Send `updatedAt`: when you read the state you're sending. We ignore a write older than the newest one we've applied, and say so: ``` 200 OK { "applied": false, "reason": "stale_write", "storedUpdatedAt": "2026-09-25T10:05:00.000Z", "user": { … } } ``` - That's a success, not an error: what we hold is newer. Don't retry it. - A write with the same `updatedAt` applies again, harmlessly. - A write without `updatedAt` always applies, and doesn't move the bar. If several places write the same fields, send it from all of them. ## Keeping YouGrow in sync Send a user's state whenever it changes, from wherever it changes. Three rules make that robust: 1. **Retry what can succeed later.** The SDK retries timeouts, network errors, `429` and `5xx` itself (3 times, waiting at most 5 s). If it still fails, `err.retryable` is `true`: in a queue worker, or a trigger with retries on, rethrow it so the work comes back. Log anything else: a `400` won't succeed as it is. 2. **Make repeats harmless.** Set `updatedAt` to when the change happened, e.g. the database write's time. A redelivered or out-of-order write is then ignored when something newer has arrived, and `DELETE` is always safe to repeat. 3. **Re-sync daily.** A small job that re-sends everyone's current state catches whatever a failure dropped, and state that changes without a write to the user, such as a snooze that ends or a membership your trigger doesn't watch. If you can't tell who changed, send everyone: resending the same state is harmless, and it isn't listed in your Events tab. An invalid `email`, `firstName`, `lastName`, `timezone` or `locale`, or a date fact whose value isn't a date, doesn't sink a write: the rest applies, and the response lists it in `ignoredFields`. Log it and fix the data. Any other invalid field is a `400`. **A Firestore trigger (Cloud Functions 2nd gen, retries on)** ``` import { onDocumentWritten } from "firebase-functions/v2/firestore"; import { YouGrow, YouGrowError } from "@yougrowai/node"; const yg = new YouGrow({ keyId: process.env.YOUGROW_KEY_ID!, secret: process.env.YOUGROW_SECRET! }); export const yougrowSync = onDocumentWritten({ document: "users/{uid}", retry: true }, async (event) => { const uid = event.params.uid; const after = event.data?.after.data(); try { if (!after) return void (await yg.users.delete(uid)); // the account was erased // stateOf: your mapping from the document to YouGrow's fields const r = await yg.users.update(uid, { ...stateOf(after), updatedAt: event.time }); if (r.applied && r.ignoredFields) console.warn("YouGrow left fields as they were", r.ignoredFields); } catch (err) { if (err instanceof YouGrowError && err.retryable) throw err; // redelivered later console.error("YouGrow refused the write", err); // fix the data; don't retry } }); ``` **A daily re-sync** ``` // Everyone's current state; the SDK sends 100 per request. const readAt = new Date().toISOString(); // before reading, so a newer write wins const users = await db.users.findAll(); await yg.users.batch(users.map((u) => ({ userId: u.id, ...stateOf(u), updatedAt: readAt }))); ``` ## Many users at once `POST /api/v2/users/batch` takes up to 100 users — for a scheduled sync or a first import. Each item is a `userId` plus the same fields as a PATCH, and each is applied on its own: one bad item never fails the rest. **Request** ``` POST https://yougrow.ai/api/v2/users/batch { "users": [ { "userId": "user_123", "steps": { "create_project": "2026-09-25T10:02:00Z" } }, { "userId": "user_456", "facts": { "projects": 0 }, "updatedAt": "2026-09-25T10:00:00Z" }, { "userId": "user_789", "timezone": "Mars/Olympus" }, { "userId": "user_999", "subscribed": "no" } ] } ``` **Response** ``` 200 OK { "applied": 2, "ignored": 1, "failed": 1, "results": [ { "index": 1, "userId": "user_456", "status": "ignored", "reason": "stale_write" }, { "index": 2, "userId": "user_789", "status": "applied", "reason": "fields_ignored", "fields": [{ "path": "timezone", "message": "not an IANA time zone, e.g. Europe/London" }] }, { "index": 3, "userId": "user_999", "status": "failed", "reason": "invalid", "fields": [{ "path": "subscribed", "message": "Invalid input: expected boolean, received string" }] } ] } ``` - `results` lists the items that weren't applied, and applied ones whose invalid profile fields were left as they were (`fields_ignored`); `index` is the item's position in your array. - `ignored` items need nothing (`stale_write` or `deleted_later`). `failed` ones weren't saved: `invalid` lists what was wrong in `fields` — fix and resend — and `internal_error` is safe to resend. - The request itself fails with `400` only when the body isn't `{"users": [...]}` with 1–100 items. - A batch counts as one request against your rate limit. - In the SDK, `yg.users.batch(items)` takes any number of users and sends them 100 at a time. It returns the combined result and logs failed items in one warning; pass `{ throwOnItemError: true }` to throw instead. ## Existing users Send the people who signed up before you connected, with their real `signedUpAt`, in batches of 100. A backfill can't send anyone an old welcome: sign-up journeys only enrol people whose `signedUpAt` is inside their window (72 hours by default, up to 30 days). Once we hold them, later sequences, such as a newsletter or an email on a milestone, can reach them. - Send `consent`, `subscribed` and `excluded` as well, so their choices hold from the start. - Send any date fact a journey starts from, such as when they were last active, for all of them too: a journey for people who've gone quiet only sees the dates you've sent. - A batch counts as one request: 600 a minute and 20,000 an hour per key. ## Reading a user back `GET /api/v2/users/{userId}` returns the state we hold, plus what YouGrow decided: the journeys they're in and any opt-outs. It answers `404` when we don't hold them — never sent, or deleted. In the SDK, `yg.users.get(id)` returns `null` then. ``` curl -u "$YOUGROW_KEY_ID:$YOUGROW_SECRET" "https://yougrow.ai/api/v2/users/user_123" { "userId": "user_123", "email": "alex@example.com", "firstName": "Alex", "lastName": null, "timezone": "Europe/London", "locale": "en-GB", "signedUpAt": "2026-09-25T09:30:00.000Z", "consent": "soft_opt_in", "subscribed": true, "excluded": null, "steps": { "create_project": "2026-09-25T10:02:00.000Z" }, "facts": { "projects": 1 }, "traits": { "plan": "pro" }, "updatedAt": "2026-09-25T10:05:00.000Z", "enrolments": [{ "journeyId": "lcj_4f8a2c", "status": "active", "mode": "live", "enrolledAt": "2026-09-25T09:30:05.000Z" }], "optOuts": [] } ``` - Timestamps come back in UTC. - `enrolments`: each journey they're in or have been through — its `status` (`active`, `completed` or `exited`) and `mode` (`test`, `shadow` or `live`). - `optOuts`: unsubscribes made in YouGrow's emails, e.g. `{"scope": "category", "category": "onboarding", "at": "…"}`. They hold whatever you send. ## Test mode and going live While a journey is in **test** mode, it enrols and emails only its listed test users. Add your team and test accounts in the journey's **Delivery** tab. Everyone else is left out, not held. When the journey goes live, or this YouGrow starts sending for real, we enrol everyone whose `signedUpAt` is still inside the journey's window. You don't resend anyone, and nobody outside the window gets a late welcome. ## Checking your integration - **Your key:** `yg.me()` (`GET /api/v2/me`) returns the connection it belongs to: its name, environment and status. Check it when you deploy, so a staging key never ends up in production. - **A user:** **Products → your product → Users → Look up** takes your user id and shows what we hold, the journeys they're in, their opt-outs and their latest writes — no secret needed. For 30 days after an erasure it shows when they were erased. From code, `yg.users.get(id)` returns the same state. - **In YouGrow:** **Products → your product → Events** lists writes as they arrive, including ones we ignored because we held something newer, erasures, and refused requests with the reason. Writes that changed nothing, such as a daily re-sync resending the same state, are counted there rather than listed. The **Users** tab shows each person's current state. ## Rules that keep it safe - **Server-side only.** The secret authenticates every request. - **Await every call.** In a serverless function (Cloud Functions, Lambda, Vercel), work left running after it returns may never finish. The SDK sends each call straight away — there's no queue to flush. - **Never block your own flows.** Catch and log errors: if YouGrow is unreachable, your user should still sign up. Writes are idempotent, so a retry later is safe. - **Retry what's worth retrying:** network errors, `429` (after `Retry-After`) and `5xx`, with backoff. Don't retry a `400` — fix the payload. The SDK retries for you (3 times by default), then `err.retryable` says whether to try again later. - **Timestamps include a timezone:** `2026-09-25T10:00:00Z`, or `new Date().toISOString()`. - **The SDK needs a Node runtime.** On an edge runtime (Vercel Edge Functions, Next.js middleware, Cloudflare Workers), call the API with `fetch` as above — it's plain HTTPS. ## Reference ### Fields The body of a PATCH, and of each batch item (plus `userId`). Every field is optional. | Field | Type | Notes | | --- | --- | --- | | `email` | `string \| null` | Their email address, ≤ 254 chars. We can't email anyone without one. An invalid value is left as it was and listed in `ignoredFields`, not a 400. | | `firstName` | `string \| null` | Used in greetings (falls back to “there”), ≤ 100 chars. An invalid value is left as it was and listed in `ignoredFields`, not a 400. | | `lastName` | `string \| null` | ≤ 100 chars. An invalid value is left as it was and listed in `ignoredFields`, not a 400. | | `timezone` | `string \| null` | An IANA time zone, e.g. `Europe/London`. Emails go out in each person's morning; without one, in your connection's default time zone. An invalid value is left as it was and listed in `ignoredFields`, not a 400. | | `locale` | `string \| null` | A BCP 47 locale, e.g. `en-GB`. An invalid value is left as it was and listed in `ignoredFields`, not a 400. | | `signedUpAt` | `string` | When the account was created: ISO 8601 with a timezone. It starts sign-up journeys while the person is inside the journey's window (72 hours by default), so sending past users with their real date is safe. It can be changed, not cleared. | | `consent` | `string \| null` | Your legal basis for marketing email: `consent`, `soft_opt_in`, `corporate_subscriber` or `none`. Service emails (like a welcome) don't need one; a marketing email that's due without one is skipped. `corporate_subscriber` counts as `none` for free-mail addresses (gmail.com, …). | | `subscribed` | `boolean` | `false`: they opted out of this email in your product, so they get no more lifecycle email. `true` lifts only that opt-out — never an unsubscribe made in our emails. | | `excluded` | `object \| null` | `{"reason": "staff"}` (reason ≤ 64 chars): never email this person, in any journey — staff, test accounts, invited teammates. `null` lets them into journeys that start from then on. | | `steps` | `object` | Onboarding step id → when it was done (ISO 8601), or `null` for not done. Use your catalog's ids: lower-case letters, digits, `_` and `-`. Merged key by key; at most 50. | | `facts` | `object` | Fact id → its latest value: a number, a string (≤ 200 chars) or a boolean. A fact your catalog marks as a date is a string: ISO 8601 with a timezone (`2026-10-08T09:12:00Z`) or a day (`2026-10-08`); one that isn't a date is left as it was and listed in `ignoredFields`. `null` removes one. Leave out one they don't have (a feature not set up, a score not measured) rather than sending 0. Merged key by key; at most 50. | | `traits` | `object` | Anything else journeys can branch on, e.g. `plan`: a string (≤ 500 chars), number or boolean. `null` removes one. Keys start with a letter, then letters, digits, `_` or `-`. Email, names, timezone and locale are fields of their own, so they're refused here. Merged key by key; at most 50. | | `entities` | `object` | The things this person has several of — workspaces, brands, projects — by your id: `kind` (your word for it, e.g. `brand`; required the first time), `name`, `parentId`, `role` (`owner`, `member` or `invited`), `steps`, `facts` and `activeAt`. Each merges like the user; `null` removes one. At most 50. Journeys choose which of them each email is about. | | `updatedAt` | `string` | Optional: when you read this state (ISO 8601). A write older than the newest one applied is ignored (`stale_write`). | `userId` (in the URL, or on a batch item): Your own id for the user, ≤ 256 chars — the same one everywhere. In a URL, encode it (`encodeURIComponent`). `batch`, `.` and `..` can't be used. ### Responses | Field | Notes | | --- | --- | | `200 applied: true` | Saved. `user` is the state we now hold. `ignoredFields` lists any profile field (`email`, `firstName`, `lastName`, `timezone`, `locale`) or date fact whose invalid value we left as it was. | | `200 applied: false` | Skipped — we hold something newer: `stale_write` (older than the stored `updatedAt`, given as `storedUpdatedAt`) or `deleted_later` (older than a later DELETE). Nothing to fix; don't retry. | | `204` | DELETE: erased, or nothing to erase. | | `400 invalid` | The body isn't valid, or has a field we don't know — or the `userId` in the URL isn't: `fields` lists each problem as a `path` and a `message`. Fix it; don't retry it as it is. (Invalid profile fields on their own don't: they're ignored.) | | `400 invalid_json` | The body isn't JSON. | | `401 unauthorized` | The key id or secret is missing or wrong, or the connection was revoked. | | `404 not_found` | GET: we don't hold that user. Events: send the user's state first. | | `404 api_disabled` | API v2 isn't switched on for this YouGrow. | | `413 body_too_large` | The body is over 512 KB. | | `429 rate_limited` | Too many requests: wait `Retry-After` seconds, then retry. | | `5xx` | Something failed on our side: `500 internal`, or `503 unavailable` with `Retry-After`. Retry with backoff — writes are idempotent. | A skipped write is a `200`, not a `409`: we already hold something newer, so there's nothing for you to fix. ### Limits - 600 requests a minute and 20,000 an hour, per key. A batch counts as one request. Over that: `429` with `Retry-After`. - 512 KB per request body, and 100 users per batch. - Per user: at most 50 steps, 50 facts and 50 traits. ### OpenAPI Every endpoint, field and response is in the OpenAPI 3.1 spec at [/developers/openapi.json](https://yougrow.ai/developers/openapi.json) — browse it in the [API reference](https://yougrow.ai/developers/api), or load it into your own tools. JSON Schemas for each body are listed on the [overview](https://yougrow.ai/developers.md). --- # The context endpoint When you set one, YouGrow asks your server about one user just before it emails them. It's how an email tells someone something true about their own results — a score, a count, a finished job, how they compare — in your words, and how your product keeps the final say: hold or stop an email at the moment it would go. > **What you lose without one.** Journeys still run on the state you [send](https://yougrow.ai/developers/users.md), and a template can print a bare fact you've sent. But no email can say what a person's own numbers mean — an insight sentence such as “Your last report found 3 issues, down from 9 last week” — because only your product can write it and keep it true. Nor can you hold an email until there's something to say (while a first result is still being produced), or stop one at the last moment. Build it if your emails should show people their results; skip it only if they'll only ever nudge steps. **Coding agents:** don't decide this for the product owner — say what they'd lose, and ask. ## What you need in place 1. An **HTTPS endpoint** on your server that accepts `POST` — on the public internet, port 443, answering directly (redirects aren't followed). 2. **Request verification** with YouGrow's public keys — see [verifying our requests](https://yougrow.ai/developers/security.md). Reject anything that doesn't verify with `401`. 3. A lookup of **one user** by your own user id: their onboarding steps, the next step, facts about their account, and any reason not to email them. 4. The URL saved in your connection's **Settings → Context endpoint**, and your app's domain in **allowed link domains** (links anywhere else are removed). 5. A green **Test connection** on the connection page — it calls your endpoint for a test user and shows exactly what we understood. ## When we call it Only while a context endpoint is set and switched on in your connection's Settings. | Field | Notes | | --- | --- | | `send` | When a journey is about to act for this user — take a branch or send an email. This is the call that matters most. | | `prepare` | About 12 hours before an email with a personalised line, so a draft can be written from your facts and reviewed by your team. | | `test` | From Test connection in YouGrow. | Expect one call per step of a journey per user — a handful per person over a week-long sequence, never a stream. If your endpoint is down, journeys keep working (see failures). ## The request ``` POST https://api.your-app.com/yougrow/context Content-Type: application/json Authorization: Bearer X-YouGrow-Key-Id: { "userId": "user_123", "purpose": "send", "journeyId": "lcj_…", "nodeId": "cond_1", "entity": { "id": "proj_42", "kind": "project" }, "requestId": "b5c1e6d2-…" } ``` | Field | Type | Notes | | --- | --- | --- | | `userId` | `string` | Your own id for the user — the userId you use in the API. | | `purpose` | `string` | `send`, `prepare` or `test`. You can answer them all the same way. | | `journeyId / nodeId` | `string?` | Which journey and step is asking — useful in your logs. | | `entity` | `object?` | When the email is about one of the user's [entities](https://yougrow.ai/developers/users.md) (a workspace, a project, a brand — whatever they have several of): its `id` and `kind`, as you sent them. Answer for that one: its steps, facts and insights. Facts are matched by id, so the person's own facts (the ones you don't keep per entity) can come back in the same list. Absent: answer for the person. | | `requestId` | `string` | Unique per request; it's also the token's `jti`. | ## The response Reply `200` with JSON — at most 64 KB, within your configured timeout (2 seconds by default, 5 at most). Only `asOf` is required; send what you have. ``` { "asOf": "2026-09-23T10:00:00Z", "steps": [ { "id": "create_brand", "label": "Add your brand", "done": true, "doneAt": "2026-09-22T09:12:00Z" }, { "id": "run_audit", "label": "Run your first audit", "done": false, "url": "https://app.acme.com/audits/new" } ], "nextStep": { "id": "run_audit", "label": "Run your first audit", "url": "https://app.acme.com/audits/new" }, "facts": [ { "id": "share_of_voice", "label": "Share of voice", "value": 12, "unit": "%", "source": "Daily snapshot" }, { "id": "competitors_named", "label": "Competitors named instead of you", "value": 3 } ], "insights": [ { "id": "competitors_named", "sentence": "ChatGPT named 3 competitors in answers about your category, but not you.", "factIds": ["competitors_named"], "weight": 0.8, "supportsStep": "run_audit" } ], "consent": { "basis": "soft_opt_in", "categories": { "onboarding": true } } } ``` ### steps — the onboarding checklist (up to 20) | Field | Type | Notes | | --- | --- | --- | | `id` | `string` | Your step id — the same ids as your catalog and the `steps` you send. Lower-case letters, digits, `_`, `-`. | | `label` | `string` | What the user sees, ≤ 120 chars. | | `done` | `boolean` | Whether it's done now. This overrides the steps you've sent, so journeys follow the live state. | | `doneAt` | `string?` | When it was done (ISO 8601 with a timezone). | | `url` | `string?` | A deep link that completes the step — https, on your allowed link domains. | | `blocked` | `string?` | If the user can't do this step yet, why (≤ 200 chars). | ### nextStep The one step to nudge towards (`id`, `label`, `url`) — usually the first step not done. It powers the “next step” button in emails. Omit it or send `null` when there's nothing to do. ### facts — true numbers about this user (up to 50) | Field | Type | Notes | | --- | --- | --- | | `id` | `string` | Stable id, ≤ 64 chars. List the ids you send in your catalog's **Facts** — Test connection warns about any it doesn't know. | | `label` | `string` | What the number is, ≤ 120 chars. | | `value` | `number \| string \| boolean` | The value (strings ≤ 200). Journeys can branch on it: fact.. It overrides the value you've sent. | | `unit` | `string?` | E.g. `%`, ≤ 20 chars. | | `display` | `string?` | How to show it, e.g. `12%`. | | `source / observedAt` | `string?` | Where it came from and when — shown to your team when they review drafts. | > **Dates.** A fact your catalog marks as a **date** (when someone was last active, when a trial ends) is text here too: ISO 8601 with a timezone (`2026-10-08T09:12:00Z`) or a plain day (`2026-10-08`). Journeys read it as days since, or until. Returning it here means an email due today knows about a change made today — someone who came back this morning isn't nudged — where the value you last sent could be a day old. One that isn't a date is ignored and the value you last sent is used; Test connection tells you. ### insights — sentences we may quote (up to 20) | Field | Type | Notes | | --- | --- | --- | | `id` | `string` | Stable id; each insight is used at most once per user. | | `sentence` | `string` | A complete, **true** sentence, ≤ 300 chars — the only way an email says what someone's numbers mean. (A template can print a bare fact with `{{fact.}}`; the sentence about it comes from here.) | | `factIds` | `string[]` | The facts it's based on. | | `weight` | `0–1` | How strong it is (default 0.5). Stronger insights are used first. | | `supportsStep` | `string?` | A step id this insight encourages — preferred when that step is the next one. | > **Numbers only ever come from you.** YouGrow may add a short, personal line next to your sentence (reviewed by your team first), but that line is checked to contain no numbers, links or names that aren't in your facts. If a sentence can't be backed by your data, leave it out. ### consent, hold and exit — your product has the final say | Field | Type | Notes | | --- | --- | --- | | `consent` | `object?` | `basis` (`consent`, `soft_opt_in`, `corporate_subscriber`, `none`) and optional `categories` (e.g. `{"onboarding": false}`). Overrides the consent you've sent. | | `hold` | `object?` | `{ "reason": "…", "until": "" }` — don't email yet (e.g. their data is still loading). We check again at `until` — 6 hours if you don't say, never more than 24 hours. | | `exit` | `object?` | `{ "reason": "…" }` — stop all lifecycle email for this user: staff accounts, invited team members, accounts pending deletion, anyone who shouldn't get it. For people you know about in advance, `excluded` in their state does the same without an endpoint. | ## Failures and timeouts - If your endpoint errors, times out, or returns something invalid, the email still goes out using the state you've sent — without the insight block — and conditions use your stored steps and facts. Unknown data never matches a branch, so people fall to each branch's safe default. - After 3 failures in a row we stop asking for 15 minutes and use the stored state; then we try again. So a slow or broken endpoint never holds up your journeys. - Links that aren't https on your allowed link domains are removed; the rest of the response is kept. - On a serverless platform, a cold start counts against your timeout too — see [reading the raw body](https://yougrow.ai/developers/security.md) for each framework, and keep the verifier at module scope. - Your connection shows the last success and the last error. Repeated failures are worth fixing quickly: the journey stops being able to see your live data. ## A complete example (Node) **Express** ``` import express from "express"; import { createVerifier, contextResponse } from "@yougrowai/node/server"; const verifier = createVerifier({ keyId: process.env.YOUGROW_KEY_ID! }); const app = express(); // Verify against the RAW body — parse only after it checks out. app.post("/yougrow/context", express.raw({ type: "application/json", limit: "16kb" }), async (req, res) => { const rawBody = req.body.toString("utf8"); const v = await verifier.verify({ headers: req.headers, rawBody, direction: "context" }); if (!v.ok) return res.status(401).json({ error: v.reason }); const { userId } = JSON.parse(rawBody); const user = await db.users.find(userId); if (!user) return res.status(404).end(); if (user.isStaff || user.pendingDeletion) { return res.type("json").send(contextResponse({ exit: { reason: "not a lifecycle recipient" } })); } const steps = [ { id: "create_brand", label: "Add your brand", done: Boolean(user.brandId), url: "https://app.acme.com/brand/new" }, { id: "run_audit", label: "Run your first audit", done: await db.audits.anyCompleted(userId), url: "https://app.acme.com/audits/new" }, ]; const sov = await db.metrics.latestShareOfVoice(userId); // null when there's no data yet res.type("json").send( contextResponse({ steps, nextStep: steps.find((s) => !s.done) ?? null, facts: sov === null ? [] : [{ id: "share_of_voice", label: "Share of voice", value: sov, unit: "%" }], insights: sov === null ? [] : [{ id: "sov", sentence: `AI answers mention you in ${sov}% of questions about your category.`, factIds: ["share_of_voice"] }], }), ); }); ``` Not using Node? Any JWT library works — the checks are listed in [verifying our requests](https://yougrow.ai/developers/security.md). --- # Webhooks When someone unsubscribes from one of our emails, or we stop emailing them because their address bounced or they reported spam, YouGrow tells your server — so your product's own records say the same thing, and your other emails respect it too. > **Compliance.** Without a webhook, our own unsubscribe links still work — we stop sending straight away. But your product won't know, so its settings page shows the person as subscribed, and emails you send yourself keep going to someone who opted out. ## What happens 1. Alex clicks **Unsubscribe** in an onboarding email (or uses the one-click unsubscribe in their mail app). 2. We stop sending Alex that type of email immediately — nothing depends on your server for that. 3. We send your webhook endpoint a signed `email_preferences.updated` for Alex's **user id** and the **category** they left (e.g. `onboarding`). 4. Your server turns off the matching setting on Alex's account, and replies `200`. It works the other way too: when someone opts out *in your product*, send `"subscribed": false` in their [state](https://yougrow.ai/developers/users.md). ## What you need in place 1. An HTTPS endpoint on your server that accepts `POST` — public, port 443, answering directly (redirects aren't followed). 2. Verification of every request with YouGrow's public keys (direction `webhook`) — [how](https://yougrow.ai/developers/security.md). Reject anything that doesn't verify with `401`. 3. A `2xx` reply within 5 seconds. Do slow work afterwards. 4. The URL saved in your connection's **Settings → Webhook endpoint**, then press **Test webhook**. ## The request ``` POST https://api.your-app.com/yougrow/webhook Content-Type: application/json Authorization: Bearer X-YouGrow-Key-Id: { "id": "wh_7c1d…", "type": "email_preferences.updated", "createdAt": "2026-09-23T10:00:00Z", "data": { "userId": "user_123", "category": "onboarding", "subscribed": false, "scope": "category", "source": "list-unsubscribe" } } ``` ### Types | Field | Notes | | --- | --- | | `email_preferences.updated` | Someone changed an email preference from one of our emails. Mirror it in your product. | | `email.suppressed` | We stopped emailing someone: their address hard-bounced, or they reported one of our emails as spam. | | `connection.test` | Sent by Test webhook in YouGrow. Verify it and reply 2xx; there's nothing to change. | More types may be added. Reply `2xx` to any type you don't handle, so it isn't retried. ### email_preferences.updated — data | Field | Type | Notes | | --- | --- | --- | | `userId` | `string` | **Your** user id, exactly as you use it in the API. Payloads never contain an email address. | | `category` | `string` | The type of email they left — the journey's unsubscribe category, e.g. `onboarding`. Map it to your own setting. | | `subscribed` | `boolean` | Always `false` today (an unsubscribe). | | `scope` | `string` | `category`: just this type of email. `all`: they chose to stop *every* email from you on our preferences page — turn off all your marketing email for them. | | `source` | `string` | `list-unsubscribe` (the one-click button in their mail app) or `preferences-page` (the link in our footer). For your records. | ### email.suppressed — data | Field | Type | Notes | | --- | --- | --- | | `userId` | `string` | **Your** user id. | | `reason` | `string` | `hard_bounce`: the address doesn't exist or refuses mail. `complaint`: they reported one of our emails as spam — treat it as an objection to marketing email. | ## Your consent records Record what we send in your own consent records. If your product keeps one marketing consent rather than a setting per type of email, this is what each webhook means for it: | Webhook | Value | In your records | | --- | --- | --- | | `email_preferences.updated` | `scope: all` | They asked to stop every email from you: record a withdrawal of marketing consent. | | `email_preferences.updated` | `scope: category` | They left one type of email: turn off that setting if you have one. Don't record it as a withdrawal of all marketing — they didn't make that choice, and we keep enforcing this one. | | `email.suppressed` | `reason: complaint` | They reported one of our emails as spam, an objection: record a withdrawal of marketing consent. | | `email.suppressed` | `reason: hard_bounce` | The address doesn't work. That's not a consent choice: record nothing, and fix the address if you can. | | `connection.test` | | Nothing to record. | Date it with the webhook's `createdAt`. If the person changed their consent in your product after that, keep their newer choice. Then let your usual sync send the user's state back to us. That echo is harmless: - An opt-out made in one of our emails holds whatever the API sends: `subscribed: true` can't lift it. - Writes through the API never trigger a webhook, so the echo can't loop. ## A complete example (Node) **Express** ``` import express from "express"; import { createVerifier, type WebhookEvent } from "@yougrowai/node/server"; const verifier = createVerifier({ keyId: process.env.YOUGROW_KEY_ID! }); const app = express(); // Map our categories to your own email settings. const SETTING = { onboarding: "productTips" } as const; // Verify against the RAW body — parse only after it checks out. app.post("/yougrow/webhook", express.raw({ type: "application/json", limit: "16kb" }), async (req, res) => { const rawBody = req.body.toString("utf8"); const v = await verifier.verify({ headers: req.headers, rawBody, direction: "webhook" }); if (!v.ok) return res.status(401).json({ error: v.reason }); const hook = JSON.parse(rawBody) as WebhookEvent; if (await db.webhooksSeen.has(hook.id)) return res.status(200).end(); // a retry we already handled if (hook.type === "email_preferences.updated") { const { userId, category, subscribed, scope } = hook.data; if (scope === "all") { await db.users.update(userId, { marketingEmail: false }); } else if (category in SETTING) { await db.users.update(userId, { [SETTING[category as keyof typeof SETTING]]: subscribed }); } } else if (hook.type === "email.suppressed" && hook.data.reason === "complaint") { await db.users.update(hook.data.userId, { marketingEmail: false }); // a spam report is an objection } await db.webhooksSeen.add(hook.id); // keep ids for a few days res.status(200).end(); // any other type: 2xx, nothing to do }); ``` Not using Node? Any JWT library works — the checks are in [verifying our requests](https://yougrow.ai/developers/security.md). ## Delivery and retries - A reply that isn't `2xx`, or no reply within 5 seconds, is retried: after 1 minute, then 2, 4, 8 … up to every 6 hours, for 24 hours. After that it's dropped — the unsubscribe itself still stands on our side. - A retry carries the **same `id`** (also the token's `jti`). If your server did the work but the reply got lost, you'll see it again — skip ids you've handled. Setting a preference to the same value twice is harmless anyway. - A serverless endpoint that's slow to start is fine: the retry a minute later finds it running. Your handler may then see an id it already handled, if it finished the first time after we stopped waiting. - **Test webhook** in your connection's Settings sends one straight away and shows what your server replied. If the first try gets no reply within 5 seconds, it tries once more, as a delivery would. > Your connection secret isn't involved: requests are signed with YouGrow's key and verified with its public keys, so nothing you store can be used to forge them. --- # Authentication Two directions, two mechanisms. When *you* call us, you authenticate with your connection's key id and secret. What *we* send you is signed with YouGrow's own key, which you verify with our published public keys — so nothing you store could ever be used to impersonate YouGrow. ## Your key id and secret - **Key id** (`ygk_…`) — public. It identifies your connection: it's your username for the API, and the audience of our tokens. - **Secret** (`ygs_…`) — shown once when you create the connection. Keep it in your server's secret manager. Never put it in source control, a browser, a mobile app or logs. - **Rotating:** Settings → Rotate secret issues a new one. The previous secret keeps working for 24 hours, so you can deploy the new one without downtime. If a secret may have leaked, rotate immediately. ## Authenticating your requests Every request to the API uses HTTP Basic auth over HTTPS: your key id is the username and your secret is the password. ``` Authorization: Basic :"> curl -u "$YOUGROW_KEY_ID:$YOUGROW_SECRET" https://yougrow.ai/api/v2/me ``` - Most HTTP clients build the header for you: `curl -u`, `requests.patch(…, auth=(key_id, secret))`, or the Node SDK from `keyId` and `secret`. - There's nothing to sign and no clock to keep in sync. HTTPS protects the request, and every write is idempotent, so a retry is harmless. - A missing or wrong key id or secret gets `401` — as does a revoked connection. During a rotation, the previous secret works for 24 hours. - **Check a key** with `GET /api/v2/me` (`yg.me()` in the SDK). It returns the connection the key belongs to — its name, environment and status — so you can tell a staging key from a production one. - Send it from your server only. Anyone with the secret can change your users' state. ## Verifying our requests Context requests and webhooks carry `Authorization: Bearer `, signed with ES256 by a key held in Google Cloud KMS. Verify it **before** trusting anything in the request: 1. Read the header's `kid` and find that key in `https://yougrow.ai/.well-known/jwks.json`. Cache the key set as its `Cache-Control` allows; if a token names a key you don't have, fetch it again (at most once a minute). 2. Check the signature, and that `alg` is `ES256`. Reject anything else, including `none` and HMAC algorithms. 3. Check the claims: | Field | Type | Notes | | --- | --- | --- | | `iss` | `string` | Must be `https://yougrow.ai`. | | `aud` | `string` | Must be your connection's key id. | | `dir` | `string` | `context` on your context endpoint, `webhook` on your webhook endpoint. | | `exp / iat` | `number` | Not expired (allow ~60 s of clock skew); tokens live at most 5 minutes. | | `body_sha256` | `string` | The base64url SHA-256 of the raw request body you received — so the body can't have been altered. | | `jti` | `string` | Unique per request (the requestId or webhook id) — use it to drop repeats. | ### With the Node SDK ``` import { createVerifier } from "@yougrowai/node/server"; const verifier = createVerifier({ keyId: process.env.YOUGROW_KEY_ID! }); const v = await verifier.verify({ headers: req.headers, rawBody, direction: "context" }); if (!v.ok) return res.status(401).end(); ``` It fetches and caches the key set, refetches when we rotate keys, and keeps working on cached keys if a refresh fails. New keys are published a day before they're used, so rotations never break you. Create it once, outside your handler, so the keys stay cached between requests. > **Important:** Verify against the **raw** body. Frameworks that parse JSON first can re-serialize it with different bytes, and the body hash won't match — read the raw body, verify, then parse. ## Reading the raw body Most frameworks parse JSON before your handler runs. Here's how to get the exact bytes in each — then verify, then parse. (`verify` takes a string, or a Buffer from 0.2.0.) **Express** ``` app.post("/yougrow/context", express.raw({ type: "application/json" }), async (req, res) => { const rawBody = req.body.toString("utf8"); // a Buffer, because of express.raw() // … verify, then JSON.parse(rawBody) }); ``` **Next.js — App Router (app/yougrow/context/route.ts)** ``` export async function POST(req: Request) { const rawBody = await req.text(); const v = await verifier.verify({ headers: req.headers, rawBody, direction: "context" }); if (!v.ok) return Response.json({ error: v.reason }, { status: 401 }); // … JSON.parse(rawBody) } ``` **Next.js — Pages Router (pages/api/yougrow/context.ts)** ``` export const config = { api: { bodyParser: false } }; // keep the body unparsed export default async function handler(req: NextApiRequest, res: NextApiResponse) { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); const rawBody = Buffer.concat(chunks).toString("utf8"); // … verify, then JSON.parse(rawBody) } ``` **Firebase / Cloud Functions (onRequest)** ``` export const yougrowContext = onRequest(async (req, res) => { const rawBody = req.rawBody.toString("utf8"); // req.body is already parsed; rawBody is the exact bytes // … verify, then JSON.parse(rawBody) }); ``` **Fastify** ``` app.register(async (scope) => { // For these routes only: hand JSON bodies over as the raw string. scope.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => done(null, body)); scope.post("/yougrow/context", async (req, reply) => { const rawBody = req.body as string; // … verify, then JSON.parse(rawBody) }); }); ``` **NestJS** ``` const app = await NestFactory.create(AppModule, { rawBody: true }); // main.ts @Post("yougrow/context") async context(@Req() req: RawBodyRequest) { const rawBody = req.rawBody!.toString("utf8"); // … verify, then JSON.parse(rawBody) } ``` **AWS Lambda (API Gateway or a function URL)** ``` export const handler = async (event) => { const rawBody = event.isBase64Encoded ? Buffer.from(event.body ?? "", "base64").toString("utf8") : event.body ?? ""; const v = await verifier.verify({ headers: event.headers, rawBody, direction: "context" }); // … }; ``` Use a Node runtime: the SDK uses `node:crypto`, so it doesn't run on edge runtimes (Vercel Edge Functions, Next.js middleware, Cloudflare Workers). On a serverless platform, a cold start plus the first key fetch has to fit inside your context endpoint's timeout — keep the verifier at module scope, and consider keeping one instance warm. ## Test vectors [test-vectors.json](https://yougrow.ai/developers/test-vectors.json) holds reference ES256 tokens, with the public key that verifies them, so you can check your verification in any language. The same file ships in the SDK package as `test/vectors.json`. --- # Open source YouGrow is open source under the AGPL-3.0 licence. You can run it on your laptop in about 15 minutes with no accounts, host your own copy on Google Cloud, and build on it. The repository is set up for coding agents, so Claude Code or Cursor can do most of the work: you mostly approve what it proposes. ## What you need - Git. - Node 24 (Node 22 works). - Java 21 or newer, for the Firebase emulators. - Claude Code ([install](https://code.claude.com/docs/en/setup)) or Cursor ([download](https://cursor.com)). A local run needs no Google Cloud project, no Firebase account and no keys. ## Run it on your laptop ### With Claude Code **In a terminal** ``` git clone https://github.com/ygai-jezl/vizzyblmrkt yougrow cd yougrow claude "Get YouGrow running on my laptop. Follow AGENTS.md." ``` ### With Cursor 1. Clone the repository (the first two lines above) and open the `yougrow` folder in Cursor. 2. In the agent chat, send `Get YouGrow running on my laptop. Follow AGENTS.md.`, or [open the prompt in Cursor](https://cursor.com/link/prompt?text=Get%20YouGrow%20running%20on%20my%20laptop.%20Follow%20AGENTS.md.). 3. Approve the terminal commands it proposes. ### What happens next 1. The agent runs `npm run doctor`. It checks your machine and prints a fix for anything missing, most often Java 21. 2. It asks before running `npm install`. 3. It starts `npm run local`: the app on the Firebase emulators, with a made-up brand called Acme. Nothing reaches the internet, and emails are written to the log instead of sent. 4. Open `http://localhost:3002/login`, click **Sign in with Google**, and pick `admin@example.com` on the emulator's test page. > AI writing, images and knowledge search need a Gemini key. Get one at [aistudio.google.com/apikey](https://aistudio.google.com/apikey), add it to `.env.local` as `GEMINI_API_KEY` yourself, and restart the local run. Don't paste keys into the chat. ## What to ask next | You want to | Ask your agent | | --- | --- | | Find your way around | Give me a tour of the admin, and show me where each part's code lives. | | Build a feature | Add a feature that ... It should ship behind a switch, with tests. | | Fix a bug | When I ..., I see ... Find out why and fix it, with a test. | | Check your work | Check my change is ready for a pull request. | | Host your own copy | Host YouGrow on my Google Cloud project my-project-id. Follow docs/self-host.md. | The agent follows `AGENTS.md` and five step by step skills in `.claude/skills/`. Claude Code and Cursor both read them. ## Host your own copy You need a Google Cloud project with billing turned on, the gcloud CLI and the Firebase CLI. Allow about 45 minutes, most of it the first build. 1. Sign in yourself, in a terminal: `gcloud auth login`, then `gcloud auth application-default login`, then `firebase login`. 2. Ask your agent: `Host YouGrow on my Google Cloud project my-project-id. Follow docs/self-host.md.` 3. It shows a read-only plan (`npm run selfhost -- --project my-project-id`), then applies one step at a time and asks before each change. 4. Two steps need you in a browser: connecting your fork of the repository to App Hosting, and turning on Google sign-in in the Firebase console. The agent tells you when. 5. Check the result: `npm run doctor -- --cloud --project my-project-id`. Costs and every step: [the self-host guide](https://github.com/ygai-jezl/vizzyblmrkt/blob/main/docs/self-host.md). ## What the agent asks before doing - Any deploy or rollout, and any setup step or script that changes a Google Cloud project. - Installing packages, `git push`, and other changes that are hard to undo. - It never reads `.env.local` or secret values. Claude Code enforces this through the repository's settings. In Cursor, approve terminal commands one at a time, and read any with `deploy`, `selfhost` or `--apply` before you approve. ## Full guides - [Start with Claude or Cursor](https://github.com/ygai-jezl/vizzyblmrkt/blob/main/docs/start-with-claude-or-cursor.md): every step, with fixes for common problems. - [AGENTS.md](https://github.com/ygai-jezl/vizzyblmrkt/blob/main/AGENTS.md): what your coding agent reads first. - [Host it on your own Google Cloud](https://github.com/ygai-jezl/vizzyblmrkt/blob/main/docs/self-host.md): the full self-host guide. - [Cloud coding agents](https://github.com/ygai-jezl/vizzyblmrkt/blob/main/docs/agents/cloud.md): Claude Code on the web, Cursor Cloud Agents and Codespaces. - [Contributing](https://github.com/ygai-jezl/vizzyblmrkt/blob/main/CONTRIBUTING.md): send your changes back.