Skip to content

Back to Help center

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/assign decides 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 Origin header (from a browser) are refused with 403 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, tests is empty. Treat a missing test as "show the original".
  • mode is live or dry_run. In a dry run, visitors are split and counted, but show everyone the original.
  • targeting can hold devices (desktop, mobile, tablet), visitor (new, returning), channels (direct, organic, paid, social, email, referral, campaign) and share (0 to 1). Missing means everyone.
  • privacy is true when privacy mode is on for the site. See Privacy mode below.
  • The response has an ETag. Send it back as If-None-Match to get a 304 when nothing changed.
  • Test and version names are never included.

Assignment

Use the libraries, or implement exactly this:

  1. fnv1a(s): 32-bit FNV-1a over the characters of s (offset 0x811c9dc5, prime 0x01000193, multiply modulo 2³²).
  2. Targeting: the visitor must match devices, visitor and channels. With a share below 1, the visitor is in the test only if fnv1a(id + ":traffic:" + visitorId) % 10000 < round(share × 10000).
  3. Bucket: fnv1a(id + ":" + visitorId) % 10000. Walk the variants with weight above 0 in order, adding weights; the first variant where bucket < 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_id cookie 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 use newVisitorId() and visitorCookie(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": true in the config). Otherwise an anonymous ID is ignored.
  • A visitor with a _varly_id cookie (consent) always counts with that ID, never anonymously.
  • Never with Do Not Track or Global Privacy Control. The libraries check the DNT and Sec-GPC request headers when you pass them (headers in Node; PHP reads them itself; opt_out in Python). With /v1/assign, send optout=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) or anonymous_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.

Was this article helpful?

Still stuck? Email support@varly.ai and we'll help you out.

Related articles

Updated November 6, 2026