How to send AxiTrace events correctly from PHP server-side code - queues, crons, webhook handlers - including visitor identity, the PHP SDK's cookie fallback trap, and why some events can never be sent from the browser.

Sending Events from a Server, Queue, or Cron

Server-side tracking is not optional for some events, and it behaves differently from the browser in ways that break silently if you don't account for them. This page covers what belongs where, how to carry visitor identity across the client/server boundary, the single most common PHP SDK footgun, and why some events structurally cannot be sent from the browser at all.

1. Client-Side vs. Server-Side: Which Events Go Where

Event type Send from Why
page.view, product.view, product.addToCart, search, view_item_list, select_item, remove_from_cart Browser (JavaScript SDK) Behavioral/browsing events - they need real browser context (referrer, viewport, pixel cookies) and happen while the visitor is on the page.
begin_checkout, add_shipping_info, add_payment_info Server preferred, browser works Your backend knows the real basket total and the real shipping tier, and its Secret Key never reaches a browser. The browser path exists for shops with no backend to send from - a Google Tag Manager install with a GA4 ecommerce dataLayer gets these for free. Pick one side per event; both sides counts each checkout twice.
transaction.charge (purchases) Server, always See the Transaction event reference - accuracy (only confirmed/paid orders), security (no Secret Key in the browser), fraud prevention, and reliability (not affected by ad blockers) all require this.
Webhook-driven events (payment provider callbacks, order-status changes from an ERP, subscription renewals from a billing system) Server, structurally required There is no browser involved in the request at all - see the navigation trap section below for why you cannot work around this by proxying through the browser.

2. Passing Visitor Identity to the Server

The JavaScript SDK sets two cookies you need to carry the browsing session's identity into a server-side event so it links up with the same profile instead of creating an orphaned one:

Cookie Maps to Purpose
vt_vid client.customId Persistent anonymous visitor ID - links the purchase to the browsing history that led to it.
vt_sid sessionId (top-level field) Current session ID - links the event to the specific session for session-based analytics.
<?php
// Called from a normal HTTP request (e.g. an order-confirmation controller) -
// $_COOKIE is populated here because the visitor's browser sent the cookies.
$visitorId = $_COOKIE['vt_vid'] ?? null;
$sessionId = $_COOKIE['vt_sid'] ?? null;
This Fails Silently in Queues and Cron Jobs

The axitrace/php-sdk package auto-detects identity and client context from PHP superglobals ($_COOKIE, $_SERVER) so that a normal web request "just works" without extra wiring - see AxiTrace::autoDetectClientData(), getVisitorId(), and getSessionId() in the SDK source. That's convenient inside an HTTP request, but $_COOKIE and most of $_SERVER are empty in a CLI process - a queue worker, a cron job, an async job runner. There is no HTTP request there, so there is nothing to read from.

The SDK does not error out when this happens. It just sends the event with no client identity, no IP, and no user agent - a transaction that silently loses its link to the browsing session that produced it, with no exception to alert you.

The fix: capture the cookies and request context while you still have an HTTP request (e.g. when the order is placed, or when the job is enqueued), then pass them explicitly into the SDK when the queue worker actually sends the event - never rely on auto-detection outside an HTTP request.

<?php
// STEP 1 - inside the HTTP request that places the order (cookies ARE available here):
// capture everything you'll need before handing off to the queue.
use AxiTrace\AxiTrace;

// getAttributionParams() returns what the SDK read from THIS request: the ad click
// ids in the URL or, when the URL has none, in the cookies the AxiTrace JavaScript
// SDK persists (_gclid, _gbraid, _wbraid, _ttclid, _rdt_cid, _oppref, _axi_msclkid,
// _axi_twclid, _axi_epik, _axi_li_fat_id, _axi_sccid), then in the platforms' own
// click-id cookies (_uetmsclkid, _twclid, _epik, li_fat_id), plus the _fbp, _fbc,
// _ttp, _ga, _rdt_uuid and __obref cookies and the UTM parameters.
$attribution = AxiTrace::init('sk_live_YOUR_SECRET_KEY')->getAttributionParams();

