Server-side collection and visit tokens

Report visits from a server with each visitor's own address and user agent, keep a visit token, and credit later conversions and orders to the visit.

Embed
Preview of the card
<a href="https://seolyticize.com/docs/server-side-collection"><img src="https://seolyticize.com/docs/_api/public/orgs/71cb2b7a-a719-4003-b3ea-6321dc1db8ab/spaces/3bf2dbf7-3faa-473c-ba3b-30588f5f8de4/pages/5fe06f39-d272-442a-9993-6a97048af0e2/social-card.svg?v=3e7ae33eec8b" alt="Server-side collection and visit tokens" width="600" height="315"></a>

The analytics snippet reports from the visitor's browser. A server collector reports from your backend instead: for AI agents and crawlers that never run a script, for frameworks that render on the server, and for conversions that happen after the visitor has left — a payment confirmed an hour later, a trial that converts next week. Three pieces, each with a curl example below:

  1. The collect request from a server, with the visitor's own address (ip) and user agent (ua), authenticated with the site's server key.

  2. The visit token the 202 answer carries, kept on your server and sent with a later event.

  3. record_order for orders from anywhere, credited to the visit by its token, with an Idempotency-Key so a retried push never counts twice.

The request fields every event shares (k, n, u, r, p) are on Web analytics without the script, with the full table of answers.

The request from a server

The visitor hash uses the address and user agent the request comes from. Sent from your own server, every event would share the server's address and count as one visitor. So a server collector authenticates with the site's server key — from issue_server_collect_key, shown once, kept in an environment variable and never in a page — and passes each visitor's own address and user agent in the body:

Field

Where

Authorization

header

Bearer <server key>. A wrong key is refused with 401 rather than counted as a browser.

ip

body

The visitor's own address, as your proxy reported it: CF-Connecting-IP, X-Real-IP, or the client end of X-Forwarded-For. Hashed under a daily salt, never stored. Up to 64 characters.

ua

body

The visitor's User-Agent header: the visitor hash, the bot check, and the browser, OS and device. Never stored whole. Up to 1,024 characters.

h

body

The visitor's Sec-CH-UA client hints, if sent. Up to 512 characters.

sa

body

The Signature-Agent header signing agents such as ChatGPT send, so an assistant is counted as one. Up to 256 characters.

c

body

The visitor's country, ISO 3166-1 alpha-2, as your proxy said it (Cloudflare's CF-IPCountry).

visit

body

A visit token, on an event that happens after the visit (below). u may then be left out.

These fields are read only from a request carrying the server key. From anyone else they are ignored, so a page cannot claim to be any visitor it likes. A server request needs no Origin; the page's hostname in u must still be one of the setup's.

curl -X POST https://seolyticize.com/api/collect \
  -H "Authorization: Bearer $SEOLYTICIZE_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"k":"<site key>","n":"pageview","u":"https://example.com/pricing","r":"https://www.google.com/","ip":"203.0.113.7","ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36","c":"GB"}'

The answer is 202 with the visit's token. Keep it on your server if a conversion may follow later (below); otherwise ignore it:

HTTP/1.1 202 Accepted
Content-Type: application/json

{"visit":"v1.AAAAAZJ3kQ8xAKwF.8yq6Vv0m3QnXw1kJ2pE7bA"}

Only a person's visit gets a token. An AI agent, a bot or an excluded path is answered 202 with no body: there is no visit to credit a conversion to.

The packaged collectors fill these in from the request's own headers, drop bots before sending, and never hold up the page:

import { addressFrom, createCollector } from "@seolyticize/next/server";

const collector = createCollector({
  siteKey: "<site key>",
  serverKey: process.env.SEOLYTICIZE_SERVER_KEY!,
  only: "agents", // with the browser script installed too; "all" without it
});

await collector.trackRequest(request); // reads ip, ua, h, sa and c from the headers
// or name them: collector.track({ url, userAgent, ip: addressFrom(request.headers), country })

With the browser script also installed, report only AI agents (only: "agents"), or every person is counted twice. Requests from bots are accepted and quietly dropped; one visitor sending too fast is answered 429.

Conversions after the visit

The visitor id resets daily and nothing is stored on the device, so a conversion that happens later — a crypto top-up confirmed an hour after checkout, a trial that converts next week — cannot be tied to its visit by itself. A visit token does it:

  1. While the visitor is on the site, the server collector reports an event for them (a sign-up, a checkout started) with their ip and ua. The 202 answer carries {"visit": "v1.…"}.

  2. The site keeps the token on its own server, with the account or the order. It is never sent to a browser and goes in no cookie.

  3. Later, the server sends the event with the token, and nothing about the visitor:

