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 need in place
- An HTTPS endpoint on your server that accepts
POST— on the public internet, port 443, answering directly (redirects aren't followed). - Request verification with YouGrow's public keys — see verifying our requests. Reject anything that doesn't verify with
401. - 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.
- The URL saved in your connection's Settings → Context endpoint, and your app's domain in allowed link domains (links anywhere else are removed).
- 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 | Type | 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 <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-…"
}| 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 (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.<id>. 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. |
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.<id>}}; 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. |
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": "<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. |
| 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 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)
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.