___TERMS_OF_SERVICE___ By creating or modifying this file you agree to Google Tag Manager's Community Template Gallery Developer Terms of Service available at https://developers.google.com/tag-manager/gallery-tos (or such other URL as Google may provide), as modified from time to time. ___INFO___ { "type": "TAG", "id": "pixelfly_cod_protection", "version": 1, "securityGroups": [], "displayName": "PixelFly COD Protection", "brand": { "id": "brand_pixelfly", "displayName": "PixelFly", "thumbnail": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAFQUExURfluS/luS/luS/luS/luS/luS/luS/ltSvhtSvlzUfl0U/l0UvluTPl1VPzJvP3f1/3e1v3e1/3Rxvl8XfhtSfl3V/3j3f////7v6/mBY/hsSfmBYvl3VvmAYvhrSPl7W/l6WvmDZf3c1P708f7z8P7z8f3m4PqKbvl4WPl5Wfuum/7q5f7o4v7o4/3j3Pqbg/mEZ/qGafmFaPqSeP3c0/7n4f7m4Py8rPhsSPy6qv729PqLb/hqRvhrR/l/YP7u6vzMv/739fqMcPmAYfzLv/uvnP7r5/7p5P3k3vqagfqRd/7r5vzGufmFZ/mEZvl8XPl7XP3a0f7x7v7y7/3k3funkvuzofl2Vf3i2/7t6fuii/7+/vl2VvuijPlyT/l1U/lyUPutmv3g2fy/sPy/r/7v7PqOc/l+X/y+rv7w7f7x7fuyn/78+/7t6Pl4V//+/rfPao4AAAAGdFJOU/T29/H8+fQOKpMAAAABYktHRBcL1piPAAAACXBIWXMAACxKAAAsSgF3enRNAAAAB3RJTUUH6gEYFA0caUbtKgAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wMS0yNFQyMDoxMzoyOCswMDowMBOfiR4AAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDEtMjRUMjA6MTM6MjgrMDA6MDBiwjGiAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTAxLTI0VDIwOjEzOjI4KzAwOjAwNdcQfQAAAXFJREFUSMft1dlTgkAcwHG7YCNJSwpdDaU0U9LoxlKs1LTTIi07DDutrPz/30KMmEYwqF6c+j4y8xmW2d8uFkuXmbp7LL2Iqfr+wTcACuQMAxTpxzBswAqMAoAP2uxDww7CMBgZJUnS6YImgdsYAAQBPTIYo4AaqgcA5vX66PEJf2AyOBVS8uEIqgNgmJmORGfY2bn5hcUlpcgyAnQAF5NWs7Iahwk+SaqtWduC9Q0OgalPwOwb0gBCCDS/IbOZZXJbNEtv25ms0s7u3j7LhoAGALgjf0Af+oWjQtCbV/IVBUEIHUMNIO/DSUlaxSlPEUrxs3PpSYzT2+kLGaTUnU6UOxs0Rs8MAJeiKFaKhgFKXF3bbHbGOIA3H/Px64CnpIGD1K1RcHdf5aWqD4/OZk812BY8J18aJWu8u5nrFbQF9XpzIeE4fE/zPLSCDIW09GMgpnPRaKkUbVQoc18CFME9ahXwJVCue6B763fAL+tvgDdnRI6o69eB+AAAAABJRU5ErkJggg==" }, "description": "Hold COD purchases in PixelFly until approved. Posts to your first-party /cod/hold endpoint. Use on purchase events where payment_method is COD.", "categories": [ "ANALYTICS", "CONVERSIONS", "MARKETING" ], "containerContexts": [ "SERVER" ] } ___TEMPLATE_PARAMETERS___ [ { "type": "GROUP", "name": "connectionGroup", "displayName": "Connection", "groupStyle": "ZIPPY_OPEN", "subParams": [ { "type": "TEXT", "name": "serverHost", "displayName": "Server hostname / base URL", "simpleValueType": true, "valueValidators": [ { "type": "NON_EMPTY" } ], "help": "Your first-party tracking host from PixelFly (COD Power-up Hold URL without /cod/hold). Examples: https://server.bazarei.com or server.bazarei.com" }, { "type": "TEXT", "name": "apiKey", "displayName": "API Key (X-PF-Key)", "simpleValueType": true, "valueValidators": [ { "type": "NON_EMPTY" } ], "help": "Container API key from PixelFly dashboard (starts with pf_live_)." } ] }, { "type": "GROUP", "name": "dataGroup", "displayName": "Order data", "groupStyle": "ZIPPY_OPEN", "subParams": [ { "type": "RADIO", "name": "dataMode", "displayName": "How to read order fields", "radioItems": [ { "value": "auto", "displayValue": "Auto from event (Recommended)" }, { "value": "manual", "displayValue": "Map fields manually" } ], "simpleValueType": true, "defaultValue": "auto", "help": "Auto reads transaction_id, value, currency, email, phone, fbp, fbc from the GA4 / Data Client event." }, { "type": "TEXT", "name": "orderId", "displayName": "Order ID", "simpleValueType": true, "help": "Usually {{Event Data - transaction_id}}", "enablingConditions": [ { "paramName": "dataMode", "paramValue": "manual", "type": "EQUALS" } ] }, { "type": "TEXT", "name": "value", "displayName": "Value", "simpleValueType": true, "enablingConditions": [ { "paramName": "dataMode", "paramValue": "manual", "type": "EQUALS" } ] }, { "type": "TEXT", "name": "currency", "displayName": "Currency", "simpleValueType": true, "enablingConditions": [ { "paramName": "dataMode", "paramValue": "manual", "type": "EQUALS" } ] }, { "type": "TEXT", "name": "email", "displayName": "Email", "simpleValueType": true, "enablingConditions": [ { "paramName": "dataMode", "paramValue": "manual", "type": "EQUALS" } ] }, { "type": "TEXT", "name": "phone", "displayName": "Phone", "simpleValueType": true, "enablingConditions": [ { "paramName": "dataMode", "paramValue": "manual", "type": "EQUALS" } ] }, { "type": "TEXT", "name": "fbp", "displayName": "fbp", "simpleValueType": true, "enablingConditions": [ { "paramName": "dataMode", "paramValue": "manual", "type": "EQUALS" } ] }, { "type": "TEXT", "name": "fbc", "displayName": "fbc", "simpleValueType": true, "enablingConditions": [ { "paramName": "dataMode", "paramValue": "manual", "type": "EQUALS" } ] }, { "type": "TEXT", "name": "items", "displayName": "Items / contents", "simpleValueType": true, "help": "Map to Event Data items (array). If empty, Auto/fallback still reads items or ecommerce.items from the event.", "enablingConditions": [ { "paramName": "dataMode", "paramValue": "manual", "type": "EQUALS" } ] }, { "type": "TEXT", "name": "paymentMethod", "displayName": "Payment method", "simpleValueType": true, "defaultValue": "cod", "help": "Sent as payment_method. Default: cod. Leave as cod when this tag only fires on COD trigger." }, { "type": "SIMPLE_TABLE", "name": "additionalFields", "displayName": "Additional fields to store", "help": "Add any extra keys (name, city, content_ids, custom properties…). Click Add Row — like GA4/Meta parameter tables. These are merged into the /cod/hold JSON and saved in PixelFly.", "simpleTableColumns": [ { "defaultValue": "", "displayName": "Field name", "name": "name", "type": "TEXT" }, { "defaultValue": "", "displayName": "Value", "name": "value", "type": "TEXT" } ], "newRowButtonText": "Add field" } ] }, { "type": "GROUP", "name": "optionsGroup", "displayName": "Options", "groupStyle": "ZIPPY_CLOSED", "subParams": [ { "type": "CHECKBOX", "name": "skipIfNotCod", "checkboxText": "Skip request if payment_method is not COD", "simpleValueType": true, "defaultValue": true, "help": "Safety net: if payment_method is present and not COD-like, tag succeeds without calling /cod/hold. Prefer a COD-only trigger as well." }, { "type": "CHECKBOX", "name": "includeEventSnapshot", "checkboxText": "Also attach full event snapshot (event_snapshot)", "simpleValueType": true, "defaultValue": false, "help": "Stores the whole sGTM event object under event_snapshot in the hold payload (useful for debugging / full raw data)." }, { "type": "CHECKBOX", "name": "logToConsole", "checkboxText": "Log request/response in Preview", "simpleValueType": true, "defaultValue": false } ] } ] ___SANDBOXED_JS_FOR_SERVER___ const getAllEventData = require('getAllEventData'); const getRequestHeader = require('getRequestHeader'); const getType = require('getType'); const JSON = require('JSON'); const logToConsole = require('logToConsole'); const makeNumber = require('makeNumber'); const makeString = require('makeString'); const sendHttpRequest = require('sendHttpRequest'); const getTimestampMillis = require('getTimestampMillis'); const eventData = getAllEventData() || {}; const TAG = 'PixelFly COD Protection'; function isEmpty(v) { return v === undefined || v === null || v === ''; } function firstNonEmpty() { for (let i = 0; i < arguments.length; i++) { const v = arguments[i]; if (!isEmpty(v)) return v; } return undefined; } function nestGet(obj, path) { if (!obj || getType(obj) !== 'object') return undefined; const parts = path.split('.'); let cur = obj; for (let i = 0; i < parts.length; i++) { if (cur === undefined || cur === null || getType(cur) !== 'object') return undefined; cur = cur[parts[i]]; } return cur; } function normalizeHost(host) { let h = makeString(host || '').trim(); if (!h) return ''; if (h.indexOf('http://') !== 0 && h.indexOf('https://') !== 0) { h = 'https://' + h; } // strip trailing slash while (h.length > 0 && h.charAt(h.length - 1) === '/') { h = h.substring(0, h.length - 1); } // if user pasted full hold URL, keep as-is if (h.indexOf('/cod/hold') !== -1) return h; return h + '/cod/hold'; } function isCodMethod(method) { if (isEmpty(method)) return true; // treat missing as COD when tag is COD-triggered const m = makeString(method).toLowerCase(); return m === 'cod' || m.indexOf('cash_on_delivery') !== -1 || m.indexOf('cash on delivery') !== -1; } function pickUserField(keys) { for (let i = 0; i < keys.length; i++) { const k = keys[i]; const direct = eventData[k]; if (!isEmpty(direct)) return direct; const userData = nestGet(eventData, 'user_data.' + k); if (!isEmpty(userData)) return userData; } return undefined; } const holdUrl = normalizeHost(data.serverHost); const apiKey = makeString(data.apiKey || '').trim(); if (!holdUrl || !apiKey) { if (data.logToConsole) logToConsole(TAG + ': missing serverHost or apiKey'); return data.gtmOnFailure(); } let orderId; let value; let currency; let email; let phone; let fbp; let fbc; let fbclid; let gclid; let paymentMethod; if (data.dataMode === 'manual') { orderId = firstNonEmpty(data.orderId, eventData.transaction_id, eventData.order_id); value = firstNonEmpty(data.value, eventData.value); currency = firstNonEmpty(data.currency, eventData.currency); email = firstNonEmpty(data.email, pickUserField(['email', 'em'])); phone = firstNonEmpty(data.phone, pickUserField(['phone', 'phone_number', 'ph'])); fbp = firstNonEmpty(data.fbp, eventData.fbp, nestGet(eventData, 'user_data.fbp')); fbc = firstNonEmpty(data.fbc, eventData.fbc, nestGet(eventData, 'user_data.fbc')); } else { orderId = firstNonEmpty( eventData.transaction_id, eventData.order_id, nestGet(eventData, 'ecommerce.transaction_id') ); value = firstNonEmpty(eventData.value, nestGet(eventData, 'ecommerce.value')); currency = firstNonEmpty(eventData.currency, nestGet(eventData, 'ecommerce.currency')); email = pickUserField(['email', 'em']); phone = pickUserField(['phone', 'phone_number', 'ph']); fbp = firstNonEmpty(eventData.fbp, nestGet(eventData, 'user_data.fbp')); fbc = firstNonEmpty(eventData.fbc, nestGet(eventData, 'user_data.fbc')); } paymentMethod = firstNonEmpty( data.paymentMethod, eventData.payment_method, nestGet(eventData, 'ecommerce.payment_method'), 'cod' ); fbclid = firstNonEmpty(eventData.fbclid, nestGet(eventData, 'user_data.fbclid')); gclid = firstNonEmpty(eventData.gclid, nestGet(eventData, 'user_data.gclid')); if (data.skipIfNotCod && !isCodMethod(paymentMethod)) { if (data.logToConsole) { logToConsole(TAG + ': skipped non-COD payment_method=' + makeString(paymentMethod)); } return data.gtmOnSuccess(); } if (isEmpty(orderId)) { if (data.logToConsole) logToConsole(TAG + ': missing order_id / transaction_id'); return data.gtmOnFailure(); } const clientIp = firstNonEmpty( eventData.ip_override, nestGet(eventData, 'context.ip'), getRequestHeader('x-forwarded-for'), getRequestHeader('cf-connecting-ip') ); const userAgent = firstNonEmpty( eventData.user_agent, nestGet(eventData, 'context.user_agent'), getRequestHeader('user-agent') ); let eventTime = firstNonEmpty(eventData.event_time, eventData.timestamp); if (isEmpty(eventTime)) { eventTime = makeNumber(getTimestampMillis() / 1000); } else { eventTime = makeNumber(eventTime); // if millis, convert to seconds if (eventTime > 9999999999) eventTime = makeNumber(eventTime / 1000); } const payload = { order_id: makeString(orderId), event_id: makeString(firstNonEmpty(eventData.event_id, orderId)), event_name: 'Purchase', payment_method: makeString(paymentMethod || 'cod'), source: 'server_container', event_time: eventTime }; if (!isEmpty(value)) payload.value = makeNumber(value); if (!isEmpty(currency)) payload.currency = makeString(currency); if (!isEmpty(email)) payload.email = makeString(email); if (!isEmpty(phone)) payload.phone = makeString(phone); const customerFirst = pickUserField(['customerFirstName', 'customerBillingFirstName', 'fn']); const customerLast = pickUserField(['customerLastName', 'customerBillingLastName', 'ln']); const customerName = makeString(firstNonEmpty( pickUserField(['name']), customerFirst && customerLast ? makeString(customerFirst) + ' ' + makeString(customerLast) : undefined, customerFirst, customerLast )).trim(); if (!isEmpty(customerName)) payload.name = customerName; if (!isEmpty(fbp)) payload.fbp = makeString(fbp); if (!isEmpty(fbc)) payload.fbc = makeString(fbc); if (!isEmpty(fbclid)) payload.fbclid = makeString(fbclid); if (!isEmpty(gclid)) payload.gclid = makeString(gclid); if (!isEmpty(clientIp)) payload.client_ip = makeString(clientIp).split(',')[0].trim(); if (!isEmpty(userAgent)) payload.user_agent = makeString(userAgent); if (!isEmpty(eventData.page_location)) payload.event_source_url = makeString(eventData.page_location); // Items / contents const itemsValue = firstNonEmpty( data.dataMode === 'manual' ? data.items : undefined, eventData.items, nestGet(eventData, 'ecommerce.items'), eventData.contents ); if (!isEmpty(itemsValue)) { // If mapped as a JSON string from a variable, try to parse if (getType(itemsValue) === 'string') { const parsed = JSON.parse(itemsValue); payload.contents = parsed !== undefined ? parsed : itemsValue; } else { payload.contents = itemsValue; } } // Extra rows (Add field table) — merge into payload // (GTM sandbox has no Object.keys — iterate the table rows directly) const extraRows = data.additionalFields; if (getType(extraRows) === 'array') { for (let i = 0; i < extraRows.length; i++) { const row = extraRows[i]; if (!row) continue; const key = makeString(row.name || '').trim(); if (!key) continue; const val = row.value; if (isEmpty(val)) continue; if (getType(val) === 'string') { const s = makeString(val); const first = s.charAt(0); if (first === '{' || first === '[') { const parsed = JSON.parse(s); payload[key] = parsed !== undefined ? parsed : val; } else { payload[key] = val; } } else { payload[key] = val; } } } if (data.includeEventSnapshot) { payload.event_snapshot = eventData; } const body = JSON.stringify(payload); if (data.logToConsole) { logToConsole(TAG + ' POST ' + holdUrl); logToConsole(TAG + ' body ' + body); } sendHttpRequest( holdUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-PF-Key': apiKey }, timeout: 5000 }, body ) .then((result) => { const code = result.statusCode; if (data.logToConsole) { logToConsole(TAG + ' status ' + code + ' response ' + makeString(result.body)); } if (code >= 200 && code < 300) { return data.gtmOnSuccess(); } return data.gtmOnFailure(); }) .catch((err) => { if (data.logToConsole) logToConsole(TAG + ' request failed'); return data.gtmOnFailure(); }); ___SERVER_PERMISSIONS___ [ { "instance": { "key": { "publicId": "read_event_data", "versionId": "1" }, "param": [ { "key": "eventDataAccess", "value": { "type": 1, "string": "any" } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "read_request", "versionId": "1" }, "param": [ { "key": "headerAccess", "value": { "type": 1, "string": "any" } }, { "key": "queryParameterAccess", "value": { "type": 1, "string": "any" } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "logging", "versionId": "1" }, "param": [ { "key": "environments", "value": { "type": 1, "string": "debug" } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "send_http", "versionId": "1" }, "param": [ { "key": "allowedUrls", "value": { "type": 1, "string": "any" } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true } ] ___TESTS___ scenarios: [] ___NOTES___ PixelFly COD Protection — sGTM tag template Version 1.1.0 Posts COD purchases to {firstPartyHost}/cod/hold with X-PF-Key. Setup: 1. Import this .tpl in sGTM → Templates → Tag Templates → Import 2. Create tag from "PixelFly COD Protection" 3. Server hostname = Hold URL host from COD Power-up (e.g. https://server.example.com) 4. API Key = container pf_live_... key 5. Trigger: purchase AND payment_method equals cod 6. Pause Meta/Google/TikTok Purchase tags for COD v1.1.0: - Added Items field (manual mode) - Added "Additional fields to store" table (Add field rows, like GA4/Meta) - Added optional full event_snapshot checkbox Docs: https://pixelfly.io