$jobPayload = [
    'orderId'      => $order->getId(),
    'placedAt'     => (new DateTimeImmutable())->format(DATE_ATOM),
    'visitorId'    => $_COOKIE['vt_vid'] ?? null,
    'sessionId'    => $_COOKIE['vt_sid'] ?? null,
    'clientIp'     => $_SERVER['REMOTE_ADDR'] ?? null,
    'userAgent'    => $_SERVER['HTTP_USER_AGENT'] ?? null,
    'attribution'  => $attribution, // a plain array of strings, safe to serialize
];
$queue->push('send-axitrace-transaction', $jobPayload);

// STEP 2 - inside the queue worker (a CLI process - $_COOKIE and $_SERVER are EMPTY here):
function handleSendAxiTraceTransaction(array $job): void
{
    $axitrace = AxiTrace::init('sk_live_YOUR_SECRET_KEY');

    // CRITICAL: these must be set explicitly. Auto-detection finds nothing in
    // a CLI process, and the SDK will send the event anyway - just without
    // identity, IP, or user agent, and with no error to tell you it happened.
    if ($job['visitorId']) {
        $axitrace->setClientId($job['visitorId']);
    }
    if ($job['sessionId']) {
        $axitrace->setSessionId($job['sessionId']);
    }
    if ($job['clientIp']) {
        $axitrace->setClientIp($job['clientIp']);
    }
    if ($job['userAgent']) {
        $axitrace->setClientUserAgent($job['userAgent']);
    }
    // Restores the click ids and browser ids captured in STEP 1. They are added to
    // every event this client sends; a key you pass in the event params still wins.
    $axitrace->setAttributionParams($job['attribution']);

    $order = getOrder($job['orderId']);

    $products = array_map(fn($item) => [
        'sku' => $item->getSku(),
        'name' => $item->getName(),
        'finalUnitPrice' => [
            'amount' => $item->getFinalPrice(),
            'currency' => $order->getCurrency(),
        ],
        'quantity' => $item->getQuantity(),
    ], $order->getItems());

    $axitrace->transaction(
        $order->getId(),
        $order->getTotal(),
        $order->getSubtotal(),
        $order->getCurrency(),
        strtoupper($order->getPaymentMethod()),
        $products,
        [
            'email' => $order->getCustomer()->getEmail(),
            // Your order id: a retried job must not count the purchase twice.
            'event_salt' => $order->getId(),
            // When the order was placed, not when this job runs - see below.
            'recorded_at' => $job['placedAt'],
        ]
    );
}

Date the Order When It Was Placed, Not When It Is Sent

A transaction that reaches AxiTrace late - a queue that was backed up, a cron job that re-sends failed deliveries, a backfill - is dated at the moment it arrives unless you say otherwise. A purchase re-sent three days later then shows up on the wrong day in your reports and reaches your ad platforms as a fresh conversion. Pass the time the order was actually placed as recorded_at (PHP SDK 1.8.0 or later; a DateTimeInterface or an ISO 8601 string), or call setRecordedAt() on a TransactionEvent. Over HTTP it is the recordedAt field of POST /v1/transaction.

  • The time is converted to UTC. A time in the future is replaced by the server's clock.
  • Meta accepts conversions up to 7 days old. An older order is still recorded in AxiTrace and forwarded to platforms that accept older conversions (Google Ads accepts 90 days), but it is not sent to Meta.
  • Always send event_salt with your order id as well. A re-sent order that already arrived in the last 30 days is then recognised and ignored instead of counted twice.

The four methods that matter here - setClientId(), setSessionId(), setClientIp(), and setClientUserAgent() - all exist specifically to override auto-detection. Call them whenever the code sending the event is not itself handling the original HTTP request: queue workers, cron jobs, webhook handlers that fire asynchronously, and CLI scripts all qualify.

