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

FieldTypeNotes
sendWhen a journey is about to act for this user — take a branch or send an email. This is the call that matters most.
prepareAbout 12 hours before an email with a personalised line, so a draft can be written from your facts and reviewed by your team.
testFrom 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 <JWT signed by YouGrow>
X-YouGrow-Key-Id: <your key id>

{
  "userId": "user_123",
  "purpose": "send",
  "journeyId": "lcj_…",
  "nodeId": "cond_1",
  "entity": { "id": "proj_42", "kind": "project" },
  "requestId": "b5c1e6d2-…"
}
FieldTypeNotes
userIdstringYour own id for the user — the userId you use in the API.
purposestringsend, prepare or test. You can answer them all the same way.
journeyId / nodeIdstring?Which journey and step is asking — useful in your logs.
entityobject?When the email is about one of the user's entities (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.
requestIdstringUnique 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)

FieldTypeNotes
idstringYour step id — the same ids as your catalog and the steps you send. Lower-case letters, digits, _, -.
labelstringWhat the user sees, ≤ 120 chars.
donebooleanWhether it's done now. This overrides the steps you've sent, so journeys follow the live state.
doneAtstring?When it was done (ISO 8601 with a timezone).
urlstring?A deep link that completes the step — https, on your allowed link domains.
blockedstring?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)

FieldTypeNotes
idstringStable id, ≤ 64 chars. List the ids you send in your catalog's Facts — Test connection warns about any it doesn't know.
labelstringWhat the number is, ≤ 120 chars.
valuenumber | string | booleanThe value (strings ≤ 200). Journeys can branch on it: fact.<id>. It overrides the value you've sent.
unitstring?E.g. %, ≤ 20 chars.
displaystring?How to show it, e.g. 12%.
source / observedAtstring?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)

FieldTypeNotes
idstringStable id; each insight is used at most once per user.
sentencestringA 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.<id>}}; the sentence about it comes from here.)
factIdsstring[]The facts it's based on.
weight0–1How strong it is (default 0.5). Stronger insights are used first.
supportsStepstring?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

FieldTypeNotes
consentobject?basis (consent, soft_opt_in, corporate_subscriber, none) and optional categories (e.g. {"onboarding": false}). Overrides the consent you've sent.
holdobject?{ "reason": "…", "until": "<ISO time>" } — 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.
exitobject?{ "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 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.