Server-side cookie
A server-side cookie is a cookie your web server sets with a Set-Cookie header in the HTTP response for a page. The
browser gives it the longest lifetime a cookie can have. Set one next to Qinto’s own cookie to extend how long a
visitor on an Apple device stays recognised. Adding one takes a few lines on your server and one setting in the
Blotout platform.
Every site that runs its own server, or an edge layer in front of one, can set this cookie, and this page shows how. Shopify stores cannot, see Hosted platforms.
Why add one
Section titled “Why add one”Safari and the other browsers on Apple devices give the longest lifetime, up to 400 days, to a cookie the site’s own server sets in the page response. Apple describes the rules in Tracking Prevention in WebKit. A server-side cookie next to Qinto’s cookie puts that lifetime to work for you: Qinto uses it as a second way to recognise a returning visitor, so a visitor on an iPhone or a Mac stays known for as long as your cookie lives, and the purchases, consent and traits from their earlier visits stay on their profile. The ID graph page explains what that profile holds.
Qinto only reads your cookie. It never changes it, stores only a hash of the value, and passes it to no app.
What the cookie must look like
Section titled “What the cookie must look like”| Attribute | Value | Why |
|---|---|---|
| Name | A name no other tool on your site uses, such as site_visitor |
Qinto reads the cookie by this name. |
| Value | A random id that is unique per browser, such as a UUID, at least 4 characters | Two visitors with the same value would be merged into one profile. Never use a static value, a hash of the IP address or a personal identifier such as an email. |
Domain |
Your site’s root domain, the domain you gave the Blotout platform when you created the site, such as example.com |
The browser sends a cookie to the host that set it and, with Domain, to every host under that domain. Your Qinto host is one of them. A host-only cookie set by www.example.com never reaches q.example.com. |
Max-Age |
34560000 (400 days) |
The longest lifetime browsers allow. A later date is cut to 400 days. |
Path |
/ |
So every page sends it. |
Secure |
Set | Your Qinto host is HTTPS only. |
HttpOnly |
Set | No script needs to read it, and an injected script cannot read a cookie with this flag. |
SameSite |
Lax |
Your pages and your Qinto host share a site, so Lax is enough. Strict works too. |
Four rules about when to set it:
- Set it on the HTML response. The tag connects to Qinto after the page has loaded, so a cookie set in the page response is already in the browser on the first connection. A cookie set later, by an API call the page makes, arrives after Qinto has already recorded the visitor without it.
- Keep the existing value. When the request already carries the cookie, send the same value back. A new value on every response is a new visitor on every response.
- Send it on every page response. Re-sending the cookie renews its 400 days. A cookie set only on the first visit expires 400 days later, however often the visitor returns.
- Your server must set it, not a tag manager or a script. A cookie written by JavaScript, even JavaScript on your own domain, lives 7 days at most on Apple devices and extends nothing.
A cookie you already set can serve, if it is set by the server, unique per browser and stable for the life of the browser. A session cookie that rotates on login does not qualify, because the value Qinto learned is gone the next time it looks.
A cookie that identifies a browser is a cookie in the sense of your privacy policy and your consent banner. Treat it the way you treat Qinto’s own cookie, in the same category.
Add the cookie on your server
Section titled “Add the cookie on your server”Every example sets the cookie described above on a page response, reuses a value the request already carries, and
reads the root domain from configuration. Replace example.com with your root domain and pick a cookie name of your
own.
Express middleware. Express does not parse the Cookie header on its own, so install
cookie-parser and register both before your
routes.
import { randomUUID } from 'node:crypto'import cookieParser from 'cookie-parser'
const COOKIE = 'site_visitor'const ROOT_DOMAIN = 'example.com'const MAX_AGE_MS = 400 * 24 * 60 * 60 * 1000
app.use(cookieParser())app.use((req, res, next) => { const existing = req.cookies[COOKIE] res.cookie(COOKIE, existing ?? randomUUID(), { domain: ROOT_DOMAIN, maxAge: MAX_AGE_MS, path: '/', secure: true, httpOnly: true, sameSite: 'lax' }) next()})Without Express, append the header yourself:
const value = existing ?? randomUUID()res.setHeader('Set-Cookie', `${COOKIE}=${value}; Max-Age=34560000; Domain=${ROOT_DOMAIN}; Path=/; Secure; HttpOnly; SameSite=Lax`)A middleware.ts at the root of your project runs on every page request, including statically rendered pages.
import { NextResponse, type NextRequest } from 'next/server'
const COOKIE = 'site_visitor'const ROOT_DOMAIN = 'example.com'const MAX_AGE = 400 * 24 * 60 * 60
export const middleware = (request: NextRequest) => { const response = NextResponse.next() const existing = request.cookies.get(COOKIE)?.value response.cookies.set(COOKIE, existing ?? crypto.randomUUID(), { domain: ROOT_DOMAIN, maxAge: MAX_AGE, path: '/', secure: true, httpOnly: true, sameSite: 'lax' })
return response}
export const config = { // Pages only: assets and API routes need no cookie. matcher: ['/((?!_next/|api/|.*\\.[a-z0-9]+$).*)']}A Worker in front of your origin, or a Worker with static assets, can add the cookie to every HTML response. This works for a static site whose host cannot run server code.
With static assets, Cloudflare serves a file that matches the request without running the Worker. Set
run_worker_first so the Worker runs for every request and can add the header:
{ "name": "my-site", "main": "src/index.ts", "assets": { "directory": "./dist", "binding": "ASSETS", "run_worker_first": true }}const COOKIE = 'site_visitor'const ROOT_DOMAIN = 'example.com'
const cookieValue = (header: string | null): string | undefined => { return header ?.split(';') .map((part) => part.trim()) .find((part) => part.startsWith(`${COOKIE}=`)) ?.slice(COOKIE.length + 1)}
export default { async fetch(request: Request, env: { ASSETS: Fetcher }): Promise<Response> { const upstream = await env.ASSETS.fetch(request) // or fetch(request) when the Worker sits in front of an origin if (!upstream.headers.get('content-type')?.includes('text/html')) { return upstream }
const value = cookieValue(request.headers.get('cookie')) ?? crypto.randomUUID() const response = new Response(upstream.body, upstream) response.headers.append( 'set-cookie', `${COOKIE}=${value}; Max-Age=34560000; Domain=${ROOT_DOMAIN}; Path=/; Secure; HttpOnly; SameSite=Lax` )
return response }}Call this before any output, for example in a bootstrap file every page includes. Laravel users can put the same
logic in a middleware and return $response->withCookie(...).
<?phpconst COOKIE = 'site_visitor';const ROOT_DOMAIN = 'example.com';const MAX_AGE = 400 * 24 * 60 * 60;
$value = $_COOKIE[COOKIE] ?? bin2hex(random_bytes(16));
setcookie(COOKIE, $value, [ 'expires' => time() + MAX_AGE, 'path' => '/', 'domain' => ROOT_DOMAIN, 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',]);Save this as wp-content/mu-plugins/site-visitor-cookie.php. A must-use plugin loads on every request without
activation. WooCommerce sets no cookie of its own that is stable per visitor, so WooCommerce stores need this too.
<?php/** * Plugin Name: Site visitor cookie */add_action('send_headers', function () { $name = 'site_visitor'; $root_domain = 'example.com'; $value = $_COOKIE[$name] ?? wp_generate_uuid4();
setcookie($name, $value, [ 'expires' => time() + 400 * DAY_IN_SECONDS, 'path' => '/', 'domain' => $root_domain, 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ]);});A page cache that serves HTML without running PHP also skips this hook. Most caches, and managed hosts such as WP
Engine or Kinsta, strip Set-Cookie from cached pages. Check the response headers of a logged-out page view after you
deploy. If the cookie is missing, set it at the edge instead, with the Cloudflare Workers or nginx example.
Django middleware. Add it to MIDDLEWARE in settings.py.
import uuid
COOKIE = "site_visitor"ROOT_DOMAIN = "example.com"MAX_AGE = 400 * 24 * 60 * 60
class VisitorCookieMiddleware: def __init__(self, get_response): self.get_response = get_response
def __call__(self, request): response = self.get_response(request) value = request.COOKIES.get(COOKIE) or str(uuid.uuid4()) response.set_cookie( COOKIE, value, max_age=MAX_AGE, domain=ROOT_DOMAIN, path="/", secure=True, httponly=True, samesite="Lax", ) return responseFlask has the same call: response.set_cookie(COOKIE, value, max_age=MAX_AGE, domain=ROOT_DOMAIN, secure=True, httponly=True, samesite="Lax") in an after_request hook.
A before_action in ApplicationController runs for every page.
class ApplicationController < ActionController::Base COOKIE = :site_visitor ROOT_DOMAIN = "example.com"
before_action :set_visitor_cookie
private
def set_visitor_cookie cookies[COOKIE] = { value: cookies[COOKIE].presence || SecureRandom.uuid, domain: ROOT_DOMAIN, expires: 400.days.from_now, path: "/", secure: true, httponly: true, same_site: :lax } endendA handler wrapper with the standard library. Wrap your page router with it.
import ( "net/http"
"github.com/google/uuid")
const cookieName = "site_visitor"const rootDomain = "example.com"
func withVisitorCookie(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { value := uuid.NewString() if existing, err := r.Cookie(cookieName); err == nil && existing.Value != "" { value = existing.Value } http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: value, Domain: rootDomain, Path: "/", MaxAge: 400 * 24 * 60 * 60, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) next.ServeHTTP(w, r) })}For a static site, or a server whose application you cannot change, nginx can set the cookie itself. $request_id is
a random 32-character hex string per request, so a visitor without the cookie receives a fresh one, and a visitor who
has it receives the same value back.
map $cookie_site_visitor $site_visitor { "" $request_id; default $cookie_site_visitor;}
server { # ... location / { add_header Set-Cookie "site_visitor=$site_visitor; Max-Age=34560000; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax"; try_files $uri $uri/ /index.html; }}The map block goes in the http context, outside server. add_header in a location block replaces the
add_header lines inherited from server, so repeat any header you set there. The header is added to 2xx and 3xx
responses only, which covers pages.
Hosted platforms
Section titled “Hosted platforms”Shopify gives a merchant no place to set a response header, so a Shopify store cannot add a server-side cookie.
Use the Blotout Shopify app, which recognises returning customers for you. If you write your own custom pixel
instead, send the customer id Shopify exposes on checkout events with identify and alias, so a customer is
recognised across visits by their id. See User data.
WordPress and WooCommerce need the plugin from the WordPress tab above. Neither sets a stable per-visitor cookie by default.
Static hosting such as Netlify, Vercel or S3 behind a CDN cannot set a cookie from the page itself. Use the Cloudflare Workers example if your DNS is on Cloudflare, the Next.js middleware on Vercel, or an edge function or nginx rule on your host.
Tell Qinto the cookie name
Section titled “Tell Qinto the cookie name”Qinto reads your cookie only once it knows the name.
- In the Blotout platform, open your site, then Settings → Capturing data.
- Under Recover visitors with your own cookie, enter the cookie name and select Save cookie name.
The site redeploys with the new setting on its own, which takes a moment. A page that connects before the redeploy finishes still works, it just does not use the cookie yet.
The same setting is available through the Platform API as the fallbackCookie field of
Update site settings. It needs a team API token.
curl -X PATCH https://qinto-api.blotout.io/teams/{teamId}/domains/{domainId}/settings \ -H "Authorization: Bearer $QINTO_API_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "fallbackCookie": "site_visitor" }'Send null to turn recovery off. Renaming the cookie starts over: pairings Qinto recorded under the old name do not
carry to the new one.
Check that it worked
Section titled “Check that it worked”- Open your site in a private window. In the browser’s developer tools, under Application → Cookies in Chrome or Storage → Cookies in Safari and Firefox, find your cookie. It must show your root domain in the Domain column, an expiry about 400 days out, and HttpOnly checked. A cookie with your full hostname as the domain, or an expiry a week out, does not meet the requirements above.
- In the console, run
await qinto.getVisitor()and note theanonId. - Still in developer tools, delete every cookie whose name starts with
q_, but keep your own cookie. Reload the page and runawait qinto.getVisitor()again. TheanonIdis the same one. Without recovery it would be a new id.
The second check only passes for a visitor whose first visit happened with your cookie in place. A visitor Qinto already knew before you added the cookie is paired with it on a later visit, and from then on is recognised through it like everyone else.
Coming from EdgeTag
Section titled “Coming from EdgeTag”- The mechanism is the same. EdgeTag read a server-side cookie you named to recover a visitor, and Qinto does too. The cookie you set for EdgeTag works unchanged. Enter its name in the Blotout platform.
- WordPress is not automatic. EdgeTag’s WordPress app set the cookie for you. In Blotout, add the plugin from the WordPress tab. For Shopify, use the Blotout Shopify app or the customer id in your custom pixel, see Hosted platforms.
- The value never becomes an id. EdgeTag mapped your cookie to its own id behind the scenes, and so does Qinto. Your cookie value appears in no event, trait or app.