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
- The user searches in the widget and clicks a result.
- The widget writes a cookie in the browser (
neuroon_clicks) with aproductId → searchLogIdmap for every product the user clicked. - The user goes to your checkout and completes the order.
- Your server reads the cookie in the "order confirmed" handler, builds the
conversions[]array attaching the matchingsearchLogIdto each line item, and callsPOST /api/plugin/shops/{shopId}/track/conversion. - Neuroon validates the
searchLogIdsignature, identifies the attributable products, and adds theirlineTotalto revenue attributed to Neuroon.
Without searchLogId in the payload, Neuroon cannot attribute the conversion. The item is recorded as noise.
The neuroon_clicks cookie
| Field | Value |
|---|---|
| Name | neuroon_clicks |
| Path | / |
SameSite | Lax |
Secure | true (over HTTPS) |
HttpOnly | false (the server needs to read it on the next request) |
| Max size | 4096 bytes (truncated if exceeded) |
| Format | JSON: { "<productId>": "<searchLogId>", "<productId>": "<searchLogId>", … } |
| TTL | Session-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
productIddoes 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
/api/plugin/shops/{shopId}/track/conversionPOST /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:
| Field | Required | Description |
|---|---|---|
orderId | yes | Unique identifier of your order. Idempotency key — resending does not duplicate. |
orderValue | yes | Total of the order (all items). |
attributedValue | recommended | Sum of lineTotal for items that have searchLogId. If you omit it, Neuroon derives it from conversions[]. |
currency | yes | ISO 4217 (EUR, USD…). |
conversions[] | yes | List of items with attribution. Items without searchLogId should be omitted from the array. |
conversions[].productId | yes | Product externalId (same value you used in /products/sync). |
conversions[].searchLogId | yes | Value read from the cookie. Without it, attribution does not work. |
conversions[].quantity | yes | Units sold. |
conversions[].lineTotal | yes | Item 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:
| Block | Typical impact |
|---|---|
| Adblockers (uBlock, Ghostery, Brave Shields) | 25-40 % in EU markets |
| Safari ITP + iOS Private Relay | residual cookie impact |
| Strict CSPs (banking, government) | block third-party pixels |
| Offline service workers | don'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
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_rawfrom the DB immediately after reading. It's a temporary value with opaquesearchLogIds — 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
- Open your storefront in a fresh tab (not incognito).
- Search for a product in the widget and click a result.
- Open DevTools → Application → Cookies →
neuroon_clicks. You should see the JSON with yourproductId. - Complete a purchase of that product.
- In the Neuroon dashboard (under Conversions), the order should appear within seconds with
attributedValueequal to that item'slineTotal.
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
| Symptom | Cause | Fix |
|---|---|---|
400 with orderValue must be >= 0 | Negative orderValue (refunds) | Refunds use a different endpoint. |
Conversion logged but attributedValue: 0 | Order items had no match in the cookie (direct URL, other channel) | Correct behavior — not every order comes from search. |
| Conversion does not appear in dashboard | Origin mismatch or shopId from a different environment | Verify Origin and that the API Key belongs to the right environment. |
| Duplicate conversions | Resends without a stable idempotency key | Always use the same orderId per order (no timestamps). |
| Attribution drops to 0 since you added PayPal/3DS | SameSite=Lax loses the cookie on cross-origin redirect | Implement the pre-redirect persistence pattern above. |
Cookie productId does not match the order's | Sync uses SKU-001 but the order uses wc_post_42 | Keep one externalId per product: same in /products/sync and in the cookie. |
Next steps
plugins/wordpress/admin-dashboard— conversion visualization in the WP panel.- API ·
POST /track/conversion— endpoint reference. - Recipe · Custom cart bridge — cart events the widget consumes for personalization.