Serve Tracking From Your Own Server
Your AxiTrace tracking hostname is a name on your domain, but it points at our infrastructure. Safari notices that and shortens the visitor cookie to seven days. This guide shows you how to make that hostname answer from your own web server instead, which removes the reason Safari shortens it. It needs one DNS record, one rule on your web server, and two lines changed in your AxiTrace snippet.
What the problem is
When you set up a tracking domain, you added a CNAME record: a name on your domain,
axi.example.com, that forwards to our infrastructure. The name is yours.
The machine that answers is ours. That gap is what Safari looks for.
Safari's browser engine, WebKit, shortens the lifetime of a cookie set in an HTTP response to seven days when the request is a first-party subresource and either of these is true:
- the subresource resolves through a CNAME that differs from your own domain and from your main hostname's own CNAME, or
- there is no CNAME at all, and the address that answered shares fewer than 16 leading bits with the address of the page the visitor is looking at (64 bits for IPv6).
The address comparison only runs when there is no CNAME. When a CNAME is present, the first rule decides the outcome and the addresses are never compared.
Two things about this are worth being precise about, because they are often described wrongly:
- It is a shortened expiry, not a block. The cookie is still set, still sent, and still works. It just dies sooner. A Safari visitor who comes back after eight days looks like a new visitor rather than a returning one.
-
HttpOnlydoes not exempt a cookie from it. The only cookie the rule leaves alone is a session-only cookie, one with no expiry at all, which dies when the browser closes. That is not an improvement. The page a visitor is actually looking at is never affected either; the rule only applies to subresource responses, which is what a tracking request is.
We measured every AxiTrace customer tracking domain on 19 September 2026. Every one of them is a CNAME onto our infrastructure, which means every one of them is shortened on Safari. This is not a misconfiguration on your side and it is not something a setting in AxiTrace can change. It is a property of where the hostname points.
Browsers this affects
| Browser | Behaviour |
|---|---|
| Safari, macOS and iOS | Shortens the cookie to seven days under the conditions above. |
| Every browser on iOS and iPadOS | Chrome, Firefox, Edge and the rest all use WebKit on iOS, so they behave like Safari there. |
| Chrome and Firefox on desktop and Android | No equivalent rule. Both stop at 400 days, which is their ceiling for any cookie. There is nothing to gain here for those browsers. |
Safari separately removes storage that page scripts wrote, which covers
localStorage and any cookie set by JavaScript, after a period without visitor
interaction with your site. That sweep is a different mechanism with different rules, and
this guide does not touch it. What this guide fixes is the expiry of cookies our server
sets in its HTTP response. Expect an improvement in how long a Safari visitor stays
recognisable, not the end of every Safari limitation.
Who this is for, and who cannot use it
You can do this if you have both of the following:
- the ability to add a DNS record on your domain, and
- the ability to add a rule to your web server, your CDN or your hosting platform that forwards a few request paths somewhere else. That means nginx, Apache or Caddy on your own server; a Cloudflare Worker, an AWS CloudFront distribution, a Vercel or Netlify edge function or a Deno Deploy handler on a platform that already hosts your site; or a hosting control panel that exposes reverse proxy rules. There is a table of all nine further down.
One of the two is not enough. A DNS record alone points visitors at a server that does not know what to do with the request.
We are saying so here rather than letting you find out halfway through. A Shopify storefront
runs on Shopify's own web servers, and you cannot add a reverse proxy rule to them. The one
forwarding mechanism Shopify does offer, App Proxy, strips the Cookie request
header and the Set-Cookie response header by design, so a cookie could not
travel through it in either direction even if the routing worked.
There is no workaround on a Shopify-hosted storefront. If you run your own storefront and use Shopify only for something else, the rest of this guide still applies to the storefront you control.
Other cases, briefly:
| Your setup | Can you do this? |
|---|---|
| WooCommerce, Shopware, Magento or a custom site on your own nginx or Apache | Yes. This is who the guide is written for. |
| Behind Cloudflare or another CDN | Yes, with a Worker or an equivalent origin rule. Read the note about the visitor address in the four things every rule must get right before you start, because getting it wrong there is worse than the cookie problem you came to fix. |
| Your site is hosted on Vercel, Netlify, CloudFront or Deno Deploy | Yes, on that same platform, with an edge function. Doing it on one of those platforms while your site is served from somewhere else does not work, for a reason worth understanding before you try: see the two groups. |
| Managed or shared hosting with no server configuration access | Only if your host exposes reverse proxy rules, or you can run PHP at your document root. There is a PHP fallback below for the second case. |
| Shopify-hosted storefront | No. See above. |
The three configurations
Three ways a tracking hostname can be wired. The first is what you have now. The other two both solve the problem, and they differ in how invasive they are, not in how well they work.
| Configuration | What you do | What it costs | What it gives |
|---|---|---|---|
| Today: CNAME to AxiTrace | Nothing. This is the setup you already have. | Nothing further. | Tracking works everywhere. On Safari the visitor cookie is shortened to seven days. |
| Subdomain on your own server Recommended | Add one DNS record for a new subdomain that resolves exactly like your main site, add a proxy rule for it, change two lines in your snippet. | One web server change. Your TLS certificate has to cover the new name. Your server is now in the path of every tracking request. | Safari keeps the full expiry. Nothing about your main site's own configuration changes, so the blast radius of a mistake is tracking, not your store. |
| Paths on your main domain | Add the same proxy rule to the virtual host that already serves your store, for the same paths at its root. No new DNS record, no new certificate name. |
The same change, but on the hostname that serves your store. One of the paths,
/v1, can collide with your own application. Check that first.
|
The same result. Choose this when you cannot add a subdomain, not by default. |
What matters is that the tracking hostname resolves to the same place your main site resolves to: the same A record, or the same CNAME target. A subdomain that does that is answered by your own server, so neither Safari rule has anything to fire on. It is the cheaper and less invasive of the two, and it is what we recommend. Putting the paths on your main domain is equivalent, not better.
Prerequisites
- A tracking domain already connected and Live on your workspace. The hostname we provisioned for you is what your proxy will forward to, so it has to exist and work before you start. See Domain Configuration if you have not set one up.
- Access to your DNS records and to your web server or CDN configuration.
- A TLS certificate on your own server that covers the hostname visitors will request. If you use Let's Encrypt, that is one more name on the certificate.
Throughout this page:
| Example value | What it stands for |
|---|---|
www.example.com |
The hostname your store actually serves on. |
axi.example.com |
The AxiTrace tracking hostname already provisioned for your workspace, the one with the CNAME to us. It stays exactly as it is and becomes the address your proxy forwards to. |
t.example.com |
The new hostname you are about to create, served by your own web server. Only used in the subdomain configuration. |
Point a hostname at your own server
Subdomain configuration. Look up what your main site resolves to:
dig +short www.example.com
Then create the new hostname so that it resolves to the same thing. If your main site is a plain A record, add an A record with the same address. If your main site is itself a CNAME, for example because it sits behind a CDN, add a CNAME with the same target. Either way the two names must land in the same place.
Behind Cloudflare? Then dig prints Cloudflare's addresses, not
your server's, and a record pointing at them is refused. Follow
the DNS step in the Cloudflare section instead.
| Type | Name | Value |
|---|---|---|
A |
t |
The same address www resolves to |
Main domain configuration. Nothing to do here. Visitors already reach your own
server on www.example.com, which is the whole point of this variant.
axi.example.com keeps its CNAME to us and keeps working exactly as before. Your
proxy forwards to it. Do not repoint it at your own server and then also use it as the proxy
target: the server would be forwarding requests to itself, which loops until something times
out. Create a new name, or use paths on your main domain.
Add the proxy rule
Which paths to proxy
AxiTrace serves sixteen paths on a tracking hostname. Seven of them are prefixes, which means everything below them counts too.
| Match | Paths |
|---|---|
| Exact |
/axitrack.js, /axitrack.min.js,
/velitrack-sdk.js, /axitrack-triggers.js,
/axitrack-surveys.js,
/axitrack-tester.js, /sdk-config, /health,
/ready
|
| Prefix |
/track, /v1, /shopify,
/woocommerce, /magento, /shopware,
/surveys
|
Surveys need both /axitrack-surveys.js, the lazily loaded widget, and
/surveys/*, including /surveys/manifest. Keep query strings intact:
the manifest uses the public key, version and preview token. Forward responses without
adding CDN or application caching; preserve the upstream cache headers. Survey answers
use the existing /track endpoint.
When upgrading an existing proxy, update both its web-server routing and any independent
path allow-list in PHP, a Worker or an edge function. Adding only a rewrite rule still
leaves the other layer returning 404. On shared hosting, edit the project-root
.htaccess when it rewrites into public/; use the document-root
example below when the host points directly at public/.
/health and /ready are status endpoints. Nothing in the SDK requests
them, but you need /health for the check in Step 4, so the examples below include
both. You can remove them once you are done.
/v1 against your own application first
If your site already serves an API at /v1, a rule for that prefix takes it over.
On a subdomain that is harmless, because nothing else lives there. On your main domain it
will break whatever was using it. This is the one path worth checking before you apply
anything.
Then your tracking hostname serves more than the sixteen paths above, because your GTM
container claims its own. An allow-list would silently drop those. Proxy
everything on the hostname instead, which means the subdomain
configuration: one rule for / rather than a list. See
Server-Side GTM Hosting.
Four things every rule must get right
Each of these is a line in the examples below. A configuration nobody understands is a configuration nobody can debug, so here is what each one is for.
Send your AxiTrace tracking hostname as the upstream Host header
Not your main hostname, and not one of ours. Our routing picks the right workspace route by
matching on that header, so a request carrying anything else does not reach the tracking API at
all. The header also decides the Domain attribute on the cookie we set: with
Host: axi.example.com the cookie is scoped to .example.com, which is
valid on every hostname you own.
Sending one of our own hostnames instead is the worst case, because it fails silently. Routing would work, the cookie would come back scoped to a domain that is not yours, and the browser would discard it without an error anywhere. You would have traded a seven day cookie for no cookie at all.
Set X-Forwarded-For to the visitor's address, do not append to it
We read the visitor's address from that header. Most proxy defaults append to whatever the client sent, which lets a visitor put a value in front of their own and have it believed. Your proxy is where that can be closed: write the address your server actually saw, discarding any inbound value.
If you are behind Cloudflare or another CDN, the address your server sees is
the CDN edge, not the visitor. Use the header the CDN provides instead, such as
CF-Connecting-IP. Getting this wrong collapses your entire audience onto a handful
of addresses, which is a worse regression than the cookie problem, and it is silent. Step 4
checks for exactly this.
Send TLS with SNI matching the hostname you are forwarding to
Our load balancer serves a separate certificate per hostname and decides which one to present
from the name in the TLS handshake. If your proxy opens the connection without that name, or
with a different one, the handshake fails and every tracking request dies before it is even
sent. In most servers this follows automatically from forwarding to
https://axi.example.com; where it does not, the examples set it explicitly.
Pass the response through untouched
Two headers in particular. The CORS headers, which we already set correctly: if
your proxy adds its own, the browser sees two and rejects the response outright. Your workspace
already allows requests from any hostname sharing your registrable domain, so there is nothing
to change on our side and nothing to add on yours. And the ETag on
/axitrack.js, which is how browsers learn that a new build of the SDK has
shipped. Rewriting or stripping it leaves visitors on a stale copy. For the same reason, do not
enable proxy caching: /sdk-config deliberately answers with no-store
when a consent policy cannot be established, and a cache would defeat that.
Find your platform
Nine ways to do this, and they fall into two groups that are not interchangeable. The difference is not how much configuration they need. It is where the response comes from, which is the entire point of the exercise.
Group one: the proxy runs on a server you control. The tracking response leaves the same machine, the same address and the same network as your pages. Both of the conditions described at the top of this page are removed. These work whatever else your stack looks like.
| Platform | What it takes | Worth knowing |
|---|---|---|
| nginx | A config file: one snippet plus location blocks. |
Nothing special. This is the reference implementation on this page. |
| Apache | A config file: directives inside your existing VirtualHost. |
Needs mod_proxy, mod_proxy_http, mod_ssl and
mod_headers. ProxyPass prefixes are unanchored.
|
| Caddy | A config file: one matcher and one reverse_proxy block. |
You must write the Host override by hand. Caddy passes the visitor's
Host through by default, which is the one thing that must not happen.
|
| PHP on shared hosting | An FTP upload and a few .htaccess lines. No server access needed. |
The weakest option here: every tracking request runs through PHP. It is also the only one that works when you cannot edit a virtual host. |
Group two: the proxy runs on the platform that already hosts your site. These are correct only under that condition, and the condition is not a detail. Read the warning under the table before you pick one.
| Platform | What it takes | Limitation |
|---|---|---|
| Cloudflare | A Worker, or an Origin Rule from the dashboard. | An Origin Rule cannot shape request headers, so it cannot correct the visitor address. Prefer the Worker. |
| AWS CloudFront | Dashboard settings, plus a CloudFront Function. |
CloudFront appends to X-Forwarded-For and no setting
changes that, so the function is required rather than optional.
|
| Vercel | An edge function. vercel.json alone cannot do it. |
Configuration cannot set X-Forwarded-For at all: the header map takes
literal values and nothing derived from the request.
|
| Netlify | An Edge Function. netlify.toml alone cannot do it. |
The same reason as Vercel, plus a 26 second ceiling on proxied requests. |
| Deno Deploy | A handler, plus one specific DNS record type. |
The DNS record must be an A or ALIAS record. A
CNAME onto a Deno hostname recreates the exact problem you are fixing.
|
These five terminate the visitor's connection on their own network. If your pages come from your own server, or from anywhere other than the platform you put in front of the tracking hostname, then the tracking hostname answers from the provider's addresses while your pages answer from yours. Different address, different network, different place where TLS ends. That is the same shape this page exists to warn about: the name is yours, the machine is somebody else's.
It is worth being blunt about what that configuration looks like, because it looks like success. Every check in Step 4 passes. /health returns 200, events arrive, the visitor address is correct. And the cookie is still shortened, because nothing about where the response came from actually changed. You have added a hop and a bill and fixed nothing. If your site is not hosted on the platform, use a subdomain on your own server instead.
nginx
Save the shared settings once as /etc/nginx/snippets/axitrace-proxy.conf:
# /etc/nginx/snippets/axitrace-proxy.conf
proxy_pass https://$axitrace_host$request_uri;
proxy_http_version 1.1;
proxy_set_header Connection "";
# Rule 1: the Host header selects the AxiTrace route AND decides the cookie
# domain. $axitrace_host is YOUR tracking subdomain, set in the server block.
proxy_set_header Host $axitrace_host;
# Rule 3: SNI must carry the same name, or the TLS handshake fails.
proxy_ssl_server_name on;
proxy_ssl_name $axitrace_host;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
# Rule 2: set the visitor address, never append. $proxy_add_x_forwarded_for
# would keep a value the visitor forged.
# BEHIND A CDN, $remote_addr is the CDN edge and not the visitor: use
# $http_cf_connecting_ip, or configure ngx_http_realip_module first.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-IP "";
# Rule 4: nothing here touches ETag, Cache-Control or the CORS headers, and
# no proxy_cache is configured. Keep it that way.
proxy_buffering off;
proxy_request_buffering off;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
Then, in the server { } block that serves t.example.com over HTTPS
(or, for the main domain variant, the one that already serves your store):
# Replace axi.example.com with YOUR AxiTrace tracking hostname. It must be a
# subdomain of your own domain. Never put one of our hostnames here: the cookie
# would come back scoped to a domain you do not own and be discarded silently.
resolver 1.1.1.1 8.8.8.8 valid=60s ipv6=off;
set $axitrace_host "axi.example.com";
gzip off;
# Exact paths
location = /axitrack.js { include snippets/axitrace-proxy.conf; }
location = /axitrack.min.js { include snippets/axitrace-proxy.conf; }
location = /velitrack-sdk.js { include snippets/axitrace-proxy.conf; }
location = /axitrack-triggers.js { include snippets/axitrace-proxy.conf; }
location = /axitrack-surveys.js { include snippets/axitrace-proxy.conf; }
location = /axitrack-tester.js { include snippets/axitrace-proxy.conf; }
location = /sdk-config { include snippets/axitrace-proxy.conf; }
location = /health { include snippets/axitrace-proxy.conf; }
location = /ready { include snippets/axitrace-proxy.conf; }
# Prefix paths. "= /track" plus "^~ /track/" matches whole path segments, the
# way our own routing does. A bare "location /track" would also swallow
# /trackpants.
location = /surveys { include snippets/axitrace-proxy.conf; }
location ^~ /surveys/ { include snippets/axitrace-proxy.conf; }
location = /track { include snippets/axitrace-proxy.conf; }
location ^~ /track/ { include snippets/axitrace-proxy.conf; }
# CHECK FIRST: if your own application serves /v1, this takes it over.
location = /v1 { include snippets/axitrace-proxy.conf; }
location ^~ /v1/ { include snippets/axitrace-proxy.conf; }
location ^~ /shopify/ { include snippets/axitrace-proxy.conf; }
location ^~ /woocommerce/ { include snippets/axitrace-proxy.conf; }
location ^~ /magento/ { include snippets/axitrace-proxy.conf; }
location ^~ /shopware/ { include snippets/axitrace-proxy.conf; }
Two details in there are deliberate. proxy_pass uses a variable rather than a
literal hostname, because with a literal one nginx resolves the address once at start-up and
keeps it for the life of the process, so a change on our side would strand you until your next
reload; the variable form re-resolves on the resolver TTL, and $request_uri has to
be appended explicitly in that form. And gzip off stops nginx compressing a
response we already encoded.
If you run our managed server-side GTM container on this hostname, replace the whole list of
location blocks with a single location / { include
snippets/axitrace-proxy.conf; }, and use a dedicated subdomain so nothing else is
affected.
Apache
# Add inside the existing <VirtualHost *:443> that serves the hostname
# visitors will request.
# Needs: mod_proxy, mod_proxy_http, mod_ssl, mod_headers
# a2enmod proxy proxy_http ssl headers
#
# Replace axi.example.com with YOUR AxiTrace tracking hostname. It must be a
# subdomain of your own domain, never one of ours.
SSLProxyEngine On
SSLProxyCheckPeerName On
SSLProxyCheckPeerCN On
# Rule 1 and Rule 3. ProxyPreserveHost Off is the default and is REQUIRED:
# it makes Apache send Host: axi.example.com, which selects the AxiTrace route
# and sets the cookie domain to .example.com. mod_ssl then takes the SNI from
# the same name. With it On, Apache forwards your own hostname instead, no
# route matches, and tracking stops.
ProxyPreserveHost Off
# Rule 2: unset the inbound header first, so mod_proxy writes exactly the peer
# address instead of appending to a value the visitor forged.
# BEHIND A CDN the peer is the CDN edge: configure mod_remoteip
# (RemoteIPHeader CF-Connecting-IP) BEFORE these lines, or every visitor
# collapses onto a few addresses.
RequestHeader unset X-Forwarded-For
RequestHeader unset X-Client-IP
# Rule 4: do not add Cache-Control, ETag or CORS headers here, and leave
# mod_cache off.
# Exact paths
ProxyPass /axitrack.js https://axi.example.com/axitrack.js
ProxyPassReverse /axitrack.js https://axi.example.com/axitrack.js
ProxyPass /axitrack.min.js https://axi.example.com/axitrack.min.js
ProxyPassReverse /axitrack.min.js https://axi.example.com/axitrack.min.js
ProxyPass /velitrack-sdk.js https://axi.example.com/velitrack-sdk.js
ProxyPassReverse /velitrack-sdk.js https://axi.example.com/velitrack-sdk.js
ProxyPass /axitrack-surveys.js https://axi.example.com/axitrack-surveys.js
ProxyPassReverse /axitrack-surveys.js https://axi.example.com/axitrack-surveys.js
ProxyPass /axitrack-triggers.js https://axi.example.com/axitrack-triggers.js
ProxyPassReverse /axitrack-triggers.js https://axi.example.com/axitrack-triggers.js
ProxyPass /axitrack-tester.js https://axi.example.com/axitrack-tester.js
ProxyPassReverse /axitrack-tester.js https://axi.example.com/axitrack-tester.js
ProxyPass /sdk-config https://axi.example.com/sdk-config
ProxyPassReverse /sdk-config https://axi.example.com/sdk-config
ProxyPass /health https://axi.example.com/health
ProxyPassReverse /health https://axi.example.com/health
ProxyPass /ready https://axi.example.com/ready
ProxyPassReverse /ready https://axi.example.com/ready
# Prefix paths
ProxyPass /surveys https://axi.example.com/surveys
ProxyPassReverse /surveys https://axi.example.com/surveys
ProxyPass /track https://axi.example.com/track
ProxyPassReverse /track https://axi.example.com/track
# CHECK FIRST: if your own application serves /v1, this takes it over.
ProxyPass /v1 https://axi.example.com/v1
ProxyPassReverse /v1 https://axi.example.com/v1
ProxyPass /shopify https://axi.example.com/shopify
ProxyPassReverse /shopify https://axi.example.com/shopify
ProxyPass /woocommerce https://axi.example.com/woocommerce
ProxyPassReverse /woocommerce https://axi.example.com/woocommerce
ProxyPass /magento https://axi.example.com/magento
ProxyPassReverse /magento https://axi.example.com/magento
ProxyPass /shopware https://axi.example.com/shopware
ProxyPassReverse /shopware https://axi.example.com/shopware
Apache treats a ProxyPass path as a prefix, so /track covers
/track/anything as our own routing does. It also covers /trackpants;
if a path like that exists on your site, wrap the rules in <LocationMatch>
with an anchored pattern instead.
Caddy
Caddy runs on your own server, so it is in the first group: it fixes the problem whatever else
your stack looks like. One matcher and one reverse_proxy block do the whole job.
# Add to the site block that serves the hostname visitors will request:
# t.example.com for the subdomain configuration, or your store hostname for
# the main domain configuration.
#
# Replace axi.example.com with YOUR AxiTrace tracking hostname, the one with
# the CNAME to us. It must be a subdomain of your own domain, never one of
# ours: the cookie would come back scoped to a domain you do not own and the
# browser would discard it without saying so.
t.example.com {
# Every path on ONE line. Inside a single named matcher, two "path" lines
# are AND'ed and would match nothing at all; multiple arguments to ONE
# "path" are OR'ed, which is what makes this work. "path" is an exact
# match, so each prefix is written as a pair: /track AND /track/*.
# CHECK FIRST: if your own application serves /v1, this takes it over.
@axitrace path /axitrack.js /axitrack.min.js /velitrack-sdk.js /axitrack-triggers.js /axitrack-surveys.js /axitrack-tester.js /sdk-config /health /ready /surveys /surveys/* /track /track/* /v1 /v1/* /shopify /shopify/* /woocommerce /woocommerce/* /magento /magento/* /shopware /shopware/*
handle @axitrace {
reverse_proxy https://axi.example.com {
# Rule 1. Caddy passes the VISITOR's Host through by default, so
# this line is the whole ball game. Write the name literally.
# Do NOT use {upstream_hostport}: it appends :443, and the route
# would not match.
header_up Host axi.example.com
# Rule 2. Set, not append. "+X-Forwarded-For" would append.
# Caddy already discards an inbound X-Forwarded-* by default,
# which is the behaviour you want; this makes it explicit.
header_up X-Forwarded-For {remote_host}
# Rule 3. Forwarding to an https:// hostname already gives SNI
# and certificate verification that name. These two lines state
# it rather than relying on it.
transport http {
tls
tls_server_name axi.example.com
}
}
}
handle {
# Your existing site configuration stays here, untouched.
# root * /var/www/html
# file_server
}
}
Host line even though the documentation says you no longer have to
This is the one line that is easy to leave out and expensive to leave out. Caddy's documentation states that it passes incoming headers through to the backend unmodified, Host included, with only the X-Forwarded-* headers as exceptions. Since v2.11.0 the same page says the rewrite now happens automatically when the upstream is HTTPS.
Do not rely on that. Issue caddyserver/caddy#7565 reports the automatic rewrite not happening on v2.11.2, where it had worked on 2.10, with the explicit header_up Host as the confirmed workaround. Writing it by hand is correct on every 2.x version, costs one line, and turns a silent 404 into a non-event.
Two more things not to do.
Do not add trusted_proxies. It is a global servers
option rather than a reverse_proxy subdirective, and its effect is to make Caddy
trust and preserve an inbound X-Forwarded-For. That is the exact opposite of
rule 2: a visitor could then put any address in front of their own and have it believed.
Do not add a header_down. Caddy leaves the response alone unless
you tell it not to, so Set-Cookie (however many there are), ETag and
the CORS headers all survive untouched, and OPTIONS is forwarded like any other
method because nothing here filters on one. The standard Caddy build has no response cache,
so there is nothing to switch off either.
Cloudflare
For a store whose site is already behind Cloudflare. That is the condition described above, and on Cloudflare it is usually already met, because a site behind Cloudflare is answered by the same edge the tracking hostname would be.
On Cloudflare the hostname is proxied through the edge, so a Worker can do the forwarding before
anything reaches your origin. Create a Worker (Workers & Pages → Create →
Start with Hello World), replace its code with the code below, set
AXITRACE_HOST and deploy it. Then give it routes as described
under the code: the routes decide which requests ever reach it,
and getting them wrong is the one way this setup can affect the rest of your store.
// Replace with YOUR AxiTrace tracking hostname. Rule 1 and Rule 3 are both
// satisfied by this one value: the upstream URL's hostname is what sets the
// Host header and what Cloudflare uses for TLS SNI. Do not override either.
const AXITRACE_HOST = "axi.example.com";
const EXACT_PATHS = new Set([
"/axitrack.js",
"/axitrack.min.js",
"/velitrack-sdk.js",
"/axitrack-triggers.js",
"/axitrack-surveys.js",
"/axitrack-tester.js",
"/sdk-config",
"/health",
"/ready",
]);
// CHECK FIRST: if your own application serves /v1, this takes it over.
const PREFIX_PATHS = [
"/surveys",
"/track",
"/v1",
"/shopify",
"/woocommerce",
"/magento",
"/shopware",
];
function isTrackingPath(pathname) {
if (EXACT_PATHS.has(pathname)) return true;
return PREFIX_PATHS.some(
(p) => pathname === p || pathname.startsWith(p + "/"),
);
}
export default {
async fetch(request) {
const url = new URL(request.url);
if (!isTrackingPath(url.pathname)) {
return fetch(request);
}
const headers = new Headers(request.headers);
// Rule 1: the copied headers include the Host your visitor requested.
// Inside one Cloudflare zone a Host header can redirect the subrequest,
// so drop it and let the upstream URL below set it.
headers.delete("Host");
// Rule 2: CF-Connecting-IP is the visitor. Set, never append, and drop
// anything the visitor sent under these names.
const visitorIp = request.headers.get("CF-Connecting-IP");
if (visitorIp) {
headers.set("X-Forwarded-For", visitorIp);
headers.set("X-Real-IP", visitorIp);
}
headers.delete("X-Client-IP");
const upstream = new URL(
url.pathname + url.search,
"https://" + AXITRACE_HOST,
);
const response = await fetch(upstream.toString(), {
method: request.method,
headers,
body: request.body,
redirect: "manual",
// Rule 4: no edge caching. /sdk-config answers no-store on purpose.
cf: { cacheEverything: false },
});
// Rule 4: reuse the upstream response as-is. This keeps Set-Cookie, ETag,
// Cache-Control and the CORS headers exactly as we sent them. Building a
// fresh Headers object here is how those get lost.
return new Response(response.body, response);
},
};
Routes and DNS
Leave the DNS record of your existing AxiTrace tracking hostname exactly as it is: a
CNAME to us with Proxy status DNS only. The Worker forwards to it.
What you add depends on the configuration you chose.
Subdomain configuration. In DNS → Records, add a
CNAME named t whose target is your store's own hostname, for example
example.com, with Proxy status Proxied. Do not copy the addresses
dig printed in Step 1: behind Cloudflare those are Cloudflare's addresses, and a
record pointing at them is refused. Cloudflare answers the proxied name from the same addresses
as your store and issues its certificate automatically. Then add one route to the Worker, in
Settings → Domains & Routes → Add → Route:
t.example.com/*
Main domain configuration. No DNS change. Add these routes, one per line, with
your store's hostname in place of www.example.com. The trailing * is
required: a Cloudflare route without it does not match a URL with a query string, and
/sdk-config always has one.
www.example.com/axitrack*
www.example.com/velitrack-sdk.js*
www.example.com/sdk-config*
www.example.com/health*
www.example.com/ready*
www.example.com/track*
www.example.com/surveys/*
www.example.com/v1/*
www.example.com/shopify/*
www.example.com/woocommerce/*
www.example.com/magento/*
www.example.com/shopware/*
Routes can only end in a wildcard, so some of them are broader than the path list:
/track* also matches a page such as /tracking-info. The Worker checks
the exact list and hands everything else back to your store untouched, so that is harmless.
www.example.com/*
It would work, and it would run the Worker on every page, image and stylesheet of your store. Each run counts against your Workers request allowance, and when the daily allowance of the Workers Free plan runs out, a route set to Fail closed answers with a Cloudflare error page instead. On a catch-all route that page is your whole store. With the routes above, the worst case is that tracking pauses until the allowance resets at midnight UTC. Set each route to Fail open so the store answers those requests itself in that case.
Size the plan before you rely on the free one. Every page view costs several Worker
requests: the SDK, its configuration and one /track request per event, so count
about four per page view. The Workers Free plan allows 100,000 requests a day, which stops
being comfortable at around 20,000 page views a day. Above that, use Workers Paid.
Cloudflare's Origin Rules can do the same thing from the dashboard: match the paths listed
above, override the destination to your AxiTrace tracking hostname, and override the
Host header to the same value. Leave every caching rule off for those paths.
Because a rule cannot shape request headers the way the Worker does, run the visitor address
check in Step 4 carefully before you rely on it.
AWS CloudFront
For a store whose site is already served through CloudFront. Add a cache behaviour to the distribution you already have rather than creating a new one; a distribution that exists only for the tracking hostname is the group two mistake described above, with an AWS bill attached.
Create an origin whose Origin domain is your AxiTrace tracking hostname, then a cache behaviour per path pattern from the list pointing at it. The settings that matter:
| Setting | Value | Why |
|---|---|---|
| Origin domain | axi.example.com |
Rules 1 and 3 together. CloudFront sets the upstream Host to the
origin domain name, and the origin certificate is validated against that same
name, so both follow from this one field.
|
| Origin protocol policy | HTTPS only | We do not serve tracking over plaintext. |
| Origin request policy |
AllViewerExceptHostHeaderb689b0a8-53d0-40ab-baf2-68738e2966ac
|
Forwards every viewer header, cookie and query string, but replaces the viewer's
Host with the origin domain name. That is rule 1.
|
| Cache policy |
CachingDisabled4135ea2d-6df8-44a3-9df3-4b5a84be39ad
|
Rule 4. /sdk-config deliberately answers no-store when a
consent policy cannot be established, and a cache would defeat that.
|
| Allowed HTTP methods | All seven, with Cache OPTIONS responses unchecked |
A tracking subdomain against a page on your main hostname is same-site but
cross-origin, so real CORS preflights happen and OPTIONS has to reach
us with its Access-Control-Request-* headers intact.
|
| Response headers policy | None | Rule 4 again. We already send the CORS headers; a policy that adds its own gives the browser two of each and the response is rejected outright. |
The two names are one word apart and the wrong one breaks routing. AllViewer (216adef6-5c7f-47e4-b989-5492eafa07d3) forwards all viewer headers, and that includes Host. Your own hostname then arrives on our side, no route matches it, and every tracking request returns 404. The policy you want is AllViewerExceptHostHeader, which is documented as replacing the viewer Host with the origin domain name.
Now the part that no setting can do for you.
// CloudFront Function, runtime cloudfront-js-2.0, event type: VIEWER REQUEST.
//
// Rule 2, and on CloudFront it is not optional. CloudFront APPENDS the viewer
// address to any X-Forwarded-For the client sent, and there is no setting that
// makes it overwrite instead. We read the first public entry in that header,
// so without this function a visitor who sends their own X-Forwarded-For is
// believed and yours is ignored.
//
// Header names are lowercase here, and values are objects: {value: "..."}.
function handler(event) {
var request = event.request;
request.headers['x-forwarded-for'] = { value: event.viewer.ip };
return request;
}
Host is read-only in a viewer request function, so a CloudFront Function cannot fix rule 1 and the managed origin request policy above is the only thing that can. And X-Forwarded-Proto and X-Real-IP are on the disallowed list for edge functions: touching either returns 502, which is why the function above sets exactly one header and stops.
One thing we could not settle from the documentation: whether CloudFront's own append runs before or after your viewer request function. If it runs after, the origin receives the address twice, which is harmless for us because both entries are the same address. Check 3 in Step 4 tells you what actually arrived, and it is the check to run first on CloudFront. If it looks wrong, the deterministic alternative is to add the CloudFront-Viewer-Address header through the origin request policy, which carries the address and port and can be read instead.
Vercel
For a store whose site is already deployed on Vercel. The tracking hostname is added as a domain on a Vercel project, so the response leaves Vercel's network exactly as your pages do.
vercel.json rewrite is not enough, and the reason is the visitor address
A rewrite to an external destination really does proxy rather than redirect, and it gets rules 1 and 3 right on its own: Vercel sends the destination hostname upstream, not your site's, and the TLS handshake follows it. So far so good.
What it cannot do is rule 2. A rewrite object accepts only source, destination, has and missing, and the header map elsewhere in vercel.json takes literal strings or an environment variable and nothing derived from the request. There is no expression anywhere in the file that evaluates to the visitor's address, so a plain rewrite sends us every visitor under whatever address the platform happens to put there. The function below is required, not a matter of taste.
Point the tracking hostname at a Vercel project of its own rather than at the project that serves your store. The proxy matcher then covers the whole hostname, the allow-list lives in one place, and nothing runs on your store's own page requests.
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"proxy": {
"entrypoint": "proxy.ts",
"matcher": "/:path*"
}
}
// proxy.ts, at the root of the project that serves your tracking hostname.
// Configured through the "proxy" property in vercel.json, which runs it on the
// Node.js runtime. You do not need a runtime export, and you should not add
// one: "export const runtime = 'edge'" is no longer supported as of
// Next.js 16.3.
import { ipAddress } from '@vercel/functions';
// Replace with YOUR AxiTrace tracking hostname, the one with the CNAME to us.
const UPSTREAM = 'https://axi.example.com';
const EXACT_PATHS = new Set([
'/axitrack.js',
'/axitrack.min.js',
'/velitrack-sdk.js',
'/axitrack-triggers.js',
'/axitrack-surveys.js',
'/axitrack-tester.js',
'/sdk-config',
'/health',
'/ready',
]);
// CHECK FIRST: if your own application serves /v1, this takes it over.
const PREFIX_PATHS = ['/surveys', '/track', '/v1', '/shopify', '/woocommerce', '/magento', '/shopware'];
function isTrackingPath(pathname: string): boolean {
if (EXACT_PATHS.has(pathname)) return true;
return PREFIX_PATHS.some((p) => pathname === p || pathname.startsWith(p + '/'));
}
export default async function proxy(request: Request): Promise<Response> {
const incoming = new URL(request.url);
if (!isTrackingPath(incoming.pathname)) {
return new Response('Not found', { status: 404 });
}
const target = new URL(incoming.pathname + incoming.search, UPSTREAM);
const headers = new Headers(request.headers);
// Rules 1 and 3. Do NOT try to set Host yourself: it is a forbidden header
// name for fetch and Node refuses it. Deleting the inbound one and fetching
// the target URL gives the correct Host AND the correct TLS SNI, both taken
// from the hostname in that URL.
headers.delete('host');
// Rule 2. Vercel sets x-forwarded-for itself and does not forward an
// external one, so this is the visitor and a client cannot forge it.
// ipAddress() reads it for you. Set, never append.
headers.set('x-forwarded-for', ipAddress(request) ?? '');
headers.set('x-forwarded-proto', 'https');
headers.set('x-forwarded-host', incoming.host);
const upstream = await fetch(target, {
method: request.method,
headers,
body: request.method === 'GET' || request.method === 'HEAD' ? undefined : request.body,
redirect: 'manual',
// @ts-expect-error required by undici when streaming a request body
duplex: 'half',
});
// Rule 4. Rebuilt deliberately, one Set-Cookie at a time, rather than passed
// through: see the note below on why copying the headers wholesale is a risk
// on these platforms.
const out = new Headers();
for (const [k, v] of upstream.headers) {
if (k.toLowerCase() !== 'set-cookie') out.append(k, v);
}
for (const c of upstream.headers.getSetCookie()) out.append('set-cookie', c);
// Vercel has honoured upstream cache-control on external rewrites by default
// for projects created on or after 6 April 2026. /sdk-config must not be
// cached, so say so.
out.set('cache-control', 'no-store');
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: out,
});
}
Netlify
For a store whose site is already deployed on Netlify, for the same reason as Vercel: the response has to leave the same network your pages leave.
netlify.toml redirect is not enough, and again the reason is the visitor address
A [[redirects]] entry with status = 200 pointed at an external https URL does proxy from Netlify's own CDN rather than redirecting the browser, and its headers map really does set upstream request headers rather than response headers. Rule 1 is handled without you doing anything: Netlify always sets the upstream Host to the hostname it connects to, which is exactly what we need, and it cannot be overridden even if you wanted to.
But that header map takes static literal values only. No expression in a redirect rule evaluates to the visitor's address, so rule 2 is out of reach from configuration. The Edge Function below is required.
// netlify/edge-functions/axitrace-proxy.ts
//
// An Edge Function rather than a redirect rule, because a redirect rule cannot
// put the visitor's address in a header. See the note above.
import type { Config, Context } from '@netlify/edge-functions';
// Replace with YOUR AxiTrace tracking hostname, the one with the CNAME to us.
const UPSTREAM = 'https://axi.example.com';
export default async (request: Request, context: Context) => {
const incoming = new URL(request.url);
const target = new URL(incoming.pathname + incoming.search, UPSTREAM);
const headers = new Headers(request.headers);
// Rules 1 and 3. Host cannot be set by hand here either, and does not need
// to be: fetching the target URL sets both the Host header and the TLS SNI
// from its hostname.
headers.delete('host');
// Rule 2. context.ip, not a header. Netlify adds no client headers to edge
// function requests, and x-nf-client-connection-ip is NOT available here -
// it belongs to the older Netlify Functions. Set, never append.
headers.set('x-forwarded-for', context.ip);
headers.set('x-forwarded-proto', 'https');
headers.set('x-forwarded-host', incoming.host);
// An explicit fetch is mandatory: context.next() can only rewrite to
// same-site URLs and cannot reach axi.example.com at all.
const upstream = await fetch(target, {
method: request.method,
headers,
body: request.method === 'GET' || request.method === 'HEAD' ? undefined : request.body,
redirect: 'manual',
});
// Rule 4, one Set-Cookie at a time. See the note below.
const out = new Headers();
for (const [k, v] of upstream.headers) {
if (k.toLowerCase() !== 'set-cookie') out.append(k, v);
}
for (const c of upstream.headers.getSetCookie()) out.append('set-cookie', c);
// Netlify's CDN caches a proxied response that carries an ETag or a
// Last-Modified, and /axitrack.js carries an ETag. Say no-store.
out.set('cache-control', 'no-store');
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: out,
});
};
// CHECK FIRST: if your own application serves /v1, this takes it over.
export const config: Config = {
path: [
'/axitrack.js',
'/axitrack.min.js',
'/velitrack-sdk.js',
'/axitrack-triggers.js',
'/axitrack-surveys.js',
'/axitrack-tester.js',
'/sdk-config',
'/health',
'/ready',
'/surveys',
'/surveys/*',
'/track',
'/track/*',
'/v1',
'/v1/*',
'/shopify/*',
'/woocommerce/*',
'/magento/*',
'/shopware/*',
],
method: ['GET', 'POST', 'OPTIONS'],
};
A proxied request times out after 26 seconds, which is far beyond anything we do, and an edge function gets 50 ms of CPU per request, which a proxy spends almost none of because it is waiting on the network rather than computing. Neither is a problem in practice, but the 26 seconds is a hard ceiling worth knowing about.
Netlify also documents Set-Cookie among the response headers it may override through its own cookie handling. That is the header this entire page exists to protect, so run check 6 in the verification list and check 4 in Step 4 on Netlify specifically, rather than assuming.
Every other example on this page hands the upstream response straight back, and says to, because building a fresh header set is how Set-Cookie gets lost. On Vercel and Netlify we tell you to do the opposite, and it is worth explaining the inconsistency rather than leaving you to wonder.
A response can carry more than one Set-Cookie, and in the header interface these runtimes use, the plain accessor joins them into a single comma-separated string. A browser reading that keeps the first cookie and discards the rest. There is an open report against Vercel describing exactly this collapse, which was never answered; Netlify documents nothing either way and lists Set-Cookie among the headers its own cookie handling may override. Appending each entry from getSetCookie() separately is the pattern that is correct whether or not the runtime would have collapsed them, which is why both functions do it.
Treat this as something to verify, not something we guarantee. Neither vendor documents the behaviour, so run check 6 in the verification list against your own deployment and count the Set-Cookie lines that actually come back. That check creates no events, so it is safe to run against a live store.
Deno Deploy
For a store whose site is already served by Deno Deploy. It has two conditions rather than one, and the second one is a DNS record type, which is not where anyone expects to find a decisive detail.
A or ALIAS record. A CNAME recreates the problem exactly.
Deno Deploy documents three ways to attach a custom domain: an ALIAS or ANAME record, an A record, or a CNAME onto a Deno hostname. The first two are fine. The third undoes the entire exercise.
Go back to the first condition at the top of this page: a subresource whose hostname resolves through a CNAME pointing off your own domain is shortened, and when a CNAME is present the addresses are never even compared. Pointing your tracking hostname at a Deno hostname with a CNAME is that condition, written out in full. You would have rebuilt the thing you were removing. Use the record values the Deno Deploy console shows you for the A or ALIAS method; they are generated per domain, so there is no address to copy from here. The separate _acme-challenge CNAME the docs ask for is harmless, because it sits on a different name and is never in your tracking hostname's own resolution path.
// main.ts, deployed as the entrypoint of a Deno Deploy app.
//
// Use Deno.serve() at the top level. It works whichever way the platform
// starts your entrypoint, which "export default { fetch }" does not.
// Replace with YOUR AxiTrace tracking hostname, the one with the CNAME to us.
const UPSTREAM = 'https://axi.example.com';
const EXACT_PATHS = new Set([
'/axitrack.js',
'/axitrack.min.js',
'/velitrack-sdk.js',
'/axitrack-triggers.js',
'/axitrack-surveys.js',
'/axitrack-tester.js',
'/sdk-config',
'/health',
'/ready',
]);
// CHECK FIRST: if your own application serves /v1, this takes it over.
const PREFIX_PATHS = ['/surveys', '/track', '/v1', '/shopify', '/woocommerce', '/magento', '/shopware'];
function isTrackingPath(pathname: string): boolean {
if (EXACT_PATHS.has(pathname)) return true;
return PREFIX_PATHS.some((p) => pathname === p || pathname.startsWith(p + '/'));
}
function visitorIp(req: Request, info: Deno.ServeHandlerInfo): string {
// Rule 2. Take the FIRST entry: that is the original client. See the
// warning below before you trust this in production.
const xff = req.headers.get('x-forwarded-for');
if (xff) return xff.split(',')[0].trim();
const addr = info.remoteAddr;
return addr.transport === 'tcp' ? addr.hostname : '';
}
Deno.serve(async (req, info) => {
const incoming = new URL(req.url);
if (!isTrackingPath(incoming.pathname)) {
return new Response('Not found', { status: 404 });
}
const headers = new Headers(req.headers);
// Rules 1 and 3. Deno's fetch SILENTLY IGNORES a Host header you set: the
// Headers object keeps the value, the wire does not, so a test that asserts
// on the object passes while nothing reached us. Delete the inbound one and
// let the target URL supply both the Host header and the TLS SNI.
headers.delete('host');
headers.delete('accept-encoding');
const ip = visitorIp(req, info);
if (ip) headers.set('x-forwarded-for', ip);
else headers.delete('x-forwarded-for');
headers.set('x-forwarded-proto', 'https');
headers.set('x-forwarded-host', incoming.host);
const upstream = await fetch(new URL(incoming.pathname + incoming.search, UPSTREAM), {
method: req.method,
headers,
body: req.method === 'GET' || req.method === 'HEAD' ? null : req.body,
redirect: 'manual',
// @ts-ignore required by the spec when streaming a request body
duplex: 'half',
});
// Rule 4. Copying the headers preserves every Set-Cookie, the ETag and the
// CORS headers. Two deletions are MANDATORY, and this is the single most
// surprising line on this page:
const out = new Headers(upstream.headers);
out.delete('content-encoding');
out.delete('content-length');
// Deno Deploy has its own CDN cache in FRONT of your app. A GET with no
// Set-Cookie is cacheable there, which would break /sdk-config.
out.set('cache-control', 'no-store');
out.set('deno-cdn-cache-control', 'no-store');
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: out,
});
});
Deno's fetch decompresses the response body for you but leaves Content-Encoding: gzip and the compressed Content-Length on the headers it hands you. Copy those through and the browser receives a body that has already been decoded, truncated to the compressed length, and labelled as gzip. It is not a subtle failure once you look, but nothing about the code that causes it looks wrong, and the obvious one-line passthrough everyone writes has this bug.
Delete content-encoding and content-length and the response is correct. Check 3 in the verification list, which compares the SDK file byte for byte against the one served directly, is the check that catches this. Run it on Deno Deploy before anything else.
Deno Deploy does not publish an authoritative client address header anywhere in its documentation, and it is the only platform on this page of which that is true. The function above reads the first entry of x-forwarded-for and falls back to remoteAddr, which is the sensible order, but on a platform that does not document what it sets, a client-supplied header could in principle be believed.
So check 3 in Step 4 is not optional here. Load a page, look at what address the event carried, and confirm it is yours rather than a Deno internal address. If you cannot confirm it, do not use this platform for tracking.
Shared hosting, end to end: FTP and PHP only
This section is for the merchant who has an FTP account, a PHP application and no way to edit a virtual host. It is the weakest of the configurations on this page: every tracking request now runs through PHP, the response is buffered rather than streamed, and compression between your server and ours is given up so that nothing has to be decoded twice. It is also the one that works when nothing else is available, and everything below was installed and verified on a live store before it was written down.
Three things to check before you start
PHP has to have the cURL extension and be allowed to open outbound HTTPS connections; the
rewrite rules have to go in the right file, and there are two layouts in the wild; and PHP has
to be given the visitor's address. One throwaway file answers all three. Upload it next to
your application's index.php:
<?php
// axi-probe.php - upload this next to your application's index.php, request it
// once in a browser, then delete it. It answers the three questions that decide
// whether the rest of this section will work, and where its files have to go.
header('Content-Type: text/plain');
echo 'curl extension: ', extension_loaded('curl') ? 'yes' : 'NO', "\n";
echo 'document root: ', $_SERVER['DOCUMENT_ROOT'] ?? '(unknown)', "\n";
echo 'this file is in: ', __DIR__, "\n";
echo 'REMOTE_ADDR: ', $_SERVER['REMOTE_ADDR'] ?? '(none)', "\n";
Request https://www.example.com/axi-probe.php once, read the four lines, then
delete the file.
| Line | What it decides |
|---|---|
curl extension |
NO means stop here and ask your host to enable ext-curl.
No PHP proxy of any kind can work without it.
|
document root and this file is in |
The same path means your document root is public/ itself, and the
rewrite rules go in public/.htaccess. Different paths, with the
document root one level above, mean the document root is the project root and a
root .htaccess is already rewriting everything into
public/. The rules then go in that file. Your hosting
panel shows the same thing in the field where the domain's document root is set.
|
REMOTE_ADDR |
Compare it with what curl https://api.ipify.org prints on the machine
you requested the probe from. Equal means PHP is given the visitor's address. A
private address such as 127.0.0.1 or 10.x.x.x means a
local proxy sits in front of PHP, which is what section 2 of the script below
handles.
|
First, one minute on the rule that needs no PHP
Apache can reverse proxy from .htaccess with the [P] flag, with no
PHP in the path at all. On shared hosting it usually fails, but it costs a minute to find out
and it is strictly better when it works.
# A throwaway test. Put it at the TOP of the .htaccess that serves your site,
# request https://www.example.com/health, then remove it either way.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^health$ https://axi.example.com/health [P,L]
</IfModule>
A 200 means you can use [P] for every path in
the list and skip the PHP script entirely. Anything else means you cannot,
and there are two independent reasons, either of which is enough:
-
mod_proxyis often not permitted in.htaccess. The response is500and your host's error log saysattempt to make remote request from mod_rewrite without proxy enabled, orAH01144: No protocol handler was valid for the URL. Nothing you can write in.htaccesschanges that; it is decided in the server configuration. -
Proxying to an
httpstarget needsSSLProxyEngine On, and that directive cannot appear in.htaccessat all. It is valid only in server configuration and virtual host context. The response is500and the log saysAH01961: SSL Proxy requested for ... but not enabled. So even a host that allowsmod_proxyin.htaccesscan still be unable to reach anhttpsupstream from there.
SSLProxyEngine On to .htaccess
A directive used outside the context that allows it is a configuration error, and
Apache answers every request under that directory with
500 Internal Server Error until it is removed. That takes your whole store
down, not just tracking. If the test above failed, remove the test rule and use the PHP
script.
The proxy script
When tracking runs through your own server, AxiTrace needs to know that the visitor address your server forwards really came from your server. This is automatic. Once an hour AxiTrace looks up the DNS records of your verified domains and remembers the addresses your site is served from. A request that reaches AxiTrace from one of those addresses carries a verified visitor address. Nothing has to be installed, registered or updated on your side, and a server move is picked up within an hour.
The check compares the address your server connects from with the addresses in your DNS. On a typical hosting setup they are the same. When your site sits behind a CDN, or your server sends outbound traffic through a different address, attribution keeps working exactly as before, but the forwarded visitor address does not count as verified evidence for automatic fraud blocking. AxiTrace reviews such setups on its side.
The visitor address is the one PHP sees in REMOTE_ADDR. When a local reverse
proxy sits in front of PHP (nginx in front of Apache is common on shared hosting), the script
below takes the address that proxy passes on in X-Forwarded-For or
X-Real-IP, so attribution keeps working there too. Configure that proxy to set
the forwarded address from the connection it accepted, not to pass through a header the
visitor sent: AxiTrace can only verify what your server vouches for.
This is the file that is running on a real store, published as it stands, with only the
upstream hostname replaced by the placeholder this page uses. Save it as
axitrace-proxy.php in the same directory as your application's
index.php - the directory the probe reported as this file is in - and
change AXITRACE_UPSTREAM to your own AxiTrace tracking hostname, the one with the
CNAME to us.
<?php
/**
* AxiTrace first-party proxy.
*
* Purpose: make tracking requests come from this server instead of from a
* hostname that CNAMEs to AxiTrace. Safari shortens the lifetime of a cookie
* set in a response that arrives through a CNAME pointing off this domain, or
* from an IP unrelated to the one serving the page. Answering from our own
* server removes both conditions, so vt_vid keeps its full expiry.
*
* Reached through the rewrite rules in the document-root .htaccess. The rewrite
* is internal, so REQUEST_URI still holds the path the visitor asked for, which
* is what gets forwarded - not the name of this script.
*/
declare(strict_types=1);
const AXITRACE_UPSTREAM = 'axi.example.com';
// ---------------------------------------------------------------------------
// 1. Which paths may be forwarded
// ---------------------------------------------------------------------------
// A second, independent allow-list. The .htaccess rules already limit what
// reaches this file; this exists so that a future rewrite rule sending
// something unexpected here gets a 404 instead of being proxied blindly.
$EXACT_PATHS = [
'/axitrack.js',
'/axitrack.min.js',
'/velitrack-sdk.js',
'/axitrack-triggers.js',
'/axitrack-surveys.js',
'/axitrack-tester.js',
'/sdk-config',
'/health',
'/ready',
];
$PREFIX_PATHS = ['/surveys', '/track', '/v1', '/shopify', '/woocommerce', '/magento', '/shopware'];
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if (!is_string($path) || $path === '') {
$path = '/';
}
$allowed = in_array($path, $EXACT_PATHS, true);
if (!$allowed) {
foreach ($PREFIX_PATHS as $prefix) {
if ($path === $prefix || strncmp($path, $prefix . '/', strlen($prefix) + 1) === 0) {
$allowed = true;
break;
}
}
}
if (!$allowed) {
http_response_code(404);
exit;
}
// ---------------------------------------------------------------------------
// 2. Who the visitor is
// ---------------------------------------------------------------------------
// REMOTE_ADDR is the address of the LAST hop. On this host nginx sits in front
// of Apache, so it can be a private address belonging to that local proxy
// rather than the visitor. Only in that case do we trust the header the local
// proxy set. When REMOTE_ADDR is already a public address it IS the visitor,
// and no header is trusted, because a client can forge those freely.
//
// Inside that case, take the LAST X-Forwarded-For entry, not the first: a local
// reverse proxy APPENDS the address it saw on the connection it accepted, while
// the first entry is whatever the visitor sent. X-Real-IP is the other
// convention for the same thing and is the fallback.
//
// Getting this wrong is silent: every visitor would be reported to AxiTrace
// under one address, and attribution would quietly collapse.
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '';
$remoteIsPublic = filter_var(
$remoteAddr,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
$clientIp = $remoteAddr;
if (!$remoteIsPublic) {
$forwarded = '';
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']);
$forwarded = trim($parts[count($parts) - 1]);
}
if (filter_var($forwarded, FILTER_VALIDATE_IP) === false
&& !empty($_SERVER['HTTP_X_REAL_IP'])) {
$forwarded = trim((string) $_SERVER['HTTP_X_REAL_IP']);
}
if (filter_var($forwarded, FILTER_VALIDATE_IP) !== false) {
$clientIp = $forwarded;
}
}
// ---------------------------------------------------------------------------
// 3. Build the upstream request
// ---------------------------------------------------------------------------
// Host must be the hostname AxiTrace provisioned for this workspace. It selects
// the route on their side and the cookie domain is derived from it. Sending
// this site's own hostname instead matches no route and tracking stops.
//
// X-Forwarded-For is SET, never appended, so a value forged by the client
// cannot end up first in the list.
$query = $_SERVER['QUERY_STRING'] ?? '';
$url = 'https://' . AXITRACE_UPSTREAM . $path . ($query !== '' ? '?' . $query : '');
$headers = [
'Host: ' . AXITRACE_UPSTREAM,
'X-Forwarded-For: ' . $clientIp,
'X-Forwarded-Proto: https',
];
// Hop-by-hop headers and anything we set ourselves above. Accept-Encoding is
// dropped so the upstream answers uncompressed and the body can be passed
// through untouched.
$skipRequestHeaders = [
'host',
'x-forwarded-for',
'x-forwarded-proto',
'connection',
'content-length',
'accept-encoding',
];
foreach ($_SERVER as $key => $value) {
if (strncmp($key, 'HTTP_', 5) !== 0) {
continue;
}
$name = strtolower(str_replace('_', '-', substr($key, 5)));
if (in_array($name, $skipRequestHeaders, true)) {
continue;
}
// Carries Origin, Content-Type, the X-VeliTrack-* authentication headers,
// If-None-Match for the SDK file, and Access-Control-Request-* so that a
// CORS preflight is answered by AxiTrace rather than by this script.
$headers[] = $name . ': ' . $value;
}
$body = file_get_contents('php://input');
$ch = curl_init($url);
if ($ch === false) {
http_response_code(502);
exit;
}
$options = [
CURLOPT_CUSTOMREQUEST => $_SERVER['REQUEST_METHOD'] ?? 'GET',
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 30,
];
if (is_string($body) && $body !== '') {
$options[CURLOPT_POSTFIELDS] = $body;
}
curl_setopt_array($ch, $options);
$raw = curl_exec($ch);
if ($raw === false) {
// Tracking must never take the page down with it. A failed hop is a lost
// event, logged here and nowhere else.
error_log('axitrace-proxy: upstream request failed: ' . curl_error($ch));
curl_close($ch);
http_response_code(502);
exit;
}
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// ---------------------------------------------------------------------------
// 4. Relay the response
// ---------------------------------------------------------------------------
http_response_code($status);
$dropResponseHeaders = [
'transfer-encoding',
'content-encoding',
'content-length',
'connection',
'keep-alive',
];
foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
if ($line === '' || stripos($line, 'HTTP/') === 0) {
continue;
}
$parts = explode(':', $line, 2);
if (count($parts) !== 2) {
continue;
}
if (in_array(strtolower(trim($parts[0])), $dropResponseHeaders, true)) {
continue;
}
// The second argument is false on purpose: it means "add", not "replace".
// Set-Cookie can legitimately appear several times, and relaying those
// headers intact is the entire reason this proxy exists.
header($line, false);
}
echo substr($raw, $headerSize);
What the four numbered sections do
Each of the four is there because of something that breaks without it. In the order they appear in the file:
The second allow-list
The .htaccess rules already decide what reaches this file, so the list inside the
script is a deliberate duplicate. It is defence in depth against your own future edits: a
rewrite rule added months from now that sends something unexpected here gets a
404 instead of being forwarded blindly to our infrastructure. Without it, this
script is an open forwarder for any path someone can talk your rewrite rules into matching.
Who the visitor is
REMOTE_ADDR is the address of the last hop, which is not always
the visitor. On a host where a local nginx sits in front of Apache it is a private address
belonging to that local proxy, and the visitor's real address is only in the header the local
proxy set. So the script trusts that header only when REMOTE_ADDR is
private. When REMOTE_ADDR is already a public address it is the visitor
by definition, and no header is trusted at all, because a client can put anything in
X-Forwarded-For or X-Real-IP and does not have to be truthful.
Getting this wrong is silent in both directions. Trust the header unconditionally and a visitor can hand you any address they like. Ignore it on a host that needs it and every visitor arrives under the store's own server address: no error, no warning, and attribution quietly collapses onto one visitor. Check 6 below is what catches it.
The upstream request: the Host override and set-not-append
Two things happen here. The Host header is set to your AxiTrace tracking hostname,
which is what selects your workspace route on our side and what the cookie
Domain is derived from; send your own store hostname instead and no route matches,
so every request answers 404. And X-Forwarded-For is
set, not appended to: the value the script resolved in section 2 is the only
entry it writes, so a value forged by the client cannot end up first in the list, which is the
entry we read. Note that the inbound X-Forwarded-For is also in the skip list, so
it is dropped rather than carried along beside the one the script wrote.
Relaying the response, and header($line, false) in particular
Everything the upstream sent is copied back except the headers that describe the connection
between the two servers rather than this response. The one line that matters most is the
relay itself: header($line, false). The second argument means
add, not replace. PHP's default is to replace, and
Set-Cookie legitimately appears several times in one response - the visitor
cookie and the session cookie among them. A proxy that leaves the default in place keeps only
the last one and silently drops the rest, which defeats the entire purpose of this setup: you
would have built a first-party path for a cookie and then thrown the cookie away. Nothing
reports an error; the dashboard simply keeps showing new visitors who are not new.
The same loop is why ETag, Cache-Control and the CORS headers arrive
exactly as we sent them. Do not rewrite any of them, and do not add your own.
Where the rewrite rules go
The rewrite is internal: Apache serves a different file without telling the
browser, and REQUEST_URI still holds the path the visitor asked for. That is what
makes the whole arrangement work, because the script forwards REQUEST_URI and not
its own filename. Never use a redirect ([R]) here: the browser would follow it to
/axitrace-proxy.php, and that path is not in the allow-list, so it answers
404.
Two document root layouts exist in the wild and the rules go in a different file in each. The probe above already told you which one you are on.
Layout A: the document root is the project root
The common way a Symfony or Laravel application is put on shared hosting: the domain points at
the project root, and a root .htaccess rewrites everything into
public/. The AxiTrace rules go in that root file, above the line
that does the rewriting into public/.
# /home/you/yourstore/.htaccess THE PROJECT ROOT, next to composer.json
#
# The two AxiTrace rules MUST stay ABOVE the rule that rewrites everything into
# public/. Below it they can never match: by then /track has already become
# public/track and no longer looks like any of the paths listed here.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^(axitrack\.js|axitrack\.min\.js|velitrack-sdk\.js|axitrack-triggers\.js|axitrack-surveys\.js|axitrack-tester\.js|sdk-config|health|ready)$ public/axitrace-proxy.php [L,QSA]
RewriteRule ^(surveys|track|v1|shopify|woocommerce|magento|shopware)(/.*)?$ public/axitrace-proxy.php [L,QSA]
# Your application's own rule, unchanged. Yours may be written slightly
# differently; what matters is that it stays BELOW the two lines above.
RewriteCond %{REQUEST_URI} !^/public/
RewriteRule ^(.*)$ public/$1 [L]
</IfModule>
Layout B: the document root is public/
The domain points straight at public/, so there is no root .htaccess
in the path at all. Same two rules, without the public/ prefix in the target,
placed above your application's own front controller rule.
# /home/you/yourstore/public/.htaccess THE DOCUMENT ROOT ITSELF
#
# The same two rules without the public/ prefix in the target, above the front
# controller rule. Below it they never run, because the front controller rule
# ends with [L] and has already claimed the request.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^(axitrack\.js|axitrack\.min\.js|velitrack-sdk\.js|axitrack-triggers\.js|axitrack-surveys\.js|axitrack-tester\.js|sdk-config|health|ready)$ axitrace-proxy.php [L,QSA]
RewriteRule ^(surveys|track|v1|shopify|woocommerce|magento|shopware)(/.*)?$ axitrace-proxy.php [L,QSA]
# Your framework's front controller, unchanged. Symfony writes
# "RewriteRule ^ index.php [L]", WordPress "RewriteRule . /index.php [L]".
RewriteRule ^ index.php [L]
</IfModule>
Both rules end in [L], and so does your application's. Whichever comes
first wins. Put the AxiTrace lines below your application's rule and they will never match
anything: in layout A /track has already become public/track, and
in layout B the front controller has already claimed the request. Nothing warns you; the
paths simply return your application's own 404 page, which is what check 1 below is
looking for.
What it costs, and the one optimisation worth making
Measured on a real store: the 116 KB SDK file took 0.29 s through the PHP
proxy against 0.15 s requested directly from the tracking hostname. That is
the worst case on this page, because it is by far the largest response. For /track,
where the request body is a few hundred bytes and the response is smaller still, the overhead
is much smaller.
If the SDK download is the part you care about, you can leave it where it is. The
<script src> may stay on your AxiTrace tracking hostname, because that file
sets no cookie, and only apiUrl has to move:
<script src="https://axi.example.com/axitrack.js"></script>
<script>
Axitrace.init({
apiUrl: 'https://www.example.com',
publicKey: 'pk_live_YOUR_PUBLIC_KEY'
});
</script>
The trade-off is worth stating plainly. You get far fewer PHP invocations, because the large
static file is no longer proxied and the five script paths can be dropped from the rewrite
rules. In exchange the script is served from a subdomain again, which matters for ad blockers
and content blockers, not for cookies. The cookie question is settled entirely by where
apiUrl points, because that is the request whose response carries
Set-Cookie. Keep every other path proxied: /sdk-config is requested
from apiUrl, not from wherever the script came from.
Verify it before you change your snippet
Seven checks, in this order, run from your own machine. The first six create no events at all, so you can run them against a live store before anything points at the proxy. Only the seventh changes what your visitors load.
Routing reaches us at all
curl -sI https://www.example.com/health
# Expect: HTTP/2 200, and a small JSON body if you drop the -I.
# A 404 from your own application means the rewrite rules are in the wrong
# file, or below the rule that claims the request first.
# A bare 404 with no HTML means the request did reach us but carried the wrong
# Host header, so no workspace route matched.
Our load balancer has no catch-all: a request carrying the wrong Host header is
not routed anywhere and answers 404. That is what makes a plain 200
here meaningful rather than cosmetic.
It reaches the RIGHT workspace
curl -s "https://www.example.com/sdk-config?pk=pk_live_YOUR_PUBLIC_KEY"
# Expect: a JSON body describing YOUR workspace's own tracking policy.
# An empty or error body means the key and the route disagree: the request
# reached us, but not as the workspace you think you are testing.
A route that works is not the same as the route you meant. This one comes back with your own workspace's tracking policy, which nothing else can produce.
The SDK file arrives intact, byte for byte
# GET, never HEAD. "curl -I" sends HEAD, and /axitrack.js answers HEAD with
# 405 Method Not Allowed BY DESIGN - which reads exactly like a broken proxy
# when nothing at all is wrong.
curl -s -D proxy.head -o proxy.js "https://www.example.com/axitrack.js"
curl -s -D direct.head -o direct.js "https://axi.example.com/axitrack.js"
ls -l proxy.js direct.js # the two sizes must be identical
grep -i '^etag' proxy.head direct.head # the two ETags must be identical
Caching survives the proxy
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'If-None-Match: "PASTE-THE-ETAG-FROM-THE-PREVIOUS-STEP"' \
"https://www.example.com/axitrack.js"
# Expect: 304
A 304 proves the ETag made the round trip in both directions. If this
returns 200, your visitors re-download the SDK on every page view, and your
measured cost above goes from an occasional 0.29 s to 0.29 s every time.
The CORS preflight allows credentials
curl -s -D - -o /dev/null -X OPTIONS \
-H 'Origin: https://www.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-velitrack-public-key' \
"https://www.example.com/track"
# The response MUST carry both of these:
# access-control-allow-origin: https://www.example.com
# access-control-allow-credentials: true
The SDK sends events with credentials: "include", because that is how the
HttpOnly visitor cookie travels at all. A browser discards a credentialed
response that does not carry access-control-allow-credentials: true, so without
that header every event fails in the browser while every command line test on this page still
looks perfectly healthy. The public key belongs in
Access-Control-Request-Headers because that is what the real preflight carries,
and it is how our CORS layer knows which workspace is asking.
The cookie and the visitor address, without creating an event
This is the check that earns its place. The body below parses as JSON and then fails
validation, because a page.view needs params.url and there is none.
The request is rejected with 400 and nothing is queued. But the
visitor cookie is issued before validation runs, so the response still carries it, and the
rejected request is still recorded with the address it arrived from.
curl -s -D - -o /dev/null -X POST "https://www.example.com/track" \
-H 'Content-Type: application/json' \
-H 'Origin: https://www.example.com' \
-H 'X-VeliTrack-Public-Key: pk_live_YOUR_PUBLIC_KEY' \
-d '{"event":"page.view","label":"proxy-check"}'
# Expect: HTTP 400, and a response that STILL carries the visitor cookie:
# set-cookie: vt_vid=...; Path=/; Domain=example.com; Max-Age=63072000;
# HttpOnly; Secure; SameSite=Lax
# Your own public address, for the comparison in the admin panel:
curl -s https://api.ipify.org; echo
Check the cookie attributes in the response: the two year Max-Age,
HttpOnly, Secure, SameSite=Lax, and above all a
Domain that is your own registrable domain. A domain that is not
yours is what happens when the Host header sent upstream is one of ours, and the
browser would discard such a cookie without a word.
Then open the AxiTrace admin, go to Event feed → Errors in your workspace, and look at
the newest row under Validation Errors. It is the request you just sent:
event type page.view, message
params.url is required for page.view event, and an IP Address
column showing the address that arrived. That address must equal what
api.ipify.org printed. If it shows your store's own server address
instead, stop here and fix section 2 of the script before you go live: every visitor would
otherwise be recorded as one person.
The rejected request stays in your workspace's validation errors as a single diagnostic row. It carries no visitor and no event, nothing is forwarded to any ad platform, and it expires with your plan's retention like every other row there. Running the check twice leaves two.
Only now, change the snippet
With the first six green, make the edit in Step 3 and confirm the result
the way Step 4 describes: in Safari's Web Inspector, under
Storage → Cookies, vt_vid now carries an expiry years away
rather than seven days. Clear the cookie first, or you will be reading the old one.
Update your AxiTrace snippet
Two places point at the tracking hostname, and both move to the new one: the
<script src> that loads the SDK, and apiUrl.
publicKey does not change. Neither does cookieDomain, if you set one,
because it is derived from your own domain and not from where the SDK is served.
Before:
<script src="https://axi.example.com/axitrack.js"></script>
<script>
Axitrace.init({
apiUrl: 'https://axi.example.com',
publicKey: 'pk_live_YOUR_PUBLIC_KEY'
});
</script>
After, for the subdomain configuration:
<script src="https://t.example.com/axitrack.js"></script>
<script>
Axitrace.init({
apiUrl: 'https://t.example.com',
publicKey: 'pk_live_YOUR_PUBLIC_KEY'
});
</script>
For the main domain configuration it is the same edit with your own store hostname, for example
https://www.example.com.
Using one of our plugins? Then there is no snippet to edit. Enter the new
hostname, without https://, in the plugin's tracking domain field and the plugin
moves both places for you: Custom tracking domain in Shopware,
First-party tracking domain in WooCommerce, Tracking domain in
Magento. For the main domain configuration that is your store's own hostname, for example
www.example.com. Clear the shop's cache afterwards so pages stop serving the old
hostname.
apiUrl must be an origin, with no path after it
https://www.example.com/axi does not work, and it fails in the least helpful
way possible: some requests honour the prefix and the event requests quietly ignore it,
so part of the setup looks healthy while events go to a path nobody serves. Serve the
sixteen paths at the root of whichever hostname you chose, and give apiUrl
the bare origin.
Verify it worked
Four checks, in this order. The third is the one that catches the regression that would otherwise go unnoticed for weeks, and the fourth is the actual proof that the change did what it was for.
DNS resolves to the same place as your main site
dig +short t.example.com
dig +short www.example.com
# The two must print the same thing. If your main site is a CNAME, the
# target must match as well, not just the address underneath it.
Skip this one for the main domain configuration: there is only one hostname and it is your own by definition.
A request gets through to us
curl -i https://t.example.com/health
# Expect HTTP 200 and a small JSON body.
# A 404 from your own server means the proxy rule did not match the path.
# A 404 that looks like it came from us means the Host header is wrong.
Events still carry the visitor's address, not your server's
The admin shows the visitor's address in two places: on each lead in Leads, and under the customer on orders in Transactions. Look at the leads and orders that arrived after the change and compare their addresses with each other and with your own. Leads are the reliable check, because a form submission travels through your proxy. Orders sent by the WooCommerce, Magento or Shopware plugin carry the address the plugin reports from your shop server, which never passes through the proxy, so they prove nothing here.
They should differ. If every lead that arrives after the change shows one
address, and it is your server's or your CDN edge's, then X-Forwarded-For is not
being set correctly. Go back to
rule 2. Everything else will look completely healthy while this is wrong,
which is exactly why it is worth checking deliberately rather than assuming. If your store has
no forms, email us the time you made the change and we will check the addresses for you.
The cookie expiry in Safari
This is the proof. On a Mac, open your site in Safari, then open
Develop → Show Web Inspector → Storage → Cookies and find
vt_vid.
You will see it twice, with the same value. Look at the row with HttpOnly
ticked and a domain starting with a dot, such as .example.com: that is the cookie
our server sets, the one this change is about. The other row is written by the SDK in the page,
and Safari keeps that one for seven days whatever you do (see the FAQ below).
- Before: its expiry is about seven days from now.
- After: its expiry is years from now.
Use a clean profile or clear the cookie first, because an existing shortened cookie keeps its old expiry until it is set again. Safari must have Prevent cross-site tracking switched on, which is the default; with it off there was nothing to fix in the first place.
Troubleshooting
Every request returns 404
Almost always the Host header. We route by that header and there is no
catch-all, so a request arriving with your own hostname, or with a hostname we do not
recognise, is not routed anywhere. Confirm the proxy sends your AxiTrace tracking
hostname, the one with the CNAME to us, and not the hostname the visitor typed. On
Apache this is ProxyPreserveHost Off; on nginx it is the
proxy_set_header Host line.
A 404 that comes from your own web server instead is a different problem: the path did not match any of your proxy rules. Compare the request path against the list.
Every visitor shows the same address
Your proxy is not passing the visitor's address through. Two causes, and they need different fixes.
If they all show your web server's address, the proxy is not setting
X-Forwarded-For at all, or it is appending to an empty value in a way we do
not see. Set it explicitly to the address your server observed.
If they all show a handful of addresses that are not yours, you are
behind a CDN and your server is seeing the CDN edge rather than the visitor. Read the
visitor from the CDN's own header, such as CF-Connecting-IP, or configure
your server's real-IP module so that its idea of the peer is corrected before your proxy
rule runs.
TLS handshake failures to our endpoint
Our load balancer serves one certificate per hostname and chooses it from the name your
proxy sends in the TLS handshake, the SNI. If the handshake carries no name, or a
different one, it fails before any HTTP request is made. Most servers derive it from the
URL they are forwarding to, which is why every example here forwards to
https://axi.example.com/... rather than to an address. On nginx, set
proxy_ssl_server_name on and proxy_ssl_name explicitly, as the
example does.
Who owns the TLS certificate now
Your certificate covers the hostname visitors request, ours covers the hostname your proxy forwards to. In the recommended setup that means you add one name to your own certificate and we keep renewing ours as before, because your existing tracking hostname never stops pointing at us.
It is different if you move a hostname we already provisioned onto your own server. After that we can no longer prove control of it through our load balancer, so we cannot renew its certificate, and TLS for that name becomes yours to issue and keep renewing. A certificate that quietly expires stops all tracking on that hostname. Prefer adding a new name.
Requests hang or time out
Check what your proxy is forwarding to. If the target hostname now resolves to your own server, the server is sending the request back to itself and will keep doing so until something gives up. This is what happens if you repoint your existing tracking hostname at your own server and then also use it as the proxy target. The forwarding target must be a hostname that still resolves to us.
The SDK loads but no events arrive
Check apiUrl first. If it has a path after the hostname, the script and
configuration requests follow it while the event requests do not, which produces exactly
this: a page that looks instrumented and a dashboard that stays empty. It has to be a
bare origin.
If apiUrl is correct, check that /track is covered by a proxy
rule as a whole path segment. A rule written for /track/ alone does not
match /track.
FAQ
Do I still need the CNAME record I added when I set up my tracking domain?
Does this change anything in Chrome or Firefox?
Does this fix everything Safari does to tracking?
localStorage and any cookie set
by JavaScript, after a period without visitor interaction. That is a different mechanism and
this change does not affect it. What it fixes is the expiry of the cookie our server sets in
its response.
Can I put the tracking paths under a prefix, like /axi/track?
apiUrl and an absolute path, so
a prefix is discarded for them while other requests keep it. The result is a half-working
setup that reports no error. Serve the paths at the root of the hostname you chose.
What happens if my proxy goes down?
/v1 as the one exception you
were asked to check. This is a real trade: before the change, tracking depended only on our
infrastructure; after it, it also depends on yours.
Can I do this on one site and not another?
Need Help?
If you have any questions or need assistance:
- Email us at info@axitrace.com - we typically reply within one business day.
- See the Domain Configuration guide if your tracking domain is not Live yet.
- See the JavaScript SDK reference for every configuration option.