Server-side tests: developer reference
The endpoints, libraries, visitor ID and rules behind server-side tests, for the developer who sets them up.
In this article
This page is for developers. All endpoints live on https://cdn.varly.ai and answer in JSON with cache-control: no-store.
Two ways to decide
- Local evaluation (recommended): a library downloads the config once and refreshes it every 30 seconds. Each decision is a calculation on your server: no network call per page view, nothing slows down when Varly is far away. Use varly-server.mjs (Node 18+, Deno, Bun, edge functions), Varly.php (PHP 7.4+, cURL; uses APCu when available, otherwise a file in the temp folder) or the small Python example.
- Remote call:
GET /v1/assigndecides on Varly's edge and records the exposure in one request. Simple for any language, but it adds a network call to every page view. Up to 6,000 calls a minute per site.
Authentication
Send the server key in every request: Authorization: Bearer sk_srv_….
- A key belongs to one site. Varly stores only a hash of it and shows it once.
- Requests with an
Originheader (from a browser) are refused with403 browser_not_allowed. - The public site key (
pk_…) is not accepted:401 invalid_key. The same answer comes for a revoked key, a key of another site, or an unknown site. - A revoked key stops working within a minute.
GET /v1/server-config
GET /v1/server-config?site=pk_…
{
"v": 1,
"site": "pk_…",
"refresh": 30,
"privacy": false,
"tests": [
{
"id": "1f0e6b8c-…",
"mode": "live",
"targeting": { "devices": ["desktop", "mobile"], "share": 0.5 },
"variants": [{ "key": "control", "weight": 50 }, { "key": "b", "weight": 50 }]
}
]
}
- Only running tests are listed. A paused, ended or deleted test disappears, and with Tracking on off for the site,
testsis empty. Treat a missing test as "show the original". modeisliveordry_run. In a dry run, visitors are split and counted, but show everyone the original.targetingcan holddevices(desktop,mobile,tablet),visitor(new,returning),channels(direct,organic,paid,social,email,referral,campaign) andshare(0 to 1). Missing means everyone.privacyistruewhen privacy mode is on for the site. See Privacy mode below.- The response has an
ETag. Send it back asIf-None-Matchto get a304when nothing changed. - Test and version names are never included.
Assignment
Use the libraries, or implement exactly this:
fnv1a(s): 32-bit FNV-1a over the characters ofs(offset0x811c9dc5, prime0x01000193, multiply modulo 2³²).- Targeting: the visitor must match
devices,visitorandchannels. With asharebelow 1, the visitor is in the test only iffnv1a(id + ":traffic:" + visitorId) % 10000 < round(share × 10000). - Bucket:
fnv1a(id + ":" + visitorId) % 10000. Walk the variants with weight above 0 in order, adding weights; the first variant wherebucket < round(sum / total × 10000)wins.
round is JavaScript's Math.round (halves round up). Visitors whose device, new/returning status or channel you don't know are left out of a test that filters on it.
POST /v1/exposures
POST /v1/exposures?site=pk_… with up to 500 exposures (256 kB) per request:
{
"exposures": [
{
"experiment_id": "1f0e6b8c-…",
"variant_key": "b",
"visitor_id": "0b5c1a2e-7d6f-4c1e-9e3a-123456789abc",
"ts": 1794067200000,
"device": "mobile",
"page_key": "/checkout"
}
]
}
For a visitor in privacy mode, add "privacy_mode": 1 and use their anonymous ID as visitor_id. Answer: 202 {"accepted": 1, "rejected": 0}. An exposure is rejected when the test isn't running, the visitor ID is invalid, variant_key is not the version the assignment gives that visitor, ts (milliseconds) is more than an hour old, or privacy_mode is 1 while privacy mode is off for the site. device and page_key are optional; session_id too. Send each visitor and test about once per visit; Varly counts a visitor once, in the version they first got. With a dry run, report the assigned version.
GET /v1/assign
GET /v1/assign?site=pk_…&experiment=<test ID>&visitor=<_varly_id>
Optional: device, ua (the visitor's user agent, URL-encoded), returning (1 or 0), channel, page. For privacy mode: anon (the anonymous ID, instead of visitor) and optout=1 when the visitor's request had DNT: 1 or Sec-GPC: 1. Answer: {"experiment": "…", "variant": "b", "inTest": true}. When inTest is true, Varly recorded the exposure. When the visitor has no _varly_id, show the original and don't call.
The visitor ID
- Read the
_varly_idcookie from the request. It is the same random ID the snippet uses, so goals, sessions and orders are linked to the version your server chose. - The snippet sets it only after the visitor accepts statistics or analytics cookies. No cookie: show the original and send nothing.
- To include first visits, set the cookie yourself on the response, but only when you know the visitor accepted cookies:
_varly_id=<random UUID>; Path=/; Max-Age=31536000; SameSite=Lax; Secure, not HttpOnly. The snippet then uses the same ID. In Node usenewVisitorId()andvisitorCookie(id); in PHP\Varly\Client::setVisitorCookie(\Varly\Client::newVisitorId()). Such a visitor counts as returning in the snippet's own segments. - Bots (by user agent) never take part.
Privacy mode
With privacy mode on, the snippet lets visitors who haven't accepted cookies take part anonymously, with a separate random ID in local storage: the id field of the _varly_anon entry. Server-side tests follow the same rules:
- Only when the site has privacy mode on (
"privacy": truein the config). Otherwise an anonymous ID is ignored. - A visitor with a
_varly_idcookie (consent) always counts with that ID, never anonymously. - Never with Do Not Track or Global Privacy Control. The libraries check the
DNTandSec-GPCrequest headers when you pass them (headersin Node; PHP reads them itself;opt_outin Python). With/v1/assign, sendoptout=1. - Only exposures and goals count, as in the snippet: orders through the order API aren't linked to these visitors.
- The anonymous ID is never in a cookie, so a normal page request doesn't carry it. Your frontend has to send it along, for example as a header on its own API requests, and your server passes it as
anonymousId(Node and PHP) oranonymous_id(Python). Without it, the visitor gets the original and isn't counted. - When the visitor later accepts cookies, the snippet turns the anonymous ID into their
_varly_id, so they keep the same version.
// Browser: send the anonymous ID with your own request (only exists in privacy mode).
const anon = JSON.parse(localStorage.getItem('_varly_anon') || 'null')?.id
fetch('/api/search?q=shoes', { headers: anon ? { 'x-varly-anon': anon } : {} })
// Server (Node)
const d = varly.decide('<test ID>', {
cookies: req.headers.cookie,
anonymousId: req.headers['x-varly-anon'],
headers: req.headers, // DNT and Sec-GPC
userAgent: req.headers['user-agent'],
})
Exposures of these visitors are marked as privacy mode, so How visitors took part on the Results tab counts them in the right group.
Errors and limits
| Status | Meaning |
|---|---|
401 invalid_key |
No key, wrong key, revoked key or the public site key |
403 browser_not_allowed |
The request came from a browser |
400 invalid_body |
Not JSON, or more than 500 exposures |
413 too_large |
More than 256 kB |
429 rate_limited |
Too many requests: config and exposures per site and server, assign per site |
503 unavailable |
Varly is briefly unavailable: keep the last config |
The libraries never throw from decide. If Varly can't be reached, they keep the last config for 10 minutes, then show everyone the original.
Still stuck? Email support@varly.ai and we'll help you out.
Related articles
- Run a server-side testTest a change your developer builds in your shop's own code, such as a new checkout or prices. Varly splits and measures.
- Send orders with the order APIConnect any shop to Varly by sending each order from your server with a secret API key.
- Cookie consent: how Varly follows your cookie bannerWhich cookie banners Varly works with, how to pick yours, and what happens before a visitor says yes.
- Send your own events with Varly.trackCount actions such as a newsletter signup or an add to cart, and use them as the goal of an A/B test.