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| Field | Meaning |
|---|---|
analytics.enabled | Statistics are on (Bezoekers tellen) and the site is online. |
analytics.requiresConsent | Counting waits for the Statistieken consent (strict mode, off by default). |
analytics.scriptUrl | The versioned, immutable script URL (…/api/v1/a/<version>.js). |
analytics.collectUrl | Where the script sends events (…/api/v1/collect). |
consent.siteKey | The public site key the script and the banner send. |
consent.version | Goes 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.required | false when the site uses nothing that needs consent: show no banner. |
consent.categories | necessary, statistics, marketing, preferences as used, each with label, description and services (provider, purpose, privacy link, cookies with retention). |
consent.serviceCategories | Service id (youtube, google-maps, …) → category, for gating embeds. |
consent.texts | Every banner and settings text in the requested locale. |
consent.expiresDays | How long a choice is kept: 182 days. |
consent.storageKey | site_consent, the local-storage key of the choice. |
consent.privacyUrl | The privacy statement link (Link naar de privacyverklaring). |
consent.endpoint | Where 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>| Attribute | Meaning |
|---|---|
data-site | The site key. Required on any domain other than the platform's own. |
data-api | The collect endpoint. Default: the script's origin + /api/v1/collect. |
data-consent | A consent category (statistics): count only after that consent. |
data-consent-version | The 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):
| Type | When | Carries |
|---|---|---|
pv pageview | on load and on every client-side navigation (pushState, replaceState, popstate) | page URL, referrer |
en engagement | when the page is left or hidden, after at least one second visible | visible time, deepest scroll (%), blocks seen |
ob outbound | click on an external, mailto: or tel: link | the link |
ck click | click on an internal link or a button | the 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: 1orSec-GPC: 1, is a prefetch, or comes from a bot; - the site is offline or statistics are off;
- the
Originis 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.
Strict mode: counting only after consent
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 consent banner
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.requiredisfalse; 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/consent | Use |
|---|---|
ConsentProvider | wraps the layout with config={privacy.consent}; also opens the settings from any [data-consent-open] element or #cookie-instellingen link |
ConsentBanner, ConsentSettings | the 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 |
CookieSettingsButton | the 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).
Consent records
Every choice is logged as proof with POST /api/v1/consent (sendBeacon-compatible):
| Field | Meaning |
|---|---|
k | site key |
id | a random id the browser keeps with its choice; stored only as a SHA-256 hash |
v | the consent version the choice was made for |
c | { statistics, marketing, preferences } |
a | accept_all, reject_all, custom or withdraw |
l | locale |
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
waitUntilas 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.
| Purpose | Agents |
|---|---|
| training | GPTBot, ClaudeBot, Google-Extended, Applebot-Extended, Meta-ExternalAgent, Bytespider, CCBot, Amazonbot, cohere-ai, AI2Bot, Diffbot, Omgili |
| search | OAI-SearchBot, Claude-SearchBot, PerplexityBot, DuckAssistBot, YouBot, Timpibot |
| assistant | ChatGPT-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
| Data | Kept |
|---|---|
| Raw events (pageviews, engagement, clicks, outbound, 404s) | 30 days |
| Daily aggregates per site, day and dimension | indefinitely (no person in them) |
| The daily salt | one day |
| Consent records | 3 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>) whenprivacy.consent.required. - Third-party embeds behind
ConsentEmbed/ConsentGate; scripts behindConsentScriptorloadScriptWhenConsented. - A "Cookie-instellingen" link in the footer (
CookieSettingsButtonor[data-consent-open]). blockAttrs(type, index)on page-builder blocks.maybeReportAiAgentinproxy.ts(Next); Astro does it for you.- The Site-URL set, so beacons from your domain are accepted.
- The platform origin in your CSP.