User Identification
Perselio personalizes results per visitor, so every recommendation and search request has to say who it is for.
With managed widgets and the JavaScript SDK this is handled for you. When you call the API from your own backend, you pass the identifiers yourself. This page explains what they are, where to get them, and what to do when they are not available yet.
The identifiers
| Field | What it is | Where it comes from |
|---|---|---|
zoeId | Anonymous browser identifier | the _sa cookie on your site domain |
userId | Your own identifier of the logged-in customer | your systems |
Always send zoeId. It is what ties the request to the behavior collected on your site, so treat it as required on every call.
Add userId whenever the visitor is logged in. It is an addition, never a replacement — sending both is what links anonymous browsing to the customer account and enables personalization across devices.
These are the literal JSON field names in the request body. There are nozidoruidaliases.
POST /api/v3/recommendation
{
"scenario": { "scenarioId": "accessories", "itemId": "item-id-1" },
"zoeId": "SA1.aB3xY7qZ.1755500000",
"userId": "customer-4711",
"includeFields": ["itemId", "name", "url"],
"limit": 8
}The Scenario Search API takes the same two fields in its request body.
Authenticating the request is a separate concern from identifying the visitor — see the API reference for the token and headers.
zoeId — reading the _sa cookie on your backend
zoeId — reading the _sa cookie on your backendThe Data Collector stores the visitor's zoeId in a first-party cookie named _sa on your domain.
The cookie value is the zoeId. There is nothing to decode, parse, or look up — pass the string through verbatim.
Cookie: _sa=SA1.aB3xY7qZ.1755500000
| Property | Value |
|---|---|
| Name | _sa (a tenant-specific prefix may be configured) |
| Value format | <version>.<randomId>.<firstVisitTimestamp>, for example SA1.aB3xY7qZ.1755500000 |
| Scope | First-party, top-most accessible domain of your site |
| Path | / |
| SameSite | Lax |
HttpOnly | No — readable both by your server and by scripts on the page |
| Lifetime | Up to 2 years with analytics consent; otherwise session-scoped or not stored at all |
Example (Node / Express):
const recommendation = await perselio.post('/api/v3/recommendation', {
scenario: { scenarioId: 'accessories', itemId },
zoeId: req.cookies['_sa'],
userId: req.user?.id || null,
includeFields: ['itemId', 'name', 'url'],
limit: 8,
});See Cookies for the full storage and consent behavior.
When _sa is not there yet
_sa is not there yet_sa is written by the collector after the page loads, so on the very first request of a brand-new visitor your backend does not have it. If the visitor is not logged in either, you have no identifier at all — and the API needs one.
Three ways to handle it. All of them are fine; they differ in how much you get out of that first request.
Option A — issue the identifier yourself and keep it
Your backend generates the value, returns it in a Set-Cookie: _sa=…, and uses the same value in the API call. The collector reads that cookie on the next page load and adopts it, so the visitor has one continuous identity starting from their very first request.
The value must match the format the collector accepts:
<version>.<randomId>.<unixSecondsOfFirstVisit>
for example SA1.aB3xY7qZ.1755500000. A value in any other shape is discarded by the collector — it deletes the cookie and issues its own, and the identity you used on the server is lost.
Storing the cookie is storage on the visitor's device, so the same consent rules apply whether the browser or your server writes it. Match what the collector does: a persistent cookie only with analytics consent, a session cookie otherwise. See Cookies.
Option B — use a throwaway identifier for that one request
Generate a random value, send it as zoeId, and forget it. Do not store it, do not set a cookie, do not reuse it on the next request.
The response will not be personalized — the identifier has no history behind it — but the call succeeds and the page renders. From the second request onwards the cookie exists and you use the real zoeId.
This is the simplest option, and it costs nothing beyond that one unpersonalized response.
Option C — skip the personalized block
Render the block once the collector has initialized (client-side, or through a follow-up request), or show a static fallback for that one page view.
Two rules that apply to all three
- Never mint an identifier when
_saexists. If the cookie is there, use its value. Generating your own on top of it splits the visitor into two identities: behavior accumulates under the collector's ID while your requests use yours, and the two never join. - Never reuse a throwaway identifier. A value you invented for one request must not outlive it — otherwise you have created a second, permanent identity for that visitor.
Whatever you choose, do not block page rendering while waiting for an identifier.
Other rules
Never send an empty string
Send a real value or null / omit the field. An empty string is not a valid identifier.
Consent changes how stable zoeId is
zoeId isWithout analytics consent the identifier does not persist across visits — it is session-scoped or not stored at all. A share of your traffic will therefore have a changing zoeId. This is expected behavior, not an error.
Keep userId consistent
userId consistentUse the same userId value in the API call and in the Data Collector. If the two differ, pre-login and post-login behavior cannot be linked.
Deduplicating multiple blocks on one page
When a single page shows several recommendation blocks, pass the same deduplicationId in each Recommendation API request. Concurrent requests sharing that ID will not repeat the same items in their top positions.
// "You might also like"
{
"scenario": { "scenarioId": "quick_picks", "itemId": "item-id-1" },
"zoeId": "SA1.aB3xY7qZ.1755500000",
"deduplicationId": "pv-9f2c1b7e",
"limit": 8
}
// "Discover" — same page, same deduplicationId
{
"scenario": { "scenarioId": "recommended_for_you", "itemId": "item-id-1" },
"zoeId": "SA1.aB3xY7qZ.1755500000",
"deduplicationId": "pv-9f2c1b7e",
"limit": 8
}Use any sufficiently unique string per page view. deduplicationId is cached for a limited time (currently 30 seconds), after which the behavior resets.
deduplicationIdis not the same thing aspageInstanceId.pageInstanceIdis exposed by the JavaScript SDK and attached to collector events so requests and events from one page load can be correlated — it is not a field of the recommendation API. Deduplication is keyed ondeduplicationIdonly, and in a backend integration you always set it yourself.
Related
- Cookies — what we store and how consent is detected
- Data Collector — the script that produces
zoeId - Marking Rendered Recommendations — attributing recommendations you render yourself
Updated 2 months ago