curl -X POST https://seolyticize.com/api/collect \
  -H "Authorization: Bearer $SEOLYTICIZE_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"k":"<site key>","n":"Top-up","p":{"value":25},"visit":"v1.…"}'

The event is credited to the visit's landing page, source, campaign and channel, so goals (get_goal_report), funnels and get_conversions count it there; a ref or order_id property on it links a Stripe, Shopify or Paddle order to the visit for get_seo_revenue, and record_order takes the token as visit for orders from anywhere else. A credited event counts as a conversion in the period it happened and never as a visit.

The token holds only the visit's random id and the minute it was issued, signed and bound to the site, so it cannot be guessed, edited to name another visit, or used on another site. It lasts 35 days. In the collectors: trackForVisit() / trackLater() (Node), track_for_visit() / track_later() (Python), trackForVisit() / trackLater() (PHP).

Orders from anywhere

A business on neither Stripe, Shopify nor Paddle pushes its orders with an API key (one pinned to the client is enough): record_order for one, record_orders for up to 500 per call. Each carries the visit token, when the site kept one, and is credited to that visit in get_seo_revenue.

One order, credited to the visit whose token the site kept:

curl -X POST https://seolyticize.com/api/v1/tools/record_order \
  -H "Authorization: Bearer $SEOLYTICIZE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-inv_1042" \
  -d '{"clientId":"<clientId>","orderId":"inv_1042","amountMinor":4999,"currency":"USD","at":"2026-10-24T09:12:00Z","visit":"v1.AAAAAZJ3kQ8xAKwF.8yq6Vv0m3QnXw1kJ2pE7bA"}'
{ "data": { "orderId": "inv_1042", "outcome": "recorded", "attributedBy": "analytics", "channel": "organic", "landingPath": "/pricing", "note": null, "code": null } }

attributedBy: "analytics" means the token matched its visit, and channel and landingPath say where it is credited; "none" with a note means the token had expired or its visit was not found: the order still counts in revenue, unattributed. A token issued for another site is refused (invalid_visit) rather than guessed at.

A batch, up to 500:

curl -X POST https://seolyticize.com/api/v1/tools/record_orders \
  -H "Authorization: Bearer $SEOLYTICIZE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: orders-2026-10-24T10" \
  -d '{"clientId":"<clientId>","orders":[
        {"orderId":"inv_1042","amountMinor":4999,"currency":"USD","at":"2026-10-24T09:12:00Z","visit":"v1.…"},
        {"orderId":"ref_77","amountMinor":1000,"currency":"USD","at":"2026-10-24T09:40:00Z","refundOf":"inv_0998"}]}'

amountMinor is in the currency's minor unit (4999 is 49.99 USD; 500 is 500 JPY). Recording an orderId again replaces the order, and an identical replay is answered unchanged, so a push can simply be repeated. A refund is an order of its own with refundOf naming the order it refunds: it nets off revenue in the period it happened and is credited where its order was. Each order is answered on its own — recorded, updated, unchanged or refused with a reason — and one refused order does not stop the rest.

Retrying safely: Idempotency-Key

Every write and every paid tool call takes an Idempotency-Key header (up to 200 characters). A retry under the same key within 24 hours — after a timeout or a dropped connection — gets the first answer back, marked Idempotent-Replayed: true, and does not run again:

# The same request twice: the second is answered from the first.
curl -i -X POST https://seolyticize.com/api/v1/tools/record_order \
  -H "Authorization: Bearer $SEOLYTICIZE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-inv_1042" \
  -d '{"clientId":"<clientId>","orderId":"inv_1042","amountMinor":4999,"currency":"USD","at":"2026-10-24T09:12:00Z"}'
# HTTP/1.1 200 OK
# Idempotent-Replayed: true

Answer

Meaning

409 idempotency_in_progress

The first request under the key is still running; retry when it finishes.

422 idempotency_key_reused

The key was used for a different tool or body; a new request needs a new key.

A 5xx

Not kept: the key is freed, so a retry runs the call again.

Name the key after what it is for (order-inv_1042), not the attempt: a fresh key per attempt protects nothing. /api/collect itself needs no key — a repeated event is a repeated event, and the visitor rate limit answers 429 to a flood. The details are on Errors and limits.

Updated