Skip to main content

Purchase tracking

When a purchase happens, this integration creates or updates the contact, records a product event for every item bought, and creates a deal (order) on the correct pipeline and stage.

Before you start, complete Container setup.

What you will build​

The result in Positive User: a contact with the purchase on their timeline, one product event per purchased product (feeding recommendations and segmentation), and a deal representing the order with its value, currency, and product breakdown.

Step 1: configure the pipeline in Positive User​

Deals (orders) live on a pipeline and a stage, both referenced by numeric ID.

  1. Go to Workspace Settings → Sales → Pipelines.
  2. Create a pipeline for your orders (for example "Orders").
  3. Add the stages that match your order lifecycle, such as "New order", "Fulfilled", "Cancelled/Returned".
  4. Note the numeric pipeline ID and stage IDs. You will hardcode these in the tag.

If you route orders to different pipelines by region or store, note the pipeline and stage IDs for each. You will map them in the tag code.

Optionally, define custom deal attributes (Workspace Settings → Sales → Deal attributes) for extra data you want on the deal, such as payment_method or discount_code. Any custom attribute sent to the API that does not yet exist will be created automatically.

Step 2: add the purchase parameters to the client-side tag​

Create the GA4 Event tag as described in Container setup → Configure the client-side GA4 tag, with the event name set to purchase and Send Ecommerce Data enabled. On top of the base parameters, add:

ParameterValue (example)Purpose
currencythe order currencyDeal and line-item currency
valuethe order totalDeal value
country_prefixregion/store code (e.g. PL)Selects the deal pipeline and prefixes product IDs
itemsthe items arrayOne product event plus one line item per entry

Trigger the tag on your purchase data layer event.

Step 3: create the custom tag template​

In the server-side container, go to Templates → Tag Templates → New. Add the fields and permissions from Container setup → Tag template conventions.

Code tab​

Paste the following. It reads the purchase data, creates or updates the contact, sends a product event per item, and creates the deal. Replace the pipeline/stage mapping with your real numeric IDs from Step 1.

const sendHttpRequest = require('sendHttpRequest');
const getAllEventData = require('getAllEventData');
const JSON = require('JSON');
const getTimestampMillis = require('getTimestampMillis');
const Math = require('Math');
const logToConsole = require('logToConsole');

const eventData = getAllEventData();
logToConsole('=== Purchase to Positive User START ===');

// --- pipeline + stage mapping by country_prefix ---
// REPLACE with the real numeric IDs from your workspace (Workspace Settings > Sales > Pipelines)
const DEAL_ROUTING = {
'PL': { pipeline: 1, stage: 1 },
'EU': { pipeline: 2, stage: 7 },
'DE': { pipeline: 3, stage: 8 },
'US': { pipeline: 4, stage: 9 },
'GBP': { pipeline: 6, stage: 11 }
// add more prefixes as needed
};
// used when the prefix has no entry above
const DEAL_ROUTING_DEFAULT = { pipeline: 1, stage: 1 };

// --- contact identifier (email) ---
const userData = eventData.user_data || {};
const userCustomId = userData.email || eventData.user_id || eventData.email;
if (!userCustomId) {
logToConsole('FAIL: no contact identifier');
data.gtmOnFailure();
return;
}

// --- purchase data (read defensively) ---
const ecommerce = eventData.ecommerce || {};
const items = ecommerce.items || eventData.items || [];
const currency = ecommerce.currency || eventData.currency;
const value = ecommerce.value || eventData.value;
const transactionId = ecommerce.transaction_id || eventData.transaction_id;
const prefix = eventData.country_prefix || '';

if (items.length === 0) {
logToConsole('FAIL: no items');
data.gtmOnFailure();
return;
}

// --- resolve deal routing from prefix ---
const routing = DEAL_ROUTING[prefix] || DEAL_ROUTING_DEFAULT;
if (!DEAL_ROUTING[prefix]) {
logToConsole('WARN: unknown prefix "' + prefix + '" -> fallback pipeline', routing.pipeline);
}
logToConsole('prefix:', prefix, 'pipeline:', routing.pipeline, 'stage:', routing.stage);

const subdomain = data.subdomain;
const apiKey = data.apiKey;
const base = 'https://' + subdomain + '.user.com/api/public';
const headers = {
'Authorization': 'Token ' + apiKey,
'Accept': '*/*; version=2',
'Content-Type': 'application/json'
};

// --- Step 1: create/update contact ---
const userBody = JSON.stringify({
custom_id: userCustomId,
email: userCustomId,
tags: ['purchase server-side']
});

logToConsole('POST update_or_create');

