HeySales and HelloRestaurant and myab · 23 October 2026 · 4 min read

How HeySales counts visits from people who haven't accepted cookies

How the HeySales tracker counts visits with nothing stored on the device until a visitor agrees, recognises them for a day by hash, and flags bots.

HeySales includes a visitor tracker that customers add to their websites. We use it on three of our own: www.heysales.co.uk, hellorestaurant.co.uk and myab.co.uk. The tracker was first built around a two-year cookie.

When we added cookie banners to all three sites, we wanted to keep counting visits from people who ignore the banner or say no, with nothing stored on their device, and keep the richer tracking for people who agree.

Three consent states

The site passes the visitor's choice to the tracker in a data-consent attribute on the script tag.

  • pending: the visitor hasn't answered the banner yet.
  • denied: they rejected cookies.
  • granted: they accepted.

Inside the tracker, pending and denied behave the same way. The difference only matters to the banner, which stays hidden once a choice is made. If the attribute is missing, the tracker sets its cookie straight away as it always did, so existing customer embeds didn't change.

var consentAttribute = currentScript && currentScript.getAttribute('data-consent');
// Without data-consent the tracker uses its cookie, as it always has
var granted = !consentAttribute || consentAttribute === 'granted';
// Without consent, nothing of ours stays on the device
if (!granted) clearStored();

The last line came from a follow-up fix. Some visitors still had a cookie from the older version of the tracker, so it now deletes that cookie on load until consent is given.

What is stored in each state

Without consent, nothing goes on the device and beacons carry no visitor id. We keep:

  • a visitor record with a hashed id, a country, and no IP address
  • a session with the site's hostname, landing page, referrer, UTM tags, user agent and device type
  • page views (path and title) and any key actions the site marks up

With consent, the tracker sets a heysales_vid cookie for two years and keeps a session id and a first-page flag in sessionStorage. The server then stores the IP address and looks it up to guess the visitor's company and town.

Recognising a visitor for one day

Without a cookie, the server builds a stand-in visitor id from the request.

export async function cookielessVisitorUid(
  companyId: string,
  ipAddress: string | null,
  userAgent: string | null,
  now = new Date(),
): Promise<string> {
  const day = now.toISOString().slice(0, 10);
  const dailySalt = await hmacHex(serverSecret, `heysales-visitor-salt:${day}`);
  const hash = await hmacHex(
    dailySalt,
    `${companyId}|${ipAddress ?? ""}|${userAgent ?? ""}`,
  );
  return `anon_${hash.slice(0, 32)}`;
}

The salt is an HMAC-SHA-256 of a server secret and the UTC date, so it changes at midnight UTC. The visitor id is a second HMAC, keyed by that salt, over the HeySales account, the IP address and the browser's user agent. The IP address goes into the hash and is never written to the database.

Three things follow from this.

  • The same person counts as a new visitor the next day.
  • The same person on two different HeySales customers' sites gets two unrelated ids, because the account is part of the hash.
  • Two people sharing one IP address with the same browser and version look like one visitor.

Sessions need the same treatment. No session id survives a page load, so the server groups a visitor's beacons to one site into a session until there is a gap of 30 minutes. It matches on hostname as well, because one account can track several sites and the daily id is the same on all of them. Before that check, visiting two such sites within half an hour merged both visits into one session.

While the visitor stays on one page, for example in a single-page app, the server hands back the session id and the tracker keeps it in memory only.

When the visitor says yes

On Accept, the banner calls HeySales.consent(true). The tracker copies the in-memory session id into sessionStorage and sets its cookie on the next beacon, so the visit stays one session.

The server then moves that session to the new cookie-based visitor, but only if it belongs to a cookieless visitor whose one-day hash matches this request's. A session id alone can't claim a visit.

HeySales.consent(false) does the reverse. It deletes the cookie and the session keys, forgets the session id, and goes back to counting without storage.

Where the country comes from

Without consent there is no IP lookup. The country comes from the CF-IPCountry header Cloudflare sets on the request. We turn the two-letter code into a name with Intl.DisplayNames in British English, and ignore XX (unknown) and T1 (Tor). With consent, the IP lookup supplies a town and country instead.

Flagging bots

Every session has an isBot flag, set when the session starts. A session is flagged when the user agent contains a marker such as bot, crawl, spider, headless, curl/ or playwright. We check both the user agent the script reports and the one on the request.

With consent, a visit is also flagged when the IP lookup marks the address as a hosting network, or the company it finds matches a cloud or hosting provider. Without consent there is no lookup, so only the user agent check applies.

Bot sessions are kept but left out of the traffic numbers. The Traffic page says how many, with a button to include them.

What a site owner needs to do

  1. Load the tracker once per page, with data-consent set to the saved choice, or pending.
  2. Show a banner with Accept and Reject. On a click, save the choice and call HeySales.consent().
  3. Add a "Cookie settings" link in the footer that reopens the banner.
  4. Never leave data-consent off a site with a banner, or the cookie is set before anyone is asked.
  5. Make your cookie and privacy policies list what is really stored.

This is close to what myab.co.uk does:

var choice = readChoice(); // "granted", "denied" or null
var script = document.createElement("script");
script.src = "https://www.heysales.co.uk/api/tracking/<company-id>/script.js";
script.setAttribute("data-consent", choice || "pending");
document.body.appendChild(script);

// In the banner's click handler
saveChoice(picked);
if (window.HeySales && window.HeySales.consent) {
  window.HeySales.consent(picked === "granted");
}

All three of our sites keep the choice for 12 months, in a first-party cookie on HeySales and HelloRestaurant and in localStorage on myab. HelloRestaurant and myab only load the tracker on their live domains, so local and preview visits aren't counted. HelloRestaurant also skips restaurant subdomains, because those are the restaurants' own sites.

If you build something similar, write down what each consent state stores, then check the device and the database against that list. Include anything an older version of your script left behind.

Tell us what you're building.

Email us about your project: what it needs to do, who it's for and when you'd like it live.