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.
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:
The collect request from a server, with the visitor's own address (
ip) and user agent (ua), authenticated with the site's server key.The visit token the
202answer carries, kept on your server and sent with a later event.record_orderfor orders from anywhere, credited to the visit by its token, with anIdempotency-Keyso 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 | |
|---|---|---|
| header |
|
| body | The visitor's own address, as your proxy reported it: |
| body | The visitor's |
| body | The visitor's |
| body | The |
| body | The visitor's country, ISO 3166-1 alpha-2, as your proxy said it (Cloudflare's |
| body | A visit token, on an event that happens after the visit (below). |
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:
- Node / Next.js
- Python
- PHP
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 })import os
from seolyticize.collect import Collector, WSGICollector
collector = Collector("<site key>", os.environ["SEOLYTICIZE_SERVER_KEY"], only="agents")
app.wsgi_app = WSGICollector(app.wsgi_app, collector) # Flask; ASGICollector, django_middleware too
# or: collector.track(url, user_agent=ua, ip=ip, country=country)$collector = new \Seolyticize\Collector('<site key>', getenv('SEOLYTICIZE_SERVER_KEY'), ['only' => 'agents']);
register_shutdown_function(fn () => $collector->trackCurrentRequest()); // reads $_SERVER
// or: $collector->track($url, ['userAgent' => $ua, 'ip' => $ip, 'country' => $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:
While the visitor is on the site, the server collector reports an event for them (a sign-up, a checkout started) with their
ipandua. The202answer carries{"visit": "v1.…"}.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.
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: trueAnswer | Meaning |
|---|---|
| The first request under the key is still running; retry when it finishes. |
| The key was used for a different tool or body; a new request needs a new key. |
A | 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.