sendHttpRequest(base + '/users/update_or_create/', (sc, h, body) => {
logToConsole('contact status:', sc);
if (sc < 200 || sc >= 300) { data.gtmOnFailure(); return; }

const parsed = JSON.parse(body);
const internalUserId = parsed.id;
if (!internalUserId) { logToConsole('FAIL: no contact id in response'); data.gtmOnFailure(); return; }

// product events (N) + 1 deal
let pending = items.length + 1;
let anyError = false;
const finish = () => {
pending = pending - 1;
if (pending <= 0) {
if (anyError) { data.gtmOnFailure(); } else { data.gtmOnSuccess(); }
}
};

// --- Step 2: product event per item (also collect deal line items) ---
const productCustomIds = [];
const orderProducts = [];

for (let i = 0; i < items.length; i++) {
const item = items[i];
const productCustomId = prefix + item.item_id;

productCustomIds.push(productCustomId);
orderProducts.push({
product_custom_id: productCustomId,
value: item.price,
currency: currency,
quantity: item.quantity
});

const peBody = JSON.stringify({
user_id: internalUserId,
event_type: 'purchase',
data: {}
});

const peUrl = base + '/products-by-id/' + productCustomId + '/product_event/';
logToConsole('POST product_event:', productCustomId);

sendHttpRequest(peUrl, (pes) => {
logToConsole('product_event status:', pes, productCustomId);
if (pes < 200 || pes >= 300) { anyError = true; }
finish();
}, { method: 'POST', headers: headers }, peBody);
}

// --- Step 3: create/update the deal (order) ---
const dealBody = JSON.stringify({
custom_id: transactionId,
user_custom_id: userCustomId,
user_id: internalUserId,
name: 'Order #' + transactionId,
pipeline: routing.pipeline,
stage: routing.stage,
status: 2, // 2 = won (completed purchase). Use 1 for "in progress".
value: value,
currency: currency,
products_custom_id: productCustomIds,
order_products: orderProducts
});

logToConsole('POST deal:', transactionId);

sendHttpRequest(base + '/deals/update_or_create/', (ds) => {
logToConsole('deal status:', ds);
if (ds < 200 || ds >= 300) { anyError = true; }
finish();
}, { method: 'POST', headers: headers }, dealBody);

}, { method: 'POST', headers: headers }, userBody);

Save the template.

For the request and response formats of each call, see the API reference: Update or create user, Create product event and Update or create deal.

Step 4: create the tag from the template​

  1. Go to Tags → New and choose your new template under the custom section.
  2. Fill in the fields: your API Key and Workspace Subdomain.
  3. For the trigger, create a custom event trigger where the event name equals purchase, and assign it to the tag.
  4. Save.

Step 5: test before publishing​

With both Preview modes running (see Container setup → Test before publishing), paste this into the browser console as a single block:

window.dataLayer = window.dataLayer || [];
dataLayer.push({ ecommerce: null });
dataLayer.push({
event: 'purchase',
email: 'test@example.com',
currency: 'PLN',
value: 1499,
ecommerce: {
currency: 'PLN',
transaction_id: 'TEST-1',
value: 1499,
items: [
{ item_id: '5900000000001', item_name: 'Test Product A', price: 1218.70, quantity: 1 },
{ item_id: '5900000000002', item_name: 'Test Product B', price: 280.30, quantity: 2 }
]
}
});

Two items let you confirm the loop sends a separate product event for each. In the server-side Preview, open the purchase event and check the Console tab. A correct run looks like this:

=== Purchase to Positive User START ===
prefix: PL pipeline: 1 stage: 1
POST update_or_create
contact status: 200
POST product_event: PL5900000000001
POST product_event: PL5900000000002
product_event status: 201 PL5900000000001
product_event status: 201 PL5900000000002
POST deal: TEST-1
deal status: 200

Then open the contact (here test@example.com) in Positive User and verify the product events on the timeline and the deal on the correct pipeline, with its value, currency, and product breakdown.

When everything checks out, publish the server-side container (and the client-side container if you changed it).

How the response flow works​

The tag chains requests so ordering is guaranteed. The contact upsert runs first because it returns the internal contact ID needed by the later calls. Only after a successful contact response do the product events and the deal fire. A pending counter (number of items plus 1 for the deal) ensures the tag reports success only when every request returned 2xx. Any non-2xx marks the whole tag as failed.

Expected status codes: contact upsert and deal return 200 OK; product events return 201 Created. The code accepts any 2xx, so all of these pass.

Things to watch out for​

Prefix keys must match exactly. The keys in DEAL_ROUTING must be byte-for-byte identical to whatever your client-side logic sends as country_prefix. If your lookup outputs GBP, the map key must be GBP, not GB. A mismatch sends that region's orders to the fallback pipeline silently. The WARN: unknown prefix log line exists to make this visible in Preview, so watch for it.

The fallback hides routing errors. DEAL_ROUTING_DEFAULT catches any unmapped prefix. If it points at a real pipeline, misrouted orders look normal. Consider pointing the fallback at a dedicated "unsorted" pipeline so anomalies surface immediately instead of blending into a valid region.

Product IDs must exist in Positive User. Product events and deal line items attach to products by custom_id. If a product is not in the workspace (not in the feed), its event attaches to nothing. Verify once in the app that a product's custom_id matches exactly what the tag sends, prefix included.

Enabling ecommerce data on the client tag is mandatory. Without Send Ecommerce Data (source: Data Layer) on the GA4 tag, the items array never reaches the server and the tag has nothing to send.

Identification depends on the identifier arriving. For guest checkouts, the email (or your chosen identifier) must be present in the data layer on the order confirmation page. The e-commerce platform must inject it from the order data. Without it, the tag fails fast and sends nothing.

Configuration summary​

Everything you customize per implementation, in one place:

WhatWhereExample
API keyTag field apiKey64-char token
Workspace subdomainTag field subdomainacme for acme.user.com
Server container URLClient-side GA4 taghttps://sst.yourdomain.com
Contact identifierClient-side user_id / emaillowercase email
Product ID schemeClient-side country_prefix plus feedPL plus product ID
Pipeline/stage per regionDEAL_ROUTING in tag code{ pipeline, stage }
Deal statusstatus in deal body2 (won)
Deal name formatname in deal bodyOrder #<id>