sign in -> My sites -> Add site; later in My account). * 2) Replace the sample data with data from your own e-shop (delete any fields you do not need). * IMPORTANT: shop.domain must be YOUR domain - otherwise you tie the key to a * foreign domain and the next (real) send ends with error 409. * 3) Try it as a DRY RUN - nothing is sent, it only prints the body + signature: * php trackless-ingest-example.php * Once everything is fine, send it FOR REAL with the --send flag: * php trackless-ingest-example.php --send * * You only need PHP with the cURL extension. Full documentation: https://trackless.cz/api * * HOW OFTEN TO SEND (not required, but RECOMMENDED - it saves resources on both sides): * Do not send a separate request for every single visit or order. Data * in Trackless is not (and need not be) real-time - reports are computed in * batches, so frequent sending gains you nothing and only piles up needless * transfer and load on your server and ours. Recommended approach: * - collect data on your side into a queue (your own table or a log); * - send it in BATCHES, typically once a day, ideally at night via cron; * - send only new/changed data since the last successful send; * - on a transient error (429, 5xx or a network failure), send the batch again; * permanent 4xx errors need a request/key fix instead of a retry loop. * This is exactly how the official server-side modules and plugins do it. * * Notes: * - Numbers may be a number or text ("1290.00"). Dates in the format "YYYY-MM-DD HH:MM:SS". * - visitor_uuid = pseudonymous visitor key (rotates daily), session_id = 30min window. * The canonical formula (same as the official modules) is below in section 1: hash_hmac of * IP+UA salted with $salt. Use it so that visits and orders pair up correctly. * - ip_hash is a KEYED hash of the IP (hash_hmac with the salt), never the raw IP; for visits * and orders use the same construction (it serves as the attribution pairing key). * - If you have no server-side access, there is also a JS tracking snippet (https://trackless.cz/api). * - Storing is idempotent: you can safely send the same batch again. * - EXCLUDED IPs: the body of a successful response (HTTP 200) is JSON * {"ok":true,"excluded_ips":["203.0.113.4","10.0.0.0/8", ...],"geoip":{...}} * "excluded_ips" is the account's central list of excluded IPs (exact IPs and CIDR * ranges, IPv4 and IPv6). Store it and, BEFORE the next send, drop on your side any * records whose client IP matches an entry (you know the real IP, the server only * gets a salted hash). Old clients that read just the status code keep working * unchanged (the body used to be the plain text "OK"). * - BOTS: on each touches/events/client row you may send 'user_agent' (the raw * visitor User-Agent). The server detects bots from this UA itself and excludes them per * your list in My account - you do not have to detect anything. The UA is NOT stored, it * serves only to recognise a bot (a row without user_agent is treated as a human). * ============================================================================ */ // ===== 1) FILL IN ========================================================= $apiKey = 'PASTE_YOUR_API_KEY_HERE'; $endpoint = 'https://trackless.cz/ingest'; // Secret salt for hashing IP addresses (make up your own long random string // and DO NOT CHANGE it). IMPORTANT: use the same hash_hmac(ip, salt) construction for // visits and orders - the app pairs orders with visits via a matching ip_hash. $salt = 'PASTE_YOUR_OWN_SECRET_SALT'; // BY DEFAULT NOTHING is sent (dry run) - it only prints what would be // sent. This way you will not accidentally create a test e-shop under your key. For real, use --send: // php trackless-ingest-example.php --send $LIVE = in_array('--send', $argv ?? [], true); // --- Canonical derivation of the visitor keys (EXACTLY like the official modules; see // PORT_CONTRACT). Everything is salted with $salt, the raw IP is never sent. You know the real // IP and User-Agent on your own server ($_SERVER['REMOTE_ADDR'] / ['HTTP_USER_AGENT']). --- $ip = '203.0.113.10'; $ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120'; $visitorUuid = substr(hash_hmac('sha256', $ip . '|' . $ua . '|' . gmdate('Y-m-d'), $salt), 0, 32); // rotates daily $ipHash = hash_hmac('sha256', $ip, $salt); // 64 hex, pairing key across days $sessionId = substr(hash_hmac('sha256', $visitorUuid . '|' . floor(time() / 1800), $salt), 0, 32); // 30min tumbling window // ===== 2) DATA FROM YOUR E-SHOP (everything is optional) ======================== $payload = [ // --- E-shop identification (recommended to always send) --- 'shop' => [ 'id' => 1, 'domain' => 'muj-eshop.cz', ], // --- Visits / marketing sources (UTM, referer, channel, session) --- 'touches' => [ [ 'id_touch' => 1001, // your unique visit ID 'visitor_uuid' => $visitorUuid, // pseudonymous visitor key (daily rotation) 'id_customer' => 42, // 0 or omit if not logged in 'date_add' => date('Y-m-d H:i:s'), 'source' => 'google', // utm_source 'medium' => 'cpc', // utm_medium 'campaign' => 'jaro-2026', // utm_campaign 'content' => 'banner-A', // utm_content 'term' => 'tricka', // utm_term 'gclid' => '', // Google click id 'fbclid' => '', // Facebook click id 'landing_url' => '/', // landing page 'referer' => 'https://www.google.com/', 'ip_hash' => $ipHash, // KEYED hash of the IP, never the raw IP 'session_id' => $sessionId, // 30min tumbling session 'channel' => 'paid_search', // direct/organic_search/paid_search/organic_social/paid_social/email/ai/referral 'country' => 'CZ', // ISO-3166 alpha-2 country derived from the visitor IP 'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', // raw visitor UA; the server detects a bot from it (NOT stored) ], ], // --- Orders (revenue + attribution) --- // Trackless assigns order_verification=server_verified to every order in this signed /ingest // batch. Do not send order_verification yourself; it is a server-owned field. 'orders' => [ [ 'id_order' => 5001, 'id_customer' => 42, 'visitor_uuid' => $visitorUuid, 'ip_hash' => $ipHash, // SAME construction as in touches 'country' => 'CZ', // visitor country, not billing or shipping country 'ft_source' => 'google', // first-touch 'ft_medium' => 'organic', 'ft_campaign' => '', 'lt_source' => 'google', // last-touch (order source) 'lt_medium' => 'cpc', 'lt_campaign' => 'jaro-2026', 'gclid' => '', 'fbclid' => '', 'n_touches' => 3, // number of visits before purchase 'days_to_convert' => 2, 'total_paid' => '1169.00', // total paid (incl. VAT, after discounts) 'total_paid_tax_excl' => '966.12', 'total_products' => '900.00', // products after discounts, excl. shipping/fees 'total_products_wt' => '1089.00', 'total_shipping_tax_excl' => '66.12', 'total_shipping_tax_incl' => '80.00', 'total_discounts_tax_excl' => '100.00', 'total_discounts_tax_incl' => '121.00', 'total_refunded_tax_excl' => '0.00', // refunds (net revenue subtracts them) 'total_refunded_tax_incl' => '0.00', 'conversion_rate' => '1', // currency rate vs the default 'id_currency' => 1, 'currency' => 'CZK', 'voucher_code' => '', 'payment' => 'Platebni karta', 'id_carrier' => 2, 'carrier_name' => 'PPL', 'id_customer_group' => 1, 'is_first_order' => 1, // 1 = customer's first order 'valid' => 1, // 1 = paid/valid 'current_state' => 2, // order state ID (see dimensions.order_states) 'date_order' => date('Y-m-d H:i:s'), ], ], // --- Order items (incl. wholesale price for margin) --- 'order_items' => [ [ 'id_order_item' => 9001, 'id_order' => 5001, 'id_product' => 7, 'id_product_attribute' => 0, // variant (0 = none) 'product_name' => 'Tricko', 'product_reference' => 'TRIKO-01', // SKU / catalogue number 'id_category_default' => 12, 'quantity' => 2, 'unit_price_tax_excl' => '413.22', 'unit_price_tax_incl' => '500.00', 'total_price_tax_excl' => '826.45', 'total_price_tax_incl' => '1000.00', 'wholesale_price' => '300.00', // WHOLESALE price (for margin calculation) ], ], // --- Events (on-site behaviour) --- 'events' => [ [ 'id_event' => 1, 'visitor_uuid' => $visitorUuid, 'id_customer' => 42, 'date_add' => date('Y-m-d H:i:s'), 'event_type' => 'view_item', // standard event or custom lowercase snake_case name, max 40 chars (purchases go via 'orders') 'page_type' => 'product', // home/category/product/cms/search/cart/checkout/404 'id_object' => 7, // e.g. product/category id 'quantity' => 1, 'value' => '500.00', // value (e.g. for add_to_cart) 'currency' => 'CZK', 'search_query' => '', // for event_type=search 'url' => '/tricko', 'session_id' => $sessionId, 'referer' => 'https://www.google.com/', 'country' => 'CZ', // ISO-3166 alpha-2 country derived from the visitor IP 'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', // raw UA; the server detects a bot from it (not stored) ], [ 'id_event' => 2, 'visitor_uuid' => $visitorUuid, 'date_add' => date('Y-m-d H:i:s'), 'event_type' => 'search', 'page_type' => 'search', 'search_query' => 'panske tricko', 'url' => '/vyhledavani?q=panske+tricko', 'session_id' => $sessionId, ], [ 'id_event' => 3, // stable unique id; retries stay idempotent 'visitor_uuid' => $visitorUuid, 'date_add' => date('Y-m-d H:i:s'), 'event_type' => 'lead_submitted', // any valid custom event name 'page_type' => 'cms', 'id_object' => 123, // optional business/form/content id 'quantity' => 1, 'value' => '1500.00', 'currency' => 'CZK', 'url' => '/kontakt/dekujeme', 'session_id' => $sessionId, ], ], // --- Visitor context (server-side, no fingerprinting) --- 'client' => [ [ 'visitor_uuid' => $visitorUuid, // REQUIRED (row key) 'id_client' => null, 'date_add' => date('Y-m-d H:i:s'), 'device_type' => 'desktop', // desktop/mobile/tablet 'browser' => 'Chrome', 'os' => 'Windows', 'country' => 'CZ', // ISO-3166 alpha-2 (e.g. from $_SERVER['HTTP_CF_IPCOUNTRY'] or ['GEOIP_COUNTRY_CODE']) 'language' => 'cs', 'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', // raw UA; the server detects a bot from it (not stored) // Optional, null here on purpose. 1 = the IP belongs to a datacenter, cloud or VPN, i.e. // a bot farm rather than a shopper; hosting_asn is that network's AS number. Our own // connectors resolve both from the CIDR database at /api/geoip/hosting.dat.gz (consumer // ISPs and Apple Private Relay are already taken out of it) plus the network list at // /api/geoip/hosting-networks.json. // Resolve it from the VISITOR's IP, never from REMOTE_ADDR when you sit behind Cloudflare // or any reverse proxy - that address is the proxy's own datacenter IP, so a naive lookup // marks 100% of your traffic as bots. Leaving both null is always safe. 'is_hosting' => null, 'hosting_asn' => null, ], ], // --- Dimensions/lookups (only need to send occasionally, when they change) --- 'dimensions' => [ 'shop_meta' => [ 'name' => 'Muj e-shop', 'timezone' => 'Europe/Prague', 'default_currency' => 'CZK', 'default_lang' => 'cs', 'domain' => 'muj-eshop.cz', ], // state ID => { name + flags }. 'logable' = the state counts as a completed/valid order for // reporting (like PrestaShop). States named like "Storno"/"Vraceno" (cancelled/returned) are // also auto-excluded from revenue (all of this can be overridden in the admin). 'order_states' => [ 1 => ['name' => 'Ceka na platbu', 'logable' => false, 'paid' => false, 'shipped' => false, 'delivered' => false], 2 => ['name' => 'Zaplaceno', 'logable' => true, 'paid' => true, 'shipped' => false, 'delivered' => false], 3 => ['name' => 'Odeslano', 'logable' => true, 'paid' => true, 'shipped' => true, 'delivered' => false], 4 => ['name' => 'Storno', 'logable' => false, 'paid' => false, 'shipped' => false, 'delivered' => false], ], 'carriers' => [1 => 'Osobni odber', 2 => 'PPL', 3 => 'Zasilkovna'], 'categories' => [12 => 'Tricka', 13 => 'Mikiny'], 'currencies' => [ 1 => ['iso' => 'CZK', 'conversion_rate' => '1', 'sign' => 'Kc'], ], 'languages' => [1 => 'cs', 2 => 'en'], 'payment_modules' => [ ['name' => 'card', 'display_name' => 'Platebni karta'], ['name' => 'cod', 'display_name' => 'Dobirka'], ], ], ]; // ===== 3) SEND (no need to change this part) ======================= // Dry run (default) only signs and prints the body; with --send it sends for real. echo $LIVE ? trackless_send($endpoint, $apiKey, $payload) : trackless_preview($endpoint, $apiKey, $payload); // ===== 5) EXCLUDED IPs: before your NEXT send, filter out records on your side ===== // trackless_send() pulled the account's excluded-IP list from the response; store it // (e.g. in your own table) and in the NEXT batch skip anything that matches an entry. // Here we just demonstrate it on a single IP: $excludedIps = trackless_excluded_ips() ?: ['203.0.113.4', '10.0.0.0/8']; // fallback for the demo $klientskaIp = '10.1.2.3'; // the record's real IP (only you know it) if (trackless_ip_excluded($klientskaIp, $excludedIps)) { echo "IP {$klientskaIp} is excluded (your own traffic) - not sending the record.\n"; } else { echo "IP {$klientskaIp} is not excluded - it will go in the next batch.\n"; } /** * Signs the body with HMAC-SHA256 (the key is api_key) and posts it to the ingest endpoint. * Returns a text result message. Read the account's excluded-IP list from the response (on 200 * the body is JSON {"ok":true,"excluded_ips":[...],"geoip":{...}}) via trackless_excluded_ips(). */ function trackless_send($endpoint, $apiKey, array $payload) { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $signature = hash_hmac('sha256', $body, $apiKey); // signature of the EXACT body $accountId = substr(hash('sha256', $apiKey), 0, 24); // non-secret routing id $ch = curl_init($endpoint); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 30); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'X-Shaim-Signature: ' . $signature, 'X-Shaim-Account: ' . $accountId, 'X-Shaim-Module: muj-eshop', 'X-Shaim-Version: 1.0', ]); $response = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); // ===== 4) RESULT ===== if ($status === 200) { // The body is JSON {"ok":true,"excluded_ips":[...],"geoip":{...}} - we extract and remember // the excluded-IP list (read it later via trackless_excluded_ips()). $data = json_decode((string) $response, true); $excludedIps = (is_array($data) && isset($data['excluded_ips']) && is_array($data['excluded_ips'])) ? $data['excluded_ips'] : []; trackless_excluded_ips($excludedIps); return "OK - data stored successfully. Excluded IPs in the response: " . count($excludedIps) . ".\n"; } if ($status === 0) { return "Could not connect to the server: " . $curlErr . "\n"; } return "Server responded with an error (HTTP " . $status . "): " . $response . "\n" . "Hint: 400 = malformed body/signature, 403 = wrong api_key or signature,\n" . "409 = the key already belongs to another e-shop (fix the domain/key, do NOT retry),\n" . "413 = body too large (split the batch). 429/5xx try later.\n"; } /** * Dry run: computes the signature and prints WHAT would be sent (endpoint, headers * incl. the signature and the exact body) - but sends nothing. For real, run with --send. */ function trackless_preview($endpoint, $apiKey, array $payload) { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); // the EXACT bytes that would be sent $signature = hash_hmac('sha256', $body, $apiKey); // signature over these bytes $accountId = substr(hash('sha256', $apiKey), 0, 24); return "DRY RUN - nothing was sent (for real, run with --send).\n\n" . "POST " . $endpoint . "\n" . "Content-Type: application/json\n" . "X-Shaim-Signature: " . $signature . "\n" . "X-Shaim-Account: " . $accountId . "\n\n" . $body . "\n"; } /** * Holds the excluded-IP list from the last successful response. Calling it with an array stores it * (trackless_send does that), calling it without an argument returns it. */ function trackless_excluded_ips(?array $set = null) { static $ips = []; if ($set !== null) { $ips = $set; } return $ips; } /** * Returns true when the IP matches an entry in the list - either by an exact match * or by falling into a CIDR range (e.g. "10.0.0.0/8"). Works for IPv4 and IPv6. */ function trackless_ip_excluded($ip, array $excludedIps) { $bin = @inet_pton($ip); if ($bin === false) { return false; } foreach ($excludedIps as $entry) { if (strpos($entry, '/') === false) { if (@inet_pton($entry) === $bin) { // exact IP match return true; } continue; } list($subnet, $bits) = explode('/', $entry, 2); $subnetBin = @inet_pton($subnet); $bits = (int) $bits; // Compare only when both IPs are the same family (same length in bytes). if ($subnetBin === false || strlen($subnetBin) !== strlen($bin)) { continue; } $bytes = intdiv($bits, 8); $rem = $bits % 8; if ($bytes > 0 && strncmp($bin, $subnetBin, $bytes) !== 0) { continue; // already differs in whole bytes } if ($rem === 0) { return true; // prefix sits on a byte boundary } $mask = chr(0xFF << (8 - $rem) & 0xFF); // last partial byte of the prefix if ((ord($bin[$bytes]) & ord($mask)) === (ord($subnetBin[$bytes]) & ord($mask))) { return true; } } return false; }