Do the same for the ad click ids. Keeping only _fbp and _fbc is enough for Meta, but every other platform needs its own click id to attribute the conversion to the ad click. The buyer rarely still has it in the URL when the order is placed, which is why the JavaScript SDK keeps each one in a first-party cookie for as long as the platform attributes a click, and the PHP SDK reads those cookies when the URL has no click id:

  • Google Ads gclid, gbraid, wbraid and TikTok ttclid: _gclid, _gbraid, _wbraid, _ttclid, 90 days (PHP SDK 1.10.0 or later).
  • Reddit rdt_cid and OpenAI Ads oppref: _rdt_cid, _oppref, 28 days (PHP SDK 1.10.0 or later).
  • Microsoft Ads msclkid and X twclid: _axi_msclkid, _axi_twclid, 90 days; Pinterest epik: _axi_epik, 60 days; LinkedIn li_fat_id: _axi_li_fat_id, 30 days; Snapchat sccid: _axi_sccid, 28 days (JavaScript SDK 0.24.0 and PHP SDK 1.11.0 or later). When that cookie is missing, the platform's own cookie is read instead: _uetmsclkid, _twclid, _epik, li_fat_id. Snapchat's click id is read from its ScCid URL parameter as well.

Capture them with getAttributionParams() in the HTTP request and hand them back with setAttributionParams() in the worker, as above. With an older SDK, upgrade first: composer require axitrace/php-sdk:^1.11.

4. The Navigation Trap: Why a Redirecting Form Loses Events

A Form Submit + Redirect Kills In-Flight Browser Requests

If you fire a tracking call (e.g. form.submit) from JavaScript right before a <form> submits and the browser navigates to a new page (or the server responds with a redirect), the browser is free to abort the in-flight tracking request the instant navigation starts. Whether it completes is a race you don't control - on a slow connection or a fast redirect, it frequently loses.

The standard browser fix for "fire a request that survives navigation" is navigator.sendBeacon() - but it does not work for AxiTrace, and this isn't a workaround-able limitation:

sendBeacon Cannot Set Custom Headers

The Beacon API can only send a body - it does not allow setting custom request headers. AxiTrace client-side authentication requires the workspace public key in a header (X-VeliTrack-Public-Key or X-Workspace-Key); there is no supported way to pass it as part of the beacon body instead. A sendBeacon call therefore cannot authenticate to the ingestion API at all, regardless of how the payload is constructed.

The fix: for any event tied to a form submission that navigates or redirects (a newsletter signup form, a lead-capture form, a checkout step that posts and redirects), don't rely on a client-side tracking call at all. Track it server-side, from the handler that processes the form submission - the same code that validates the input and persists it has the data it needs to call AxiTrace directly, with no race against navigation.

<?php
// A newsletter/lead-capture form handler - runs server-side BEFORE the
// redirect, so there is no navigation race and no sendBeacon involved.
use AxiTrace\AxiTrace;

function handleNewsletterSignup(): void
{
    $email = $_POST['email'] ?? null;
    if (!$email) {
        // ... validation error handling ...
        return;
    }

    saveSubscriber($email);

    $axitrace = AxiTrace::init('sk_live_YOUR_SECRET_KEY');

    // Cookies ARE available here - this handler runs inside the original
    // HTTP request, before any redirect.
    $visitorId = $_COOKIE['vt_vid'] ?? null;
    if ($visitorId) {
        $axitrace->setClientId($visitorId);
    }

    $axitrace->subscribe($email, [
        'subscription_type' => 'newsletter',
    ]);

    // Only now redirect the user to a thank-you page - the event was already
    // sent and acknowledged before navigation starts.
    header('Location: /newsletter/thank-you');
    exit;
}

Summary

  • Behavioral browsing events stay client-side. Transactions and anything driven by a webhook are server-side, always.
  • Always read vt_vid/vt_sid while you still have an HTTP request, and pass them explicitly - don't assume the SDK will find them later.
  • In any CLI context (queue worker, cron, async job), call setClientId(), setSessionId(), setClientIp(), and setClientUserAgent() explicitly. Auto-detection finds nothing there and fails silently, not loudly.
  • When a transaction is sent later than the order was placed (queue, cron re-send, backfill), pass recorded_at with the time the order was placed, and event_salt with your order id.
  • If a tracked action is followed by a redirect (form submit, checkout step), send the event server-side before the redirect - client-side tracking calls and sendBeacon both lose this race.