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 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 <base64 of "key id:secret">
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.

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 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 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.

FieldTypeNotes
emailstring | nullTheir 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.
firstNamestring | nullUsed in greetings (falls back to “there”), ≤ 100 chars. An invalid value is left as it was and listed in ignoredFields, not a 400.
lastNamestring | null≤ 100 chars. An invalid value is left as it was and listed in ignoredFields, not a 400.
timezonestring | nullAn 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.
localestring | nullA BCP 47 locale, e.g. en-GB. An invalid value is left as it was and listed in ignoredFields, not a 400.
signedUpAtstringWhen 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.
consentstring | nullYour 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, …).
subscribedbooleanfalse: 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.
excludedobject | 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.
stepsobjectOnboarding 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.
factsobjectFact 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.
traitsobjectAnything 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.
entitiesobjectThe 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.
updatedAtstringOptional: 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

FieldTypeNotes
200 applied: trueSaved. 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: falseSkipped — 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.
204DELETE: erased, or nothing to erase.
400 invalidThe 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_jsonThe body isn't JSON.
401 unauthorizedThe key id or secret is missing or wrong, or the connection was revoked.
404 not_foundGET: we don't hold that user. Events: send the user's state first.
404 api_disabledAPI v2 isn't switched on for this YouGrow.
413 body_too_largeThe body is over 512 KB.
429 rate_limitedToo many requests: wait Retry-After seconds, then retry.
5xxSomething 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 — browse it in the API reference, or load it into your own tools. JSON Schemas for each body are listed on the overview.