Docs
Install Arrivl on any stack
About five minutes, whichever path fits. AI agents don't run JavaScript, so Arrivl installs where requests actually arrive — your edge or your server — never as a browser beacon.
No code · fastest
Cloudflare
Enter your domain, authorize Arrivl, done — we deploy the tracking on your own Cloudflare account through the official OAuth flow. Disconnect any time from your CF dashboard.
Start with CloudflareNo code
Shopify
A few DNS settings connect your store — no app, no theme changes, works on every plan.
Full Shopify guideNext.js middleware
One file + one env var. Works on Vercel, AWS, or self-hosted.
.env
ARRIVL_WEBSITE_KEY=ak_YOUR_WEBSITE_KEY
middleware.ts / proxy.ts
// middleware.ts (Next.js 15) or proxy.ts (Next.js 16)
// Next 16 note: rename the function to `proxy` and the file to `proxy.ts`.
import { NextRequest, NextResponse, NextFetchEvent } from 'next/server';
export function middleware(request: NextRequest, event: NextFetchEvent) {
const { pathname } = request.nextUrl;
// Never track your own API or static assets: the matcher below excludes
// most of these, this is a second-layer guard.
if (pathname.startsWith('/api/')) return NextResponse.next();
// Real client/bot IP. Prefer Cloudflare's CF-Connecting-IP (CF sets it to
// the true client) so a CF-fronted site records the real bot IP, not the
// Cloudflare edge IP; fall back to the leftmost X-Forwarded-For, then
// X-Real-IP. Matches the CF Worker's precedence.
const ip =
request.headers.get('cf-connecting-ip') ||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'';
// intake requires a non-empty ip; if none resolved (rare), skip the send.
if (!ip) return NextResponse.next();
const params = new URLSearchParams({
url: request.url,
userAgent: request.headers.get('user-agent') || '',
ref: request.headers.get('referer') || '',
ip,
websiteKey: process.env.ARRIVL_WEBSITE_KEY!,
});
// event.waitUntil sends the request in the background WITHOUT blocking the
// response. A bare un-awaited fetch is killed on Edge/serverless (Vercel,
// Cloudflare, Netlify): the runtime freezes the function the instant the
// response is returned, so the event never leaves. waitUntil keeps it alive,
// on every platform (no @vercel/functions needed).
event.waitUntil(
fetch(`https://yourdomain.com/api/v1/intake/pageview?${params}`).catch(() => {})
);
return NextResponse.next();
}
// Track AI-discovery paths (robots.txt, llms.txt, sitemap.xml, .well-known)
// even though their extensions look static: these are the highest-signal
// hits Arrivl captures. The catch-all below then excludes other .txt/.xml/.json.
export const config = {
matcher: [
'/robots.txt',
'/llms.txt',
'/llms-full.txt',
'/sitemap.xml',
'/ai.txt',
'/sitemap-:path*',
'/.well-known/:path*',
'/((?!api|_next/static|_next/image|favicon.ico|manifest.webmanifest|.*\\.(?:png|jpg|jpeg|svg|gif|webp|ico|css|js|mjs|map|woff2?|ttf|otf|eot|txt|xml|json)$).*)',
],
};Replace ak_YOUR_WEBSITE_KEY with your real key: sign in → Dashboard → Settings → API Keys → Copy. The in-dashboard version of this page fills it in for you.
Any backend — plain HTTP
One GET per pageview, fire-and-forget from your server. Rails, Django, Laravel, Go, .NET — anything that can send a request.
curl --request GET \ "https://yourdomain.com/api/v1/intake/pageview\ ?url=https://yoursite.com/blog/post\ &userAgent=Mozilla/5.0%20(compatible;%20ChatGPT-User/1.0)\ &ref=\ &ip=1.2.3.4\ &websiteKey=ak_YOUR_WEBSITE_KEY"
Replace ak_YOUR_WEBSITE_KEY with your real key: sign in → Dashboard → Settings → API Keys → Copy. The in-dashboard version of this page fills it in for you.
AWS Lambda / Node
// track.js: shared helper, no dependencies (Node 18+ global fetch)
const INTAKE = 'https://yourdomain.com/api/v1/intake/pageview';
export function trackArrivl({ url, userAgent, ref, ip }) {
// intake requires a non-empty userAgent and ip; skip the send otherwise.
if (!userAgent || !ip) return;
const params = new URLSearchParams({
url,
userAgent,
ref: ref || '',
ip,
websiteKey: process.env.ARRIVL_WEBSITE_KEY,
});
// Fire-and-forget: no await. .catch keeps a failed send silent.
fetch(`${INTAKE}?${params}`).catch(() => {});
}
// --- AWS Lambda (API Gateway / Lambda URL, HTTP API v2 event) ---
export const handler = async (event) => {
const h = event.headers || {};
const proto = h['x-forwarded-proto'] || 'https';
const host = h.host || h.Host || 'yoursite.com';
const path = event.rawPath || event.path || '/';
const qs = event.rawQueryString ? `?${event.rawQueryString}` : '';
trackArrivl({
url: `${proto}://${host}${path}${qs}`,
userAgent: h['user-agent'],
ref: h.referer || h.referrer,
// Real client/bot IP: leftmost X-Forwarded-For, then the request source IP.
ip:
h['x-forwarded-for']?.split(',')[0]?.trim() ||
event.requestContext?.http?.sourceIp,
});
// ... your real response ...
return { statusCode: 200, headers: { 'content-type': 'text/html' }, body: '<!doctype html>...' };
};
// --- Express / Fastify on EC2 or Fargate ---
// app.use((req, _res, next) => {
// if (req.method === 'GET' && req.accepts('html')) {
// trackArrivl({
// url: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
// userAgent: req.get('user-agent'),
// ref: req.get('referer'),
// ip: req.get('x-forwarded-for')?.split(',')[0]?.trim() || req.ip,
// });
// }
// next();
// });Replace ak_YOUR_WEBSITE_KEY with your real key: sign in → Dashboard → Settings → API Keys → Copy. The in-dashboard version of this page fills it in for you.
Signed in, this page fills in your real key — and verification runs automatically.