Personyze is usually installed as a browser tracker: the snippet decides what to show and renders it in the page. But the same decision engine is reachable directly over HTTP, which lets your own server ask "what should this user see?" and render the answer itself.
Use this when:
- You render HTML on the server and don't want a client-side flash of default content.
- You're personalizing something that isn't a web page at all — a native app, a kiosk, an email pipeline, a set-top box.
- You want to A/B test a server-side change (a different API response, a different price, a different ranking) rather than a visual swap.
- You're on a platform with no Personyze SDK. For Android and iOS, prefer the mobile SDKs, which implement everything on this page for you.
The endpoint
One endpoint does everything: you send what the user just did, and you get back what should happen next.
POST https://app.personyze.com/rest/tracker-v1
Authenticated like the rest of the API — HTTP Basic, username api, password your API key. Content-Type must be application/json.
The request
{
"user_id": 123456,
"session_id": "…value from the previous response…",
"languages": "en, es",
"time_zone": -4,
"screen": "1920x1080",
"os": "Ubuntu/18.04",
"platform": "x86_64",
"device_type": "regular",
"commands": [
["Navigate", "urn:personyze:doc:checkout"]
]
}
| Field | Notes |
|---|---|
user_id |
Any 32-bit integer that is stable for this user. Your own user id, a device id, a hash — whatever you can reproduce next time. |
session_id |
null on the very first request. After that, always send back the session_id from the most recent response. |
commands |
What happened, and what you want back. See below. |
languages, time_zone, screen, os, platform, device_type |
Describe the environment. All optional, but they feed targeting rules and appear in Real time visitors, so send what you have. device_type is one of regular, tablet, phone. |
new_session |
Set true to force a fresh session instead of failing when a stored session_id has gone stale. |
past_sessions |
Comma-separated start times of this user's previous sessions, oldest first — you keep the list, you send it back. In the browser the tracker's own cookies carry this, so the web tag sends nothing; a server or a native app has no such cookie, which is why it is yours to maintain. It feeds the visitor state that targeting rules are evaluated against, so rules about returning visitors need it. Append each session's start time (the first number of session_id) as that session ends, and keep the last dozen — the official mobile SDKs keep 12. |
noti_enabled |
Set true if this client can display a web push notification. Campaigns that deliver push are then eligible for this user; left out, they are skipped. Only meaningful where you have somewhere to show one — a mobile app, a service worker — not on a plain server render. |
The session_id is the whole state of the conversation. Store it wherever you keep per-user state, send back the newest one every time, and Personyze knows who it is talking to. If you send a malformed one you get Invalid session_id — unless new_session is set, in which case a new session starts.
Getting new_session right
This is the easiest thing on this page to get wrong, and it fails quietly.
new_session: true alongside a session that is still valid does not mean "start one if the old one has expired". It means start a new session, and that is what happens: the session counter goes up and a fresh session begins. Send it on every request and you open a new session on every request — which re-buckets the visitor in any A/B test that rotates per session, makes every session one page long so bounce rate reads 100%, and quietly breaks frequency capping and any rule about what happened earlier in the visit. Every call still succeeds and still returns sensible-looking actions, so nothing draws attention to it.
Leaving it out is not the answer either: a stored session that has gone stale then answers Invalid session_id, and your returning visitors get errors.
Decide it yourself, per request:
new_session = (no stored session_id) OR (its start time is more than 90 minutes ago)
The first number in session_id is the session's start time, as a Unix timestamp — that one field is safe to read, and the rest of the string should be treated as opaque. Sessions expire after 90 minutes of inactivity. If the stored value is unparseable, treat it as expired: that turns a corrupted or truncated session into a fresh start instead of a permanent error.
The mobile SDKs and the server-side libraries all do exactly this; if you use one of them, it is handled for you.
The response
{
"session_id": "…store this…",
"cache_version": 17,
"conditions": [{"id": 42}],
"actions": [{"id": 88, "data": {"data": "…"}}],
"dismiss_conditions": [],
"dismiss_actions": [51]
}
| Field | Meaning |
|---|---|
conditions |
The campaigns that currently match this user. |
actions |
What to show. Each entry is an action id; data carries the per-user content when the action has any. |
dismiss_actions, dismiss_conditions |
Ids that no longer apply and should be taken down if you are still showing them. |
session_id |
The updated session. Store it. |
cache_version |
Changes when the account's configuration changes — useful if you cache action content on your side. |
Your server decides what an action means. The id is the contract: action 88 might be "show the free-shipping banner", or "use ranking algorithm B". You map ids to behaviour in your own code, or read the content out of data.
Asking for a decision
Personyze will not execute the same action twice on the same "page". So every time you want a fresh decision, send a Navigate command with a document identifier:
["Navigate", "urn:personyze:doc:checkout"]
The name is any URL-encoded text and shows up in the dashboard. Without a Navigate, you'll log events but get no new actions back.
Reporting what you did
This is the step that makes A/B results real, and it's the one most easily forgotten. After you act on a decision, tell Personyze:
["action status", 88, "executed"]
| Status | Send it when |
|---|---|
executed |
You actually rendered or applied the action. |
target |
The user engaged with it — clicked, converted, did the thing you were testing for. |
close |
The user dismissed it. |
An action that is never reported executed looks like it never ran, and its variant will appear to have no traffic.
When a user interacts with a product that a Personyze action recommended, attribute it:
["Product Viewed", "SKU-8891", "action_id", 88]
Other commands
The same command list drives profile updates and catalog interactions:
["User Profile", "first_name", "John", "industry", "e-commerce"]— write profile fields. Pairs of name and value, as many as you like.- Per-product:
["Product Viewed", "SKU-1"],["Product Added to cart", "SKU-1"],["Product Liked", "SKU-1"],["Product Purchased", "SKU-1"],["Product Removed from cart", "SKU-1"],["Product Unliked", "SKU-1"]. - Whole-cart:
["Products Purchased"],["Products Removed from cart"],["Products Unliked"]— these act on everything currently in the state they name, so["Products Purchased"]after a checkout is the one call that converts the whole cart, instead of oneProduct Purchasedper line. - Per-article:
["Article Viewed", "ID"],["Article Liked", "ID"],["Article Commented", "ID"],["Article Goal", "ID"],["Article Unliked", "ID"], and["Articles Unliked"]for all of them.
Product commands take optional trailing name/value pairs — the same place action_id goes:
| Argument | Notes |
|---|---|
action_id |
The Personyze action that led to this interaction. Attribution: this is what feeds the Products purchased winner criterion. |
quantity |
How many units. Defaults to 0 when omitted, so send it on purchases if you report per-unit numbers anywhere. |
["Product Purchased", "SKU-1", "action_id", 88, "quantity", 3]
Commands are executed in order, so you can log an event and ask for a decision in a single round trip.
Running an A/B test
- Create the test in the panel. Personyze has a dedicated A/B Testing campaign type, and any campaign can turn on A/B testing: you get two 50/50 groups plus winner criteria. Personyze assigns each user to a group and keeps them there — you do not implement the split yourself, and you must not, or the two systems will disagree about who is in which arm.
- Give each arm an action, of a type this endpoint can deliver. Variant A is one action id, variant B another; your code branches on which id comes back. Which action type you pick matters, and it is the one thing here that fails quietly — see below.
- Call
tracker-v1with aNavigateat the point of decision, and look at which action id comes back. - Branch on the id, and report
["action status", <id>, "executed"]. - Report the outcome in the form your winner criterion actually measures — see the table below. This is the step that decides whether the test can be scored at all.
- Read the verdict, from ab_test or in the panel — the same numbers either way.
Because Personyze holds the assignment, a user who arrives through your server one day and through the browser tracker the next stays in the same arm, provided both identify them the same way.
Which actions this endpoint can deliver
Not every action type can reach a server-side caller, and picking one that cannot is silent. The campaign saves, it passes every readiness check, and tracker-v1 simply returns no actions — for ever, with nothing anywhere explaining why.
The rule: an action is delivered over this endpoint only if its type is built to run outside a browser. Types meant for the page — Run JavaScript Code, Cookies and Local Storage, Webhook, Send event, Redirect — are not, and are the ones people reach for first when they want an action with no visible content. They will never appear in a response.
Types that do work include Banner, Image, HTML Form, the Recommendations family, and — the one to reach for in a purely server-side test — Server-side decision, which has no visual content at all and exists for exactly this: an id to branch on, and nothing else to configure.
If a campaign you expect to fire returns nothing, this is the first thing to check.
Finding the action ids
Your code branches on numeric action ids, so you need to know them. A Server-side decision action shows its own id in its editor. For any other type, read them from the API:
curl 'https://api:YOUR_API_KEY@app.personyze.com/rest/actions/columns/id,name'
The ids are stable for the life of the action, so it is normal to keep them as constants in your code.
What each winner criterion measures
A test is scored on the criteria you pick when configuring it. There are four, and each is fed by a different signal — reporting the wrong one means the arm looks like it produced nothing:
| Criterion | Scored from | So you must send |
|---|---|---|
| Clicks | Sessions in which the action was engaged with | ["action status", <id>, "target"] |
| Products purchased | Sessions containing an attributed purchase | A purchase interaction carrying action_id — ["Product Purchased", "SKU-1", "action_id", 88] |
| Goal event | Sessions in which that configured goal event fired | Whatever fires that goal event in your account |
| Bounce rate | Sessions with only one page view — lower is better | Nothing extra; it follows from Navigate |
["action status", <id>, "target"] drives the Clicks criterion and only that one. If your test is scored on purchases, target will not move it — send the attributed product interaction instead. In every case, also send executed, or the arm has no denominator.
Where the results live
Two endpoints, answering two different questions.
ab_test gives you the verdict — who is winning, by how much, and how sure Personyze is:
curl 'https://api:YOUR_API_KEY@app.personyze.com/rest/ab_test/where/condition_id=1878'
It returns the same comparison the panel shows and the same one the platform acts on when it promotes a winner: per goal, the best arm, the arm it is measured against, significance, improvement in percentage points, and the thresholds configured for the test. When there is no winner yet, has_winner is false and message says why — not enough sessions, minimum duration not reached, or the goals disagreeing about who is ahead. That is the field to put in a dashboard: "not yet, and here is what is missing" is more useful than an empty result.
summary_actions gives you the daily traffic per action — n_executed, n_delivered, n_sessions_extra, goal counts and value. Use it for your own charting and for anything at day granularity; ab_test answers about the test as a whole, over its whole life, not per day. Note that the control group has no action of its own, so it does not appear here at all — another reason lift against control has to come from ab_test.
Neither endpoint changes anything. Promoting the winning variation is still the platform's job (or yours, in the panel); reading the verdict never triggers it.
Practical notes
- Keep the session. Losing
session_idstarts a new session and can re-bucket the user. Persist it next to your own session state, not in memory. - One call per decision point. Batch events into the same call that asks for the decision instead of chattering.
- Fail open. If
tracker-v1is slow or errors, render your default. Never let a personalization call block a checkout. Set a short timeout and treat "no actions" and "call failed" as the same path. - Errors follow the usual status codes.
A complete worked example
The custom recommendations app guide walks through this same endpoint end to end, in PHP, for the specific case of pulling product recommendations into a non-web application.