Overview
This guide walks through reporting completed orders to ExpertVoice from your Google Tag Manager Server-Side (sGTM) container.
You'll create a custom Tag that fires when an order completes and sends a POST request to ExpertVoice's pixel gateway with your order, product, and discount-code details. This lets ExpertVoice track conversions from ExpertVoice campaigns without requiring a client-side pixel on your storefront.
What You'll Need
- A GTM Server-Side container already running, with a server endpoint your client-side (web) container is sending events to.
-
Your ExpertVoice Reporting ID (format
exp-123-12345, formerly called the Pixel ID). You can look this up for your brand at advocacy.expertvoice.com/app/configure#pixel. -
Confirmation of your "purchase" event's data shape. This template assumes your checkout sends the standard GA4 e-commerce
purchaseevent schema:-
transaction_id,value,tax,shipping,currency,coupon items: [{ item_id, item_name, price, quantity }, ...]
If your checkout instead pushes a custom dataLayer event with different field names, the
getEventData()key paths in the template code will need to be adjusted to match your actual schema. Check your container's Preview mode to confirm your event's real field names before wiring this up. -
- A decision on whether you want discount-code prefix filtering (optional — see Configuration Notes below).
How It Works
GTM Server-Side lets you build a custom Tag Template — a small sandboxed JavaScript program that runs when a tag fires. Unlike a browser, this sandbox doesn't have fetch(), JSON, or other native globals available by default; everything is imported via require(). This template uses:
| Sandboxed API | Purpose |
|---|---|
getEventData(key) |
Reads a field from the incoming event (e.g. transaction_id, items) |
sendHttpRequest(url, options, body) |
Makes the actual POST request to ExpertVoice |
JSON |
JSON.stringify() the payload — must be explicitly required |
logToConsole |
Debug logging, visible in GTM Preview mode |
data.gtmOnSuccess() / data.gtmOnFailure()
|
Required — tells GTM whether the tag completed successfully |
Setup Steps
1. Configure the template
Open the code template we've provided and fill in two values at the top:
-
REPORTING_ID— your ExpertVoice Reporting ID -
PREFIXES— optional; see Configuration Notes below
2. Create the custom Tag Template
In your GTM Server-Side container:
Templates → New (under "Tag Templates")
This opens the Template Editor with four tabs: Info, Fields, Code, Permissions.
-
Info tab: Name it
ExpertVoice Order Reporter. - Fields tab: Leave empty. All configuration is hardcoded directly in the code (your Reporting ID and prefixes), so the template stays fully self-contained.
- Code tab: Paste in the template code.
-
Permissions tab: GTM auto-detects which permissions the code needs and prompts for them the first time you click Save. Accept the prompts. You should see:
-
send_http— scoped toexpertvoice.com read_event_data-
logToConsole(safe to leave enabled; used for debugging only)
-
3. Create the Tag
Tags → New → select the ExpertVoice Order Reporter template as the tag type.
Trigger: whichever trigger fires on a completed purchase/order event in your server container (commonly a GA4 Client event trigger, or a custom event trigger — this depends on your own container setup).
4. Test
Use GTM Server-Side's Preview mode:
- Complete a test order on your site with Preview active.
- Confirm the ExpertVoice tag shows a green checkmark (fired successfully) in Preview.
- Check the logged output for whether
items,coupon, andtransaction_idresolved to real values rather than blank/undefined. - Once you've confirmed a successful test, let your ExpertVoice contact know so we can verify the order was received correctly on our end.
Configuration Notes
Reporting ID
Format: exp-123-12345 (i.e. exp-{pixelId}-{orgId}). The template splits this automatically — no need to separate the two values manually.
Discount code prefix filtering (optional)
PREFIXES is an optional array of coupon-code prefixes (e.g. ['EV-', 'EXP-']).
- If left empty, every order is sent to ExpertVoice. This is safe — ExpertVoice only stores/counts orders whose code matches one it actually issued, so sending everything just means some extra (ignored) traffic rather than lost orders.
- If populated, only orders whose coupon code starts with one of the listed prefixes (case-insensitive) are sent — useful if you run many non-ExpertVoice promotions through the same checkout flow and want to reduce unnecessary traffic.
Template Code
The full template is available as a download attached to this article. It's also included below for reference — but download the attachment to paste into GTM rather than copying from this page, to avoid formatting issues (smart quotes, line-wrapping, etc.) that can happen when copying code out of a web page.
const getEventData = require('getEventData');
const sendHttpRequest = require('sendHttpRequest');
const JSON = require('JSON');
const logToConsole = require('logToConsole');
const makeString = require('makeString');
// ─── Configuration ────────────────────────────────────────────────────────────
// Enter your ExpertVoice Reporting ID
// (format: exp-123-12345 — look it up at
// https://advocacy.expertvoice.com/app/configure#pixel)
const REPORTING_ID = '';
// OPTIONAL. Comma-separated list of coupon code prefixes (case-insensitive)
// used to filter which orders get sent. Leaving this as an empty array
// sends ALL orders to ExpertVoice — that's safe to do either way, since
// ExpertVoice only stores/counts orders whose code matches one it actually
// issued. Filtering here just avoids sending noise for orders that would
// be discarded on our end anyway. Example: const PREFIXES = ['EV-', 'EXP-'];
const PREFIXES = [];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function hasMatchingPrefix(code, prefixes) {
if (prefixes.length === 0) return true;
if (!code) return false;
const lowerCode = code.toLowerCase();
for (let i = 0; i < prefixes.length; i++) {
if (lowerCode.indexOf(prefixes[i].toLowerCase()) === 0) return true;
}
return false;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
const idParts = REPORTING_ID.split('-');
const PIXEL_ID = idParts[1];
const ORG_ID = idParts[2];
const couponCode = getEventData('coupon') || '';
if (!hasMatchingPrefix(couponCode, PREFIXES)) {
logToConsole('ExpertVoice: no matching prefix, order not reported.');
data.gtmOnSuccess();
} else {
const items = getEventData('items') || [];
const products = items.map(function(item) {
return {
id: makeString(item.item_id || ''),
name: item.item_name || '',
sku: makeString(item.item_id || ''),
upc: '',
price: makeString(item.price || ''),
quantity: item.quantity || 1
};
});
const tax = getEventData('tax') || 0;
const shipping = getEventData('shipping') || 0;
// Some GA4 implementations report "value" as the grand total, already
// including shipping (rather than a pre-shipping subtotal). Back shipping
// out here so "subtotal" reflects the post-discount, pre-tax/shipping
// amount actually charged. If your dataLayer's "value" already excludes
// shipping, remove the "- shipping" below.
const subtotal = (getEventData('value') || 0) - shipping;
// msrp_subtotal (pre-discount) is derived by summing the line items
// directly, since GA4's standard purchase event doesn't include a
// separate pre-discount subtotal field. discount is then whatever
// difference exists between that sum and the actual post-discount
// "subtotal" above. Clamped at 0 so rounding noise or an items/value
// mismatch never produces a negative discount.
const itemsTotal = items.reduce(function(sum, item) {
return sum + (Number(item.price) || 0) * (item.quantity || 1);
}, 0);
const discount = Math.max(0, itemsTotal - subtotal);
const payload = {
action: 'REDIRECT-ORDERPLACED',
appName: 'external-redirect',
data: {
custom: {},
eventDate: makeString(Date.now()),
order: {
code: [couponCode],
currency: getEventData('currency') || 'USD',
discount: makeString(discount.toFixed(2)),
id: makeString(getEventData('transaction_id') || ''),
msrp_subtotal: makeString(itemsTotal.toFixed(2)),
shipping_address: {
city: '',
country: '',
postal_code: '',
state: ''
},
subtotal: makeString(subtotal.toFixed(2)),
total: makeString((subtotal + tax + shipping).toFixed(2)),
tax: makeString(tax.toFixed(2)),
shipping: makeString(shipping.toFixed(2))
},
orgId: ORG_ID,
pageCategory: 'order-app-gtm',
pixelId: PIXEL_ID,
products: products,
version: '3.0'
},
mfgId: ORG_ID,
referrer: null,
url: 'gtm-server-side',
userAgent: 'gtm-server-side',
version: 1
};
sendHttpRequest(
'https://www.expertvoice.com/pixel-gateway/ext/1.0/gateway',
{
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=UTF-8',
'Accept': 'application/json',
'x-codingscape-dev': '1'
},
timeout: 5000
},
JSON.stringify(payload)
).then((result) => {
if (result.statusCode >= 200 && result.statusCode < 300) {
data.gtmOnSuccess();
} else {
logToConsole('ExpertVoice: non-2xx response', result.statusCode);
data.gtmOnFailure();
}
}).catch(() => {
logToConsole('ExpertVoice: request failed or timed out');
data.gtmOnFailure();
});
}
Known field limitations
| Field | Why it's limited |
|---|---|
shipping_address |
Always blank — GA4's standard purchase event doesn't carry a shipping address. Only fillable if your dataLayer includes one under a custom key. |
discount |
Derived, not read directly — GA4's standard schema has no distinct discount field, so it's calculated as (sum of line items) minus the post-shipping-adjusted value. This is an approximation: it captures any gap between your items array and your value field, not strictly promo codes. If your dataLayer has a real discount field, wire it in directly instead. |
sku |
Uses item_id — GA4's schema has no distinct SKU field by default. |
If your dataLayer includes richer data under custom keys (a real discount amount, shipping address, distinct SKU), the template's getEventData() calls can be extended to pull those in — just let your ExpertVoice contact know your event schema and we can help adjust the template.
Troubleshooting
Tag shows a red X in Preview / gtmOnFailure() fired
Check the Preview console log (via logToConsole output) for the logged status code or failure reason. Most commonly this means either:
- The
send_httppermission wasn't granted forexpertvoice.com, or - The request timed out (default timeout is 5000ms in the template)
Products array is empty or fields are blank
This almost always means the incoming event doesn't actually match the assumed GA4 schema — confirm your real event data shape via Preview mode's event data inspector before assuming the template's getEventData() calls are correct.
Order appears to send successfully but you're not sure it's being received correctly
Check whether PREFIXES is configured and whether the order's coupon code actually matches one of the listed prefixes — if it doesn't match, the tag intentionally skips sending. Reach out to your ExpertVoice contact and we can confirm receipt on our end.
Need Help?
If you run into any issues setting this up, or your checkout event doesn't match the expected schema described above, contact your ExpertVoice partner representative — we're happy to help adjust the template for your specific setup.