Skip to main content

Recipe: end-to-end conversion tracking

For Neuroon to attribute a sale to the search that drove it, you need to link two events: the user's click on a widget result, and the final order conversion. The widget handles the first half by itself. Your server has to close the loop.

How attribution works

  1. The user searches in the widget and clicks a result.
  2. The widget writes a cookie in the browser (neuroon_clicks) with a productId → searchLogId map for every product the user clicked.
  3. The user goes to your checkout and completes the order.
  4. Your server reads the cookie in the "order confirmed" handler, builds the conversions[] array attaching the matching searchLogId to each line item, and calls POST /api/plugin/shops/{shopId}/track/conversion.
  5. Neuroon validates the searchLogId signature, identifies the attributable products, and adds their lineTotal to revenue attributed to Neuroon.

Without searchLogId in the payload, Neuroon cannot attribute the conversion. The item is recorded as noise.

FieldValue
Nameneuroon_clicks
Path/
SameSiteLax
Securetrue (over HTTPS)
HttpOnlyfalse (the server needs to read it on the next request)
Max size4096 bytes (truncated if exceeded)
FormatJSON: { "<productId>": "<searchLogId>", "<productId>": "<searchLogId>", … }
TTLSession-bound; the widget refreshes entries on every click

JSON keys are the product externalId (same one you sent to /products/sync). Each searchLogId is an opaque string emitted by Neuroon in the search response.

If the order item's productId does not appear in the cookie, that item is not attributed — the user reached it through a different channel (direct URL, another search, internal navigation). That's correct behavior.

The endpoint

POST/api/plugin/shops/{shopId}/track/conversion
POST /api/plugin/shops/shop_xxxxxxxx/track/conversion HTTP/1.1
Host: api.neuroon.ai
X-Shop-API-Key: sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Origin: https://your-shop.example
Content-Type: application/json

{
"orderId": "ORD-2026-0001",
"orderValue": 199.95,
"attributedValue": 79.98,
"currency": "EUR",
"conversions": [
{ "productId": "SKU-001", "searchLogId": "slog_abc123", "quantity": 2, "lineTotal": 79.98 }
]
}

Payload fields:

FieldRequiredDescription
orderIdyesUnique identifier of your order. Idempotency key — resending does not duplicate.
orderValueyesTotal of the order (all items).
attributedValuerecommendedSum of lineTotal for items that have searchLogId. If you omit it, Neuroon derives it from conversions[].
currencyyesISO 4217 (EUR, USD…).
conversions[]yesList of items with attribution. Items without searchLogId should be omitted from the array.
conversions[].productIdyesProduct externalId (same value you used in /products/sync).
conversions[].searchLogIdyesValue read from the cookie. Without it, attribution does not work.
conversions[].quantityyesUnits sold.
conversions[].lineTotalyesItem subtotal (unitPrice × quantity).

Idempotency: Neuroon deduplicates by orderId. Resending the same conversion does not inflate metrics.

Why server-side

Relying on a JS pixel alone loses a meaningful share of conversions:

BlockTypical impact
Adblockers (uBlock, Ghostery, Brave Shields)25-40 % in EU markets
Safari ITP + iOS Private Relayresidual cookie impact
Strict CSPs (banking, government)block third-party pixels
Offline service workersdon't flush beacons before the tab closes

Calling the endpoint server-to-server from the order-confirm handler removes those four vectors. On top of that, the server can send the actual lineTotal after discounts, not the estimate the browser would have.

Implementations

track-conversion.js
import fetch from 'node-fetch';

const COOKIE_NAME = 'neuroon_clicks';

// Read the cookie from the incoming request (Express/Fastify/Next.js Route Handler).
function readClicksCookie(rawCookieHeader) {
if (!rawCookieHeader) return {};
const match = rawCookieHeader
  .split(';')
  .map(c => c.trim())
  .find(c => c.startsWith(COOKIE_NAME + '='));
if (!match) return {};
try {
  return JSON.parse(decodeURIComponent(match.split('=')[1]));
} catch {
  return {};
}
}

export async function trackConversion(order, rawCookieHeader) {
const clicks = readClicksCookie(rawCookieHeader);

const conversions = order.lines
  .map(l => {
    const searchLogId = clicks[String(l.sku)];
    if (!searchLogId) return null;            // no attribution → skip
    return {
      productId:   String(l.sku),
      searchLogId,
      quantity:    Number(l.quantity),
      lineTotal:   Number(l.unitPrice * l.quantity),
    };
  })
  .filter(Boolean);

const attributedValue = conversions
  .reduce((sum, c) => sum + c.lineTotal, 0);

const payload = {
  orderId:         String(order.id),
  orderValue:      Number(order.total),
  attributedValue,
  currency:        order.currency,
  conversions,
};

try {
  const r = await fetch(
    `${process.env.NEUROON_API_URL}/api/plugin/shops/${process.env.NEUROON_SHOP_ID}/track/conversion`,
    {
      method: 'POST',
      headers: {
        'X-Shop-API-Key': process.env.NEUROON_API_KEY,
        'Origin':         process.env.NEUROON_ORIGIN,
        'Content-Type':   'application/json',
      },
      body: JSON.stringify(payload),
    },
  );
  if (!r.ok) console.warn('Neuroon conversion tracking failed', r.status);
} catch (err) {
  console.warn('Neuroon conversion tracking error', err);
}
}

// Use from your order-confirmed handler
export async function onOrderConfirmed(req, order) {
await markOrderAsPaid(order.id);
trackConversion(order, req.headers.cookie); // fire-and-forget
}

Critical pattern: cross-origin payment gateways

If your checkout redirects to an external gateway (PayPal, Stripe 3DS, Klarna, Redsys…) and the user POSTs back to your domain, the neuroon_clicks cookie may be lost during the redirect because of SameSite=Lax. No cookie means no attribution.

Fix: persist the clicks in server-side storage before redirecting to the gateway, and read them from storage if the cookie does not survive. WooCommerce handles this automatically through order meta. For custom integrations:

// Before redirecting to the gateway
async function persistClicksBeforeGateway(req, orderId) {
const clicks = readClicksCookie(req.headers.cookie);
if (Object.keys(clicks).length > 0) {
await db.query(
'UPDATE orders SET neuroon_clicks_raw = $1 WHERE id = $2',
[JSON.stringify(clicks), orderId],
);
}
}

// On order-confirmed (after the gateway callback)
async function getClicksWithFallback(req, orderId) {
const fromCookie = readClicksCookie(req.headers.cookie);
if (Object.keys(fromCookie).length > 0) return fromCookie;

// Fallback: read what we persisted before the redirect
const row = await db.query(
'SELECT neuroon_clicks_raw FROM orders WHERE id = $1',
[orderId],
);
return row?.neuroon_clicks_raw ? JSON.parse(row.neuroon_clicks_raw) : {};
}

Delete neuroon_clicks_raw from the DB immediately after reading. It's a temporary value with opaque searchLogIds — it must not persist beyond the tracking flow.

Outbox for retries

If your integration cannot tolerate event loss, persist the payload in an outbox table and retry with a job:

CREATE TABLE neuroon_conversion_outbox (
order_id VARCHAR(64) PRIMARY KEY,
payload JSON NOT NULL,
attempts INT NOT NULL DEFAULT 0,
last_error VARCHAR(1024),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
synced_at TIMESTAMP NULL
);

A job that runs every N minutes with exponential backoff (typical 5 / 10 / 20 min):

async function flushOutbox() {
const rows = await db.query(`
SELECT * FROM neuroon_conversion_outbox
WHERE synced_at IS NULL AND attempts < 5
ORDER BY created_at LIMIT 100`);

for (const row of rows) {
try {
await postConversion(row.payload);
await db.query(`UPDATE neuroon_conversion_outbox SET synced_at = NOW() WHERE order_id = $1`, [row.order_id]);
} catch (err) {
await db.query(`
UPDATE neuroon_conversion_outbox
SET attempts = attempts + 1, last_error = $1
WHERE order_id = $2`, [err.message, row.order_id]);
}
}
}

Validate the attribution

  1. Open your storefront in a fresh tab (not incognito).
  2. Search for a product in the widget and click a result.
  3. Open DevTools → Application → Cookies → neuroon_clicks. You should see the JSON with your productId.
  4. Complete a purchase of that product.
  5. In the Neuroon dashboard (under Conversions), the order should appear within seconds with attributedValue equal to that item's lineTotal.

If the order shows up with attributedValue: 0, double-check that the order item's productId is the exact same string as the externalId you synced.

Common errors

SymptomCauseFix
400 with orderValue must be >= 0Negative orderValue (refunds)Refunds use a different endpoint.
Conversion logged but attributedValue: 0Order items had no match in the cookie (direct URL, other channel)Correct behavior — not every order comes from search.
Conversion does not appear in dashboardOrigin mismatch or shopId from a different environmentVerify Origin and that the API Key belongs to the right environment.
Duplicate conversionsResends without a stable idempotency keyAlways use the same orderId per order (no timestamps).
Attribution drops to 0 since you added PayPal/3DSSameSite=Lax loses the cookie on cross-origin redirectImplement the pre-redirect persistence pattern above.
Cookie productId does not match the order'sSync uses SKU-001 but the order uses wc_post_42Keep one externalId per product: same in /products/sync and in the cookie.

Next steps