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;
3. The PHP SDK's Cookie Fallback Trap
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_saltwith 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,wbraidand TikTokttclid:_gclid,_gbraid,_wbraid,_ttclid, 90 days (PHP SDK 1.10.0 or later). - Reddit
rdt_cidand OpenAI Adsoppref:_rdt_cid,_oppref, 28 days (PHP SDK 1.10.0 or later). - Microsoft Ads
msclkidand Xtwclid:_axi_msclkid,_axi_twclid, 90 days; Pinterestepik:_axi_epik, 60 days; LinkedInli_fat_id:_axi_li_fat_id, 30 days; Snapchatsccid:_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 itsScCidURL 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
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_sidwhile 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(), andsetClientUserAgent()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_atwith the time the order was placed, andevent_saltwith 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
sendBeaconboth lose this race.