Kwisple
Browse the docs

Content and data

Statistics and consent

The platform's cookieless statistics script, what it sends and how visitors are counted, the generated cookie banner and consent log, and how AI-agent visits are reported from your server.

Every site gets two things from the platform: first-party statistics that set no cookies and store nothing in the browser, and a cookie banner generated from the services the site uses, with a log of every choice. Both are configured by the agency under Website → Instellingen → Cookies & privacy. Your site includes a script tag and, when the site uses something that needs consent, the banner. AI agents that read the site never run scripts, so your server reports them.

These defaults are designed to follow the GDPR, the ePrivacy rules and the guidance of the Belgian data protection authority as the platform reads them. They are not legal advice.

The configuration: client.privacy()

const privacy = await client.privacy("nl"); // GET /api/v1/consent?locale=nl
FieldMeaning
analytics.enabledStatistics are on (Bezoekers tellen) and the site is online.
analytics.requiresConsentCounting waits for the Statistieken consent (strict mode, off by default).
analytics.scriptUrlThe versioned, immutable script URL (…/api/v1/a/<version>.js).
analytics.collectUrlWhere the script sends events (…/api/v1/collect).
consent.siteKeyThe public site key the script and the banner send.
consent.versionGoes up when the purposes change (a service added or removed); a stored choice for another version is asked again. Text edits do not bump it.
consent.requiredfalse when the site uses nothing that needs consent: show no banner.
consent.categoriesnecessary, statistics, marketing, preferences as used, each with label, description and services (provider, purpose, privacy link, cookies with retention).
consent.serviceCategoriesService id (youtube, google-maps, …) → category, for gating embeds.
consent.textsEvery banner and settings text in the requested locale.
consent.expiresDaysHow long a choice is kept: 182 days.
consent.storageKeysite_consent, the local-storage key of the choice.
consent.privacyUrlThe privacy statement link (Link naar de privacyverklaring).
consent.endpointWhere choices are logged (POST /api/v1/consent).

The answer is cached under kwisple:privacy and kwisple:all; saving the privacy settings sends the webhook with both tags. See Brand kit and privacy data.

Including the statistics script

Next.js

// src/app/layout.tsx (Server Component)
import { ConsentBanner, ConsentProvider } from "@kwisple/sdk/consent";
import "@kwisple/sdk/consent.css"; // only for the default banner look
import { Analytics } from "@kwisple/sdk/next";
import { draftMode } from "next/headers";
import { client } from "@/lib/cms";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const [privacy, draft] = await Promise.all([client.privacy("nl").catch(() => null), draftMode()]);
  return (
    <html lang="nl-BE">
      <body>
        {children}
        {privacy ? (
          <ConsentProvider config={privacy.consent}>
            <ConsentBanner />
          </ConsentProvider>
        ) : null}
        <Analytics privacy={privacy} disabled={draft.isEnabled} />
      </body>
    </html>
  );
}

<Analytics> takes privacy (or a client, then it fetches privacy() itself) and disabled. It renders nothing while disabled is true (pass draft.isEnabled so editors previewing drafts are never counted), on staging (KWISPLE_ENV=staging), and when analytics.enabled is false.

Astro

---
// src/layouts/Base.astro
import { Analytics, Consent } from "@kwisple/sdk/astro/components";
import { previewToken } from "@kwisple/sdk/astro/server";
import { client } from "@/lib/cms";

const previewing = Boolean(previewToken(Astro));
const privacy = await client.privacy().catch(() => null);
---
<body>
  <slot />
  <Analytics privacy={privacy} disabled={previewing} />
  <Consent config={privacy?.consent} />
</body>

<Consent> ships a small module script only when config.required is true (or always is set).

Any other site

The tag the components render, which you can write yourself:

<script async src="https://<platform>/api/v1/a/<version>.js"
        data-site="<siteKey>"
        data-api="https://<platform>/api/v1/collect"></script>
AttributeMeaning
data-siteThe site key. Required on any domain other than the platform's own.
data-apiThe collect endpoint. Default: the script's origin + /api/v1/collect.
data-consentA consent category (statistics): count only after that consent.
data-consent-versionThe consent version the stored choice must match.

Use the values from client.privacy() instead of hard-coding the version: the script URL changes when the script changes. If your site sends a Content-Security-Policy, allow the platform origin in script-src and connect-src.

What the script sends

The script is under 1 kB gzipped, has no dependencies, sets no cookies and reads or writes no storage (except reading the consent choice in strict mode). It posts small JSON beacons with navigator.sendBeacon (falling back to fetch with keepalive):

TypeWhenCarries
pv pageviewon load and on every client-side navigation (pushState, replaceState, popstate)page URL, referrer
en engagementwhen the page is left or hidden, after at least one second visiblevisible time, deepest scroll (%), blocks seen
ob outboundclick on an external, mailto: or tel: linkthe link
ck clickclick on an internal link or a buttonthe link (if any), its visible text (max 80 characters), the block it is in

Every beacon carries the site key, the type and the page URL. The server keeps the path without query string or hash, the referrer host only, and UTM values from the page URL.

The script sends nothing when the browser sends Do Not Track or Global Privacy Control, when the browser is automated (navigator.webdriver), inside a frame (the dashboard's live preview), on non-http pages, and while a page is being prerendered. Client-side navigations count as pageviews automatically, so App Router sites need no extra code.

Blocks seen and clicks per block

The script names blocks so the statistics and the Websitecheck can tell which parts of a page were seen and clicked. Mark your page-builder blocks with blockAttrs:

import { blockAttrs } from "@kwisple/sdk";

{blocks.map((block, i) => (
  <section key={block.id} {...blockAttrs(block.type, i)}>…</section>
))}
// → data-kwisple-block="3:cta"

A block counts as seen when at least half of it is in view (or it fills half the screen). Without data-kwisple-block attributes the script falls back to the <section> elements inside <main>, named by position and id or heading. At most 30 blocks per page are tracked.

Campaigns

UTM parameters (utm_source, utm_medium, utm_campaign) in the page URL are stored with the pageview. The dashboard shows them under Campagnes; the toolkit's trackable-link builder makes such links.

How visitors are counted

The collect endpoint (POST /api/v1/collect) counts nothing and answers 204 when:

  • the request carries DNT: 1 or Sec-GPC: 1, is a prefetch, or comes from a bot;
  • the site is offline or statistics are off;
  • the Origin is not one of the site's origins: the platform address, a verified domain, or the origin of the Site-URL;
  • the page URL does not belong to the site, or the request carries a valid preview cookie.

It is rate limited per visitor (a burst of 60, then 2 per second).

A visitor is a daily hash: SHA-256 of a daily salt, the site, the IP address and the user agent, truncated. The salt is random, shared through the database, and deleted the next day, so the same person on two days counts twice and no visit can be linked across days. No IP address is stored. Country is only taken from a trusted CDN header when the platform runs behind one; no geolocation lookup is done.

Under Cookies & privacy → Geavanceerd, Ook toestemming vragen voor het tellen van bezoekers (also ask consent for counting visitors) makes the statistics wait for the Statistieken consent. Then analytics.requiresConsent is true, the components add data-consent="statistics" and data-consent-version, and a banner always shows.

The script reads the stored choice from localStorage["site_consent"] and counts only when its version matches, statistics is granted and the choice is younger than 182 days. It listens for the kwisple:consent event the consent store dispatches, so it starts counting the moment the visitor accepts and stops when they withdraw.

The platform generates the banner from the services the agency ticks under Diensten van anderen (third-party services) and the embeds it finds in published content.

  • No service that needs consent → no banner. Then consent.required is false; show only a "Cookie-instellingen" link that explains what the site does.
  • With such a service: reject all, settings and accept all with equal weight, nothing pre-ticked, a choice per category, a persistent link to change or withdraw the choice.

React

Export of @kwisple/sdk/consentUse
ConsentProviderwraps the layout with config={privacy.consent}; also opens the settings from any [data-consent-open] element or #cookie-instellingen link
ConsentBanner, ConsentSettingsthe default UI (with @kwisple/sdk/consent.css)
useConsent()your own UI: ready, bannerOpen, settingsOpen, choices, texts, config, acceptAll(), rejectAll(), save(choices), withdraw(), openSettings(), closeSettings(), given(categoryOrService)
ConsentGate<ConsentGate need="youtube" fallback={…}> renders children only with consent
ConsentEmbed<ConsentEmbed service="google-maps"> placeholder with load-once and always-allow buttons
ConsentScript<ConsentScript need="marketing" src="…" /> loads a script after consent
CookieSettingsButtonthe persistent settings button

Outside React (and in Astro, from @kwisple/sdk/consent/store): consentGiven(), whenConsented(), loadScriptWhenConsented(), openConsentSettings(), grantConsentFor(), canGrantConsentFor().

RANSON.N keeps its old banner markup and CSS and drives it with useConsent() (src/components/cookie-banner.tsx).

Every choice is logged as proof with POST /api/v1/consent (sendBeacon-compatible):

FieldMeaning
ksite key
ida random id the browser keeps with its choice; stored only as a SHA-256 hash
vthe consent version the choice was made for
c{ statistics, marketing, preferences }
aaccept_all, reject_all, custom or withdraw
llocale

No IP address and no user agent are stored. The origin must belong to the site, a version newer than the current one is refused (409), and the endpoint is rate limited (a burst of 10, then one per 10 s). The agency sees the log under Gegeven toestemmingen (consents given), the last 500 with Download als CSV.

AI-agent visits

Crawlers that collect training data, search indexes behind AI answers and assistants fetching a page for one person never run the statistics script. Your server reports them; the dashboard shows them under AI-bezoek (AI visits), apart from visitors.

maybeReportAiAgent

// Next.js: src/proxy.ts
import { maybeReportAiAgent } from "@kwisple/sdk";
import type { NextRequest } from "next/server";
import { client } from "@/lib/cms";

export async function proxy(req: NextRequest) {
  maybeReportAiAgent(client, req);
}
function maybeReportAiAgent(
  client: { reportAgentVisit(path: string, userAgent: string): Promise<void> },
  request: Request,
  waitUntil?: (promise: Promise<unknown>) => void,
): void;
  • It returns at once: a cheap user-agent check (looksLikeAiAgent(request)) filters out every normal visitor without a network call.
  • It skips /api/*, paths starting with /_ and files with an extension.
  • It sends the path and the user agent (max 500 characters) with client.reportAgentVisit, never throws, and never slows the response.
  • On serverless hosts pass the platform's waitUntil as the third argument, so the report survives the end of the response.
  • Nothing is reported on staging.

The Astro integration's middleware calls it for you.

What the platform does

POST /api/v1/agent authenticates with the site's token, then detectAiAgent in @kwisple/analytics matches the user agent against a list of known agents. Unknown agents are ignored: the answer is 202 { recorded: false, agent: null }. A burst of 300 reports, then 5 per second, is accepted per site. Nothing about a person is stored.

PurposeAgents
trainingGPTBot, ClaudeBot, Google-Extended, Applebot-Extended, Meta-ExternalAgent, Bytespider, CCBot, Amazonbot, cohere-ai, AI2Bot, Diffbot, Omgili
searchOAI-SearchBot, Claude-SearchBot, PerplexityBot, DuckAssistBot, YouBot, Timpibot
assistantChatGPT-User, Claude-User, Perplexity-User, Meta-ExternalFetcher, MistralAI-User

Hosted sites are counted by the platform's own proxy, which reports to an internal endpoint with a signature (HMAC-SHA256 over site, path, agent and timestamp, valid for one minute), so that public path cannot be used to inflate a site's numbers. Custom-code sites are authenticated by their token instead.

Visitors who click a link in an AI answer (ChatGPT, Perplexity, Copilot, Gemini, Claude, …) are real visitors: they count normally and also appear as Bezoekers via AI-antwoorden.

Retention

DataKept
Raw events (pageviews, engagement, clicks, outbound, 404s)30 days
Daily aggregates per site, day and dimensionindefinitely (no person in them)
The daily saltone day
Consent records3 years

A nightly job (analytics.rollup, 02:15) aggregates the previous days and applies these limits. Today's numbers are computed live with the same queries, so they never jump.

What the dashboard shows

Statistieken for a site (Website → Statistieken, for people with Bekijken on Statistieken) shows, for 7, 30 or 90 days:

  • Bezoekers, Pagina's bekeken, Gemiddelde bezoekduur, Zagen maar één pagina, each compared with the period before, and who is on the site now;
  • Bezoekers per dag, Meest bekeken pagina's, Eerste pagina van het bezoek;
  • Waar ze vandaan komen, Campagnes, devices, browsers and countries;
  • Klikken naar andere websites, Formulieren verstuurd, Pagina's die niet bestaan;
  • AI-bezoek: Pagina's gelezen door AI, Welke AI-systemen, Wat ze lezen and Bezoekers via AI-antwoorden.

Form submissions are counted from the form inbox (spam excluded); 404s from client.reportNotFound and the not-found beacon.

Checklist

  • <Analytics privacy={privacy} disabled={previewing} /> once in the root layout.
  • The banner (ConsentProvider + ConsentBanner, or Astro's <Consent>) when privacy.consent.required.
  • Third-party embeds behind ConsentEmbed / ConsentGate; scripts behind ConsentScript or loadScriptWhenConsented.
  • A "Cookie-instellingen" link in the footer (CookieSettingsButton or [data-consent-open]).
  • blockAttrs(type, index) on page-builder blocks.
  • maybeReportAiAgent in proxy.ts (Next); Astro does it for you.
  • The Site-URL set, so beacons from your domain are accepted.
  • The platform origin in your CSP.

Last updated 2026-10-30