Pattern 2: Pull In
NetSuite is ACTIVE. Data flows IN from external systems.
How It Works
PULL IN PATTERN
─────────────────────────────────────────────────────────────────
NETSUITE EXTERNAL SYSTEM
┌─────────────────┐ ┌─────────────────┐
│ │ HTTP GET │ │
│ Fetch Data │ ◀───────────── │ API Endpoint │
│ (Scheduled) │ JSON/XML │ │
│ │ │ │
│ Create/Update │ │ │
│ Records │ │ │
└─────────────────┘ └─────────────────┘
│
│ Triggered by:
├── Scheduled Script (periodic polling)
├── Map/Reduce Script (bulk import)
└── Suitelet (on-demand via button)
When to Use
| Scenario | Frequency | Script | External System |
|---|---|---|---|
| Import Shopify orders | Every 15 min | Scheduled | Shopify API |
| Sync inventory from WMS | Every hour | Map/Reduce | Warehouse API |
| Fetch exchange rates | Daily | Scheduled | Currency API |
| Import leads from marketing | Every 30 min | Scheduled | HubSpot |
| On-demand sync (button) | User clicks | Suitelet | Any API |
Scenario A: Periodic Order Import
Case: Every 15 minutes, check Shopify for new orders and create Sales Orders in NetSuite.
FLOW
─────────────────────────────────────────────────────────────────
Every 15 Minutes (Scheduled)
│
▼
┌─────────────────┐ ┌─────────────────┐
│ Scheduled │ ───▶ │ Shopify API │
│ Script │ GET │ /orders.json │
└────────┬────────┘ └─────────────────┘
│
│ For each order:
▼
┌─────────────────┐
│ Check if exists │
│ (by external ID)│
└────────┬────────┘
│
┌────┴────┐
│ │
No Yes
│ │
▼ ▼
┌────────┐ ┌────────┐
│ Create │ │ Skip │
│ Order │ │ │
└────────┘ └────────┘
Script: Scheduled Script
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
* @description Import orders from Shopify every 15 minutes
*/
define(['N/https', 'N/record', 'N/search', 'N/log'], function(https, record, search, log) {
const SHOPIFY_STORE = 'your-store.myshopify.com';
const SHOPIFY_TOKEN = 'shpat_xxxxx';
function execute(context) {
// Get last sync timestamp (from script parameter or custom record)
var lastSync = getLastSyncTime();
// Fetch new orders from Shopify
var response = https.get({
url: 'https://' + SHOPIFY_STORE + '/admin/api/2024-01/orders.json',
headers: {
'X-Shopify-Access-Token': SHOPIFY_TOKEN
}
});
var orders = JSON.parse(response.body).orders;
log.audit('Shopify Orders', 'Found ' + orders.length + ' orders');
orders.forEach(function(shopifyOrder) {
// Skip if already imported
if (orderExists(shopifyOrder.id)) {
log.debug('Skip', 'Order ' + shopifyOrder.id + ' already exists');
return;
}
try {
createSalesOrder(shopifyOrder);
log.audit('Created', 'Order ' + shopifyOrder.order_number);
} catch (e) {
log.error('Failed', 'Order ' + shopifyOrder.id + ': ' + e.message);
}
});
// Update last sync time
updateLastSyncTime();
}
function createSalesOrder(shopifyOrder) {
var salesOrder = record.create({
type: record.Type.SALES_ORDER,
isDynamic: true
});
// Find or create customer
var customerId = findOrCreateCustomer(shopifyOrder.customer);
salesOrder.setValue('entity', customerId);
salesOrder.setValue('memo', 'Shopify Order: ' + shopifyOrder.order_number);
salesOrder.setValue('custbody_shopify_order_id', shopifyOrder.id);
// Add line items
shopifyOrder.line_items.forEach(function(lineItem) {
var itemId = findItemBySku(lineItem.sku);
if (!itemId) {
log.error('Item Not Found', 'SKU: ' + lineItem.sku);
return;
}
salesOrder.selectNewLine({ sublistId: 'item' });
salesOrder.setCurrentSublistValue({ sublistId: 'item', fieldId: 'item', value: itemId });
salesOrder.setCurrentSublistValue({ sublistId: 'item', fieldId: 'quantity', value: lineItem.quantity });
salesOrder.setCurrentSublistValue({ sublistId: 'item', fieldId: 'rate', value: lineItem.price });
salesOrder.commitLine({ sublistId: 'item' });
});
return salesOrder.save();
}
function orderExists(shopifyOrderId) {
var results = search.create({
type: search.Type.SALES_ORDER,
filters: [['custbody_shopify_order_id', 'is', shopifyOrderId]]
}).run().getRange({ start: 0, end: 1 });
return results.length > 0;
}
function findItemBySku(sku) {
var results = search.create({
type: search.Type.ITEM,
filters: [['itemid', 'is', sku]],
columns: ['internalid']
}).run().getRange({ start: 0, end: 1 });
return results.length > 0 ? results[0].getValue('internalid') : null;
}
function findOrCreateCustomer(shopifyCustomer) {
// Search by email
var results = search.create({
type: search.Type.CUSTOMER,
filters: [['email', 'is', shopifyCustomer.email]]
}).run().getRange({ start: 0, end: 1 });
if (results.length > 0) {
return results[0].id;
}
// Create new customer
var customer = record.create({ type: record.Type.CUSTOMER });
customer.setValue('companyname', shopifyCustomer.first_name + ' ' + shopifyCustomer.last_name);
customer.setValue('email', shopifyCustomer.email);
return customer.save();
}
function getLastSyncTime() {
// Implementation: Read from script parameter or custom record
}
function updateLastSyncTime() {
// Implementation: Save to script parameter or custom record
}
return { execute: execute };
});
Deployment:
- Schedule: Every 15 minutes
- Status: Scheduled
Scenario B: On-Demand Sync Button
Case: User clicks "Sync Now" button on a form to trigger immediate data fetch.
FLOW
─────────────────────────────────────────────────────────────────
User clicks "Sync Now" button
│
▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐
│ Client Script │ ───▶ │ Suitelet │ ───▶ │ External │
│ (Button Click) │ AJAX │ (Backend) │ GET │ API │
└─────────────────┘ └─────────────────┘ └─────────────┘
Suitelet (Backend):
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* @description On-demand sync triggered by button
*/
define(['N/https', 'N/record', 'N/log'], function(https, record, log) {
function onRequest(context) {
if (context.request.method !== 'POST') {
context.response.write(JSON.stringify({ error: 'POST required' }));
return;
}
var params = JSON.parse(context.request.body);
var recordId = params.recordId;
try {
// Fetch data from external system
var response = https.get({
url: 'https://api.external-system.com/data/' + recordId,
headers: { 'Authorization': 'Bearer TOKEN' }
});
var externalData = JSON.parse(response.body);
// Update NetSuite record
record.submitFields({
type: record.Type.CUSTOMER,
id: recordId,
values: {
'custentity_external_status': externalData.status,
'custentity_last_sync': new Date()
}
});
context.response.write(JSON.stringify({
success: true,
message: 'Synced successfully'
}));
} catch (e) {
context.response.write(JSON.stringify({
success: false,
error: e.message
}));
}
}
return { onRequest: onRequest };
});
Client Script (Button):
/**
* @NApiVersion 2.1
* @NScriptType ClientScript
*/
define(['N/https', 'N/currentRecord', 'N/ui/dialog'], function(https, currentRecord, dialog) {
function syncNow() {
var rec = currentRecord.get();
https.post.promise({
url: '/app/site/hosting/scriptlet.nl?script=123&deploy=1',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recordId: rec.id })
}).then(function(response) {
var result = JSON.parse(response.body);
dialog.alert({ title: 'Sync Result', message: result.message });
}).catch(function(e) {
dialog.alert({ title: 'Error', message: e.message });
});
}
return {
pageInit: function() {},
syncNow: syncNow
};
});
Scenario C: Bulk Import with Map/Reduce
Case: Import thousands of products from external PIM system.
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
* @description Bulk import products from PIM
*/
define(['N/https', 'N/record', 'N/search', 'N/log'], function(https, search, record, log) {
function getInputData() {
// Fetch product list from external system
var response = https.get({
url: 'https://api.pim-system.com/products',
headers: { 'Authorization': 'Bearer TOKEN' }
});
return JSON.parse(response.body).products;
}
function map(context) {
var product = JSON.parse(context.value);
// Check if product exists
var existingId = findProductBySku(product.sku);
context.write({
key: product.sku,
value: {
product: product,
existingId: existingId
}
});
}
function reduce(context) {
var data = JSON.parse(context.values[0]);
var product = data.product;
var existingId = data.existingId;
try {
if (existingId) {
// Update existing
record.submitFields({
type: record.Type.INVENTORY_ITEM,
id: existingId,
values: {
'displayname': product.name,
'salesprice': product.price,
'custitem_pim_last_sync': new Date()
}
});
log.debug('Updated', product.sku);
} else {
// Create new
var item = record.create({ type: record.Type.INVENTORY_ITEM });
item.setValue('itemid', product.sku);
item.setValue('displayname', product.name);
item.setValue('salesprice', product.price);
item.save();
log.debug('Created', product.sku);
}
} catch (e) {
log.error('Failed', product.sku + ': ' + e.message);
}
}
function findProductBySku(sku) {
var results = search.create({
type: search.Type.INVENTORY_ITEM,
filters: [['itemid', 'is', sku]]
}).run().getRange({ start: 0, end: 1 });
return results.length > 0 ? results[0].id : null;
}
function summarize(summary) {
log.audit('Import Complete', 'Processed: ' + summary.mapSummary.keys.length);
}
return {
getInputData: getInputData,
map: map,
reduce: reduce,
summarize: summarize
};
});
Scenario D: CDC (Change Data Capture) - Incremental Sync
Case: Only fetch records that have changed since the last sync to avoid re-processing and improve performance.
CDC PATTERN
─────────────────────────────────────────────────────────────────
First Run (Initial Load):
│
▼
┌─────────────────┐ ┌─────────────────┐
│ Scheduled │ ───▶ │ External API │
│ Script │ GET │ ?since=null │
└────────┬────────┘ └─────────────────┘
│
│ Process all records
▼
┌─────────────────┐
│ Store timestamp │ ← lastSyncTime = 2024-01-15T10:30:00Z
│ (Custom Record) │
└─────────────────┘
Subsequent Runs:
│
▼
┌─────────────────┐
│ Read last │ ← lastSyncTime = 2024-01-15T10:30:00Z
│ sync timestamp │
└────────┬────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│ Scheduled │ ───▶ │ External API │
│ Script │ GET │ ?since=2024-01-15T10:30:00Z
└────────┬────────┘ │ &until=now │
│ └─────────────────┘
│
│ Only new/changed records
▼
┌─────────────────┐
│ Process delta │
│ Update lastSync │ ← lastSyncTime = 2024-01-15T10:45:00Z
└─────────────────┘
Sync State Custom Record Setup
Create custom record customrecord_integration_sync_state:
| Field ID | Type | Purpose |
|---|---|---|
custrecord_sync_integration_id | Text | Unique identifier (e.g., "SHOPIFY_ORDERS") |
custrecord_sync_last_timestamp | Date/Time | Last successful sync timestamp |
custrecord_sync_last_record_id | Text | Last processed record ID (for cursor-based) |
custrecord_sync_status | List | IDLE, RUNNING, FAILED |
custrecord_sync_records_processed | Integer | Count from last run |
custrecord_sync_last_error | Text | Last error message |
CDC Scheduled Script
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
* @description CDC Import - Only fetch changed records since last sync
*/
define(['N/https', 'N/record', 'N/search', 'N/log', 'N/format'], function(https, record, search, log, format) {
const INTEGRATION_ID = 'SHOPIFY_ORDERS';
const SHOPIFY_STORE = 'your-store.myshopify.com';
const SHOPIFY_TOKEN = 'shpat_xxxxx';
function execute(context) {
var syncState = getSyncState();
// Mark as running
updateSyncStatus(syncState.id, 'RUNNING');
try {
var lastSyncTime = syncState.lastTimestamp;
var currentSyncTime = new Date();
// Build API URL with date filter
var apiUrl = buildApiUrl(lastSyncTime, currentSyncTime);
log.audit('CDC Sync Start', {
lastSync: lastSyncTime ? lastSyncTime.toISOString() : 'INITIAL',
currentSync: currentSyncTime.toISOString()
});
// Fetch only changed records
var response = https.get({
url: apiUrl,
headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN }
});
var orders = JSON.parse(response.body).orders;
log.audit('Records Found', orders.length + ' orders changed since last sync');
var processedCount = 0;
var skippedCount = 0;
orders.forEach(function(order) {
var result = processOrder(order);
if (result.processed) {
processedCount++;
} else {
skippedCount++;
}
});
// Update sync state with new timestamp
updateSyncState(syncState.id, {
lastTimestamp: currentSyncTime,
status: 'IDLE',
recordsProcessed: processedCount,
lastError: ''
});
log.audit('CDC Sync Complete', {
processed: processedCount,
skipped: skippedCount,
nextSyncFrom: currentSyncTime.toISOString()
});
} catch (e) {
log.error('CDC Sync Failed', e.message);
updateSyncState(syncState.id, {
status: 'FAILED',
lastError: e.message
});
}
}
function buildApiUrl(lastSyncTime, currentSyncTime) {
var baseUrl = 'https://' + SHOPIFY_STORE + '/admin/api/2024-01/orders.json';
var params = [];
if (lastSyncTime) {
// Fetch orders updated since last sync
// Add small buffer (1 minute) to handle clock differences
var bufferTime = new Date(lastSyncTime.getTime() - 60000);
params.push('updated_at_min=' + encodeURIComponent(bufferTime.toISOString()));
}
// Always set upper bound to current time
params.push('updated_at_max=' + encodeURIComponent(currentSyncTime.toISOString()));
// Sort by updated_at to process in order
params.push('order=updated_at asc');
// Limit per request
params.push('limit=250');
return baseUrl + '?' + params.join('&');
}
function processOrder(shopifyOrder) {
var externalId = String(shopifyOrder.id);
// Check if order exists in NetSuite
var existingOrder = findExistingOrder(externalId);
if (existingOrder) {
// Order exists - check if we need to update
var nsLastModified = existingOrder.lastModified;
var shopifyUpdated = new Date(shopifyOrder.updated_at);
if (shopifyUpdated <= nsLastModified) {
// NetSuite is up to date, skip
return { processed: false, reason: 'UP_TO_DATE' };
}
// Update existing order
updateExistingOrder(existingOrder.id, shopifyOrder);
log.debug('Updated', 'Order ' + shopifyOrder.order_number);
return { processed: true, action: 'UPDATE' };
} else {
// New order - create it
createNewOrder(shopifyOrder);
log.debug('Created', 'Order ' + shopifyOrder.order_number);
return { processed: true, action: 'CREATE' };
}
}
function findExistingOrder(externalId) {
var results = search.create({
type: search.Type.SALES_ORDER,
filters: [['custbody_shopify_order_id', 'is', externalId]],
columns: ['internalid', 'lastmodifieddate']
}).run().getRange({ start: 0, end: 1 });
if (results.length > 0) {
return {
id: results[0].id,
lastModified: new Date(results[0].getValue('lastmodifieddate'))
};
}
return null;
}
function createNewOrder(shopifyOrder) {
var salesOrder = record.create({
type: record.Type.SALES_ORDER,
isDynamic: true
});
var customerId = findOrCreateCustomer(shopifyOrder.customer);
salesOrder.setValue('entity', customerId);
salesOrder.setValue('memo', 'Shopify: ' + shopifyOrder.order_number);
salesOrder.setValue('custbody_shopify_order_id', shopifyOrder.id);
salesOrder.setValue('custbody_shopify_updated_at', new Date(shopifyOrder.updated_at));
// Add line items
shopifyOrder.line_items.forEach(function(lineItem) {
var itemId = findItemBySku(lineItem.sku);
if (itemId) {
salesOrder.selectNewLine({ sublistId: 'item' });
salesOrder.setCurrentSublistValue({ sublistId: 'item', fieldId: 'item', value: itemId });
salesOrder.setCurrentSublistValue({ sublistId: 'item', fieldId: 'quantity', value: lineItem.quantity });
salesOrder.setCurrentSublistValue({ sublistId: 'item', fieldId: 'rate', value: lineItem.price });
salesOrder.commitLine({ sublistId: 'item' });
}
});
return salesOrder.save();
}
function updateExistingOrder(orderId, shopifyOrder) {
// Update relevant fields that might have changed
record.submitFields({
type: record.Type.SALES_ORDER,
id: orderId,
values: {
'custbody_shopify_status': shopifyOrder.financial_status,
'custbody_shopify_fulfillment': shopifyOrder.fulfillment_status,
'custbody_shopify_updated_at': new Date(shopifyOrder.updated_at)
}
});
}
// ─────────────────────────────────────────────────────────────────
// SYNC STATE MANAGEMENT
// ─────────────────────────────────────────────────────────────────
function getSyncState() {
var results = search.create({
type: 'customrecord_integration_sync_state',
filters: [['custrecord_sync_integration_id', 'is', INTEGRATION_ID]],
columns: [
'internalid',
'custrecord_sync_last_timestamp',
'custrecord_sync_last_record_id',
'custrecord_sync_status'
]
}).run().getRange({ start: 0, end: 1 });
if (results.length > 0) {
var lastTimestamp = results[0].getValue('custrecord_sync_last_timestamp');
return {
id: results[0].id,
lastTimestamp: lastTimestamp ? new Date(lastTimestamp) : null,
lastRecordId: results[0].getValue('custrecord_sync_last_record_id'),
status: results[0].getValue('custrecord_sync_status')
};
}
// Create initial sync state record
var newState = record.create({ type: 'customrecord_integration_sync_state' });
newState.setValue('custrecord_sync_integration_id', INTEGRATION_ID);
newState.setValue('custrecord_sync_status', 'IDLE');
var newId = newState.save();
return {
id: newId,
lastTimestamp: null,
lastRecordId: null,
status: 'IDLE'
};
}
function updateSyncState(stateId, values) {
var updateValues = {};
if (values.lastTimestamp !== undefined) {
updateValues['custrecord_sync_last_timestamp'] = values.lastTimestamp;
}
if (values.lastRecordId !== undefined) {
updateValues['custrecord_sync_last_record_id'] = values.lastRecordId;
}
if (values.status !== undefined) {
updateValues['custrecord_sync_status'] = values.status;
}
if (values.recordsProcessed !== undefined) {
updateValues['custrecord_sync_records_processed'] = values.recordsProcessed;
}
if (values.lastError !== undefined) {
updateValues['custrecord_sync_last_error'] = values.lastError;
}
record.submitFields({
type: 'customrecord_integration_sync_state',
id: stateId,
values: updateValues
});
}
function updateSyncStatus(stateId, status) {
updateSyncState(stateId, { status: status });
}
// Helper functions
function findOrCreateCustomer(shopifyCustomer) {
if (!shopifyCustomer) return getDefaultCustomer();
var results = search.create({
type: search.Type.CUSTOMER,
filters: [['email', 'is', shopifyCustomer.email]]
}).run().getRange({ start: 0, end: 1 });
if (results.length > 0) return results[0].id;
var customer = record.create({ type: record.Type.CUSTOMER });
customer.setValue('companyname', shopifyCustomer.first_name + ' ' + shopifyCustomer.last_name);
customer.setValue('email', shopifyCustomer.email);
return customer.save();
}
function findItemBySku(sku) {
var results = search.create({
type: search.Type.ITEM,
filters: [['itemid', 'is', sku]],
columns: ['internalid']
}).run().getRange({ start: 0, end: 1 });
return results.length > 0 ? results[0].getValue('internalid') : null;
}
function getDefaultCustomer() {
// Return a default customer ID for guest orders
return 123; // Replace with your default customer
}
return { execute: execute };
});
CDC with Cursor-Based Pagination
For APIs that use cursor/offset pagination instead of timestamps:
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
* @description CDC with cursor-based pagination
*/
define(['N/https', 'N/record', 'N/search', 'N/log'], function(https, record, search, log) {
const INTEGRATION_ID = 'CRM_CONTACTS';
function execute(context) {
var syncState = getSyncState();
var lastCursor = syncState.lastRecordId;
var pageSize = 100;
var totalProcessed = 0;
var hasMore = true;
while (hasMore) {
// Build URL with cursor
var url = 'https://api.crm.com/contacts?limit=' + pageSize;
if (lastCursor) {
url += '&after=' + lastCursor;
}
var response = https.get({
url: url,
headers: { 'Authorization': 'Bearer TOKEN' }
});
var data = JSON.parse(response.body);
var contacts = data.results;
if (contacts.length === 0) {
hasMore = false;
break;
}
// Process each contact
contacts.forEach(function(contact) {
processContact(contact);
totalProcessed++;
});
// Update cursor to last processed ID
lastCursor = contacts[contacts.length - 1].id;
// Save progress after each page (in case of failure)
updateSyncState(syncState.id, {
lastRecordId: lastCursor,
recordsProcessed: totalProcessed
});
// Check if more pages exist
hasMore = data.hasMore || contacts.length === pageSize;
// Governance check
var remainingUsage = runtime.getCurrentScript().getRemainingUsage();
if (remainingUsage < 500) {
log.audit('Governance', 'Stopping - will resume from cursor: ' + lastCursor);
break;
}
}
log.audit('CDC Complete', 'Processed: ' + totalProcessed);
}
function processContact(contact) {
// Check if exists by external ID
var existingId = findByExternalId('customrecord_crm_contact', 'custrecord_crm_id', contact.id);
if (existingId) {
// Update
record.submitFields({
type: 'customrecord_crm_contact',
id: existingId,
values: {
'custrecord_crm_name': contact.name,
'custrecord_crm_email': contact.email,
'custrecord_crm_last_sync': new Date()
}
});
} else {
// Create
var rec = record.create({ type: 'customrecord_crm_contact' });
rec.setValue('custrecord_crm_id', contact.id);
rec.setValue('custrecord_crm_name', contact.name);
rec.setValue('custrecord_crm_email', contact.email);
rec.save();
}
}
function findByExternalId(recordType, fieldId, externalId) {
var results = search.create({
type: recordType,
filters: [[fieldId, 'is', externalId]]
}).run().getRange({ start: 0, end: 1 });
return results.length > 0 ? results[0].id : null;
}
// getSyncState and updateSyncState same as above...
return { execute: execute };
});
CDC Comparison Strategies
| Strategy | API Requirement | Best For |
|---|---|---|
| Timestamp-based | updated_at_min parameter | Most REST APIs |
| Cursor-based | after or offset parameter | Large datasets |
| Delta token | API returns change token | Microsoft Graph, etc |
| Polling with hash | None (compare locally) | APIs without filters |
Handling Clock Drift
Always add a small buffer when using timestamps:
function getLastSyncWithBuffer(lastSync, bufferMinutes) {
if (!lastSync) return null;
// Subtract buffer to handle clock differences
var buffered = new Date(lastSync.getTime() - (bufferMinutes * 60 * 1000));
return buffered;
}
// Usage: fetch 5 minutes before last sync to be safe
var since = getLastSyncWithBuffer(lastSyncTime, 5);
Monitoring CDC Health
Create a saved search to monitor sync states:
Type: Custom Record - Integration Sync State
Columns: Integration ID, Last Timestamp, Status, Records Processed, Last Error
Filters: Status = FAILED OR Last Timestamp older than 1 day
Handling Pagination
For APIs that return paginated results:
function fetchAllPages() {
var allResults = [];
var page = 1;
var hasMore = true;
while (hasMore) {
var response = https.get({
url: 'https://api.example.com/items?page=' + page + '&limit=100',
headers: { 'Authorization': 'Bearer TOKEN' }
});
var data = JSON.parse(response.body);
allResults = allResults.concat(data.items);
hasMore = data.hasNextPage;
page++;
// Governance check
if (page > 50) break; // Safety limit
}
return allResults;
}
Script Selection Guide
| Need | Use This Script |
|---|---|
| Periodic polling (every X minutes) | Scheduled Script |
| Import thousands of records | Map/Reduce Script |
| User-triggered sync | Client Script → Suitelet |
| One-time migration | Map/Reduce Script |
| Incremental sync (only changes) | Scheduled Script with CDC |
| Avoid duplicate processing | CDC with timestamp/cursor tracking |