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):
| Package | For |
|---|---|
@skoup/analytics | The 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-vue | app.use(createSkoupPlugin({ site, router })), useSkoup() |
@skoup/analytics-nuxt | Module: skoup: { site } in nuxt.config |
@skoup/analytics-svelte | initSkoup({ 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
| Sent | Never sent |
|---|---|
Page views: host, path, utm_* and ref parameters | Page title, form contents, any other URL parameter, fragment |
| Referrer (without its parameters) | Screen size, language, browser, OS |
Named events (skoup('event', …)) with their scalar properties | The 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.
Consent
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:
| Path | To | Headers added |
|---|---|---|
GET /_skoup/t.js | https://app.skoup.ai/t.js | — |
POST /_skoup/t | https://api.skoup.ai/t | X-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.fracceptswww.,shop.andapp.; 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(orcookieDomain) 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.