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 Cloudflare

No code

Shopify

A few DNS settings connect your store — no app, no theme changes, works on every plan.

Full Shopify guide

Next.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.

Book a demo