Skip to Content
GuidesSkoup Analytics

Skoup Analytics

Skoup Analytics measures the visits ChatGPT, Perplexity, Gemini or Claude send to your site, for Revenue › AI traffic and GET /v1/brands/{brand}/traffic. Enable it in Settings › Integrations › Skoup Analytics, then paste the tag.

<script defer src="https://app.skoup.ai/t.js" data-site="site_…"></script>

Paste it on every page, right before </body>. Hits go to https://api.skoup.ai/t (POST, JSON as text/plain, sendBeacon), batched every second.

With a framework

The core and its wrappers are published on npm, source on github.com/lunifyfr/skoup-sdk  (MIT):

PackageFor
@skoup/analyticsThe core: createSkoup({ site }), or the skoup.iife.js tag
@skoup/analytics-react<SkoupProvider site>, useSkoup(), useSkoupPageview()
@skoup/analytics-next<SkoupAnalytics site> in the root layout (App Router)
@skoup/analytics-vueapp.use(createSkoupPlugin({ site, router })), useSkoup()
@skoup/analytics-nuxtModule: skoup: { site } in nuxt.config
@skoup/analytics-svelteinitSkoup({ site }), trackPage() from afterNavigate
// Next.js — app/layout.tsx import { SkoupAnalytics } from '@skoup/analytics-next' export default function RootLayout({ children }) { return <SkoupAnalytics site="site_…" consent="wait">{children}</SkoupAnalytics> }

Every wrapper exposes the same instance: skoup.event(), skoup.consent(), skoup.attribution(). Page views follow the framework’s navigation, nothing to call.

What Skoup Analytics measures

SentNever sent
Page views: host, path, utm_* and ref parametersPage title, form contents, any other URL parameter, fragment
Referrer (without its parameters)Screen size, language, browser, OS
Named events (skoup('event', …)) with their scalar propertiesThe IP address is not stored: it gives the country, then is forgotten
Visitor id: a random value in the skp_vid cookie (13 months), set on the site’s registrable domain (nordvelo.fr for www., shop. and app.: one visitor across subdomains)Any identity data

A visit groups the hits of one visitor without a thirty-minute gap; a page entered from an AI assistant or with a utm_source opens a new visit, so it gets attributed. A visit’s source (referrer or utm_source) is classified by the surface list Skoup maintains. The first touch of a conversion is the oldest visit of the same visitor within the last 30 days.

The script sets a cookie: load it after your visitors’ consent, through your banner (CMP). Or let it load and hold the cookie back:

<script defer src="https://app.skoup.ai/t.js" data-site="site_…" data-consent="wait"></script> <script> // when your banner gets the consent: window.skoup && window.skoup('consent', 'granted') </script>

Without a cookie, each hit is a visit of an unknown visitor: no first touch, no deferred conversion.

Events

// a sign-up, a demo request, a call, a booking… skoup('event', 'signup', { plan: 'growth' }) // an order outside Shopify (amount, currency, order id) skoup('event', 'purchase', null, { value: 129.9, currency: 'EUR', order_id: '1042' })

An event name is [a-z0-9_], 64 characters at most; up to 20 scalar properties of 256 characters. For a store, the report’s conversion counts the purchase events; for a SaaS or an establishment, the key events chosen in the app (every named event by default). Calls made before the script loads are queued: window.skoup = window.skoup || function () { (window.skoup.q = window.skoup.q || []).push(arguments) }.

A single-page app sees every route change as a page; skoup('pageview') forces one.

Attributing a Stripe subscription

skoup.attribution() returns what the visit came from — { visitor_id, referrer, landing_url, utm_source, utm_medium, utm_campaign }, kept for the browser session. Write it into the subscription’s (or customer’s) Stripe metadata, which Skoup already reads:

const from = skoup.attribution() ?? {} await stripe.checkout.sessions.create({ subscription_data: { metadata: { skoup_referrer: from.referrer, skoup_utm_source: from.utm_source, skoup_landing_url: from.landing_url } }, // … })

Shopify store

On a connected Shopify store, Skoup installs the Skoup Analytics pixel itself (the app’s web pixel extension) as soon as the brand has its site key: page views, product views, add-to-carts, checkouts and purchases, including the checkout pages a theme tag cannot reach. Nothing to paste in the theme; the tag remains for your other sites (landing, blog).

The pixel sends its hits through the store’s app proxy (https://your-store.com/apps/skoup/t): first-party, signed by Shopify, off the blockers’ lists. Consent is the store’s: the pixel is declared analytics to Shopify, which loads it only after the visitor agreed where the law requires it (Shopify’s banner or a compatible consent app).

Every purchase carries the order id and the checkout token: Skoup joins the order received by webhook to the visit that made it, whichever arrives first. That is what feeds the “First AI contact” of Revenue › Orders and first_touch of GET /v1/brands/{brand}/attribution: the buyer who asked ChatGPT ten days ago, then came back through Google.

The pixel needs the write_pixels and read_customer_events scopes: a store connected before they were added shows “Reconnect the store” in Settings › Integrations. Rotating the site key updates the pixel; forgetting the key removes it from the store.

First-party proxy

Serve the tag and receive the hits on your own domain: ad blockers no longer see skoup.ai, and the skp_vid cookie is set by the collector’s response, over HTTP, so it lives 13 months even on Safari (which caps a cookie set in JavaScript at 7 days). Nothing to declare on Skoup’s side, like Sentry’s tunnel: copy the secret from Settings › Integrations › Skoup Analytics › Install › First-party proxy, and serve the tag from your domain. The script served at /_skoup/t.js sends its hits to /_skoup/t on its own (data-endpoint forces it if needed):

<script defer src="https://www.your-site.com/_skoup/t.js" data-site="site_…"></script>

Your server forwards two paths, adding two headers to the request to the collector:

PathToHeaders added
GET /_skoup/t.jshttps://app.skoup.ai/t.js—
POST /_skoup/thttps://api.skoup.ai/tX-Skoup-Proxy: <secret> · X-Skoup-Client-IP: <visitor's IP>

The collector trusts X-Skoup-Client-IP (for the country, never stored) and returns the cookie only with the right secret; the collector’s response, Set-Cookie included, goes back to the browser as is. Without the secret, the hit is handled as a direct one.

nginx

location = /_skoup/t.js { proxy_pass https://app.skoup.ai/t.js; proxy_set_header Host app.skoup.ai; proxy_ssl_server_name on; } location = /_skoup/t { proxy_pass https://api.skoup.ai/t; proxy_set_header Host api.skoup.ai; proxy_ssl_server_name on; proxy_set_header X-Skoup-Proxy "skp_proxy_…"; proxy_set_header X-Skoup-Client-IP $remote_addr; proxy_pass_request_headers on; }

Cloudflare Worker (route www.your-site.com/_skoup/*)

export default { async fetch(request, env) { const url = new URL(request.url) if (url.pathname === '/_skoup/t.js') { return fetch('https://app.skoup.ai/t.js', { cf: { cacheTtl: 3600 } }) } if (url.pathname === '/_skoup/t' && request.method === 'POST') { const headers = new Headers(request.headers) headers.set('X-Skoup-Proxy', env.SKOUP_PROXY_SECRET) headers.set('X-Skoup-Client-IP', request.headers.get('CF-Connecting-IP') ?? '') return fetch('https://api.skoup.ai/t', { method: 'POST', headers, body: request.body }) } return new Response(null, { status: 404 }) }, }

Next.js (app/_skoup/[...path]/route.ts)

export async function GET() { return fetch('https://app.skoup.ai/t.js', { next: { revalidate: 3600 } }) } export async function POST(request: Request) { const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '' return fetch('https://api.skoup.ai/t', { method: 'POST', headers: { 'Content-Type': 'text/plain', 'X-Skoup-Proxy': process.env.SKOUP_PROXY_SECRET!, 'X-Skoup-Client-IP': ip }, body: await request.text(), }) }

The secret is renewed with the site key (« Regenerate the key »). Keep it server-side: never in the page.

Pitfalls

  • One site per brand, declared hosts: hits from a domain other than the key’s are dropped (empty = every domain). A declared host covers its subdomains: nordvelo.fr accepts www., shop. and app.; a second domain (a landing on another name) is declared on its own. The cookie is set on the registrable domain the browser accepts; data-cookie-domain (or cookieDomain) forces it.
  • The current day is not counted: the report stops at yesterday; hits are recorded every minute.
  • Regenerating the key refuses the old one at once: update the tag. Forgetting the key stops the collection and keeps the visits already measured.
  • Ad blockers and Safari: a blocker may hold the script back; Safari caps a cookie set in JavaScript at 7 days, which shortens the first touch. The first-party proxy above lifts both.
Last updated on