Pattern 1: Push Out
NetSuite is ACTIVE. Data flows OUT to external systems.
How It Works
PUSH OUT PATTERN
─────────────────────────────────────────────────────────────────
NETSUITE EXTERNAL SYSTEM
┌─────────────────┐ ┌─────────────────┐
│ │ HTTP POST │ │
│ Record Saved │ ─────────────▶ │ Receive Data │
│ (Order, etc) │ JSON/XML │ (Webhook) │
│ │ │ │
└─────────────────┘ └─────────────────┘
│
│ Triggered by:
├── User Event Script (afterSubmit)
├── Workflow Action Script
├── Scheduled Script
└── Map/Reduce Script
When to Use
| Scenario | Trigger | Script | External System |
|---|---|---|---|
| Order to fulfillment center | Order created | User Event | ShipStation, ShipBob |
| Customer to CRM | Customer saved | User Event | Salesforce, HubSpot |
| Invoice to accounting | Invoice approved | Workflow Action | QuickBooks, Xero |
| Daily sales to BI | Nightly schedule | Scheduled | Snowflake, BigQuery |
| Inventory to e-commerce | Every 15 min | Scheduled | Shopify, Magento |
| Bulk orders to 3PL | Every hour | Map/Reduce | 3PL API |
Scenario A: Real-Time Sync on Record Save
Case: When a Sales Order is created, immediately send it to the fulfillment system.
FLOW
─────────────────────────────────────────────────────────────────
User creates Sales Order
│
▼
┌─────────────────┐
│ beforeSubmit │ ← Validate, modify data
└────────┬────────┘
│
▼
┌─────────────────┐
│ Record Saved │ ← Committed to database
│ to Database │
└────────┬────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│ afterSubmit │ ───▶ │ External API │
│ (User Event) │ POST │ (Fulfillment) │
└─────────────────┘ └─────────────────┘
Script: User Event Script
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* @description Sync Sales Order to fulfillment system on create
*/
define(['N/https', 'N/record', 'N/log', 'N/runtime'], function(https, record, log, runtime) {
const FULFILLMENT_API = 'https://api.fulfillment-system.com/orders';
const API_KEY = 'your-api-key'; // Store in script parameter or custom record
function afterSubmit(context) {
// Only run on create
if (context.type !== context.UserEventType.CREATE) {
return;
}
var salesOrder = context.newRecord;
// Build payload
var payload = {
externalOrderId: salesOrder.getValue('tranid'),
netsuiteId: salesOrder.id,
customer: {
name: salesOrder.getText('entity'),
email: salesOrder.getValue('email')
},
shipTo: {
address: salesOrder.getValue('shipaddress')
},
items: getLineItems(salesOrder),
total: salesOrder.getValue('total'),
createdAt: new Date().toISOString()
};
try {
var response = https.post({
url: FULFILLMENT_API,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + API_KEY
},
body: JSON.stringify(payload)
});
if (response.code === 200 || response.code === 201) {
log.audit('Sync Success', 'Order ' + salesOrder.id + ' synced');
// Optional: Store external ID on the record
record.submitFields({
type: record.Type.SALES_ORDER,
id: salesOrder.id,
values: { 'custbody_external_order_id': JSON.parse(response.body).id }
});
} else {
throw new Error('API returned ' + response.code);
}
} catch (e) {
log.error('Sync Failed', e.message);
// Queue for retry - create a custom record
createRetryRecord(salesOrder.id, 'SALES_ORDER', e.message);
}
}
function getLineItems(salesOrder) {
var items = [];
var lineCount = salesOrder.getLineCount({ sublistId: 'item' });
for (var i = 0; i < lineCount; i++) {
items.push({
sku: salesOrder.getSublistValue({ sublistId: 'item', fieldId: 'item', line: i }),
quantity: salesOrder.getSublistValue({ sublistId: 'item', fieldId: 'quantity', line: i }),
rate: salesOrder.getSublistValue({ sublistId: 'item', fieldId: 'rate', line: i })
});
}
return items;
}
function createRetryRecord(recordId, recordType, errorMsg) {
var retry = record.create({ type: 'customrecord_integration_queue' });
retry.setValue('custrecord_queue_record_id', recordId);
retry.setValue('custrecord_queue_record_type', recordType);
retry.setValue('custrecord_queue_status', 'PENDING');
retry.setValue('custrecord_queue_error', errorMsg);
retry.setValue('custrecord_queue_attempts', 0);
retry.save();
}
return { afterSubmit: afterSubmit };
});
Deployment:
- Deploy to: Sales Order
- Execute As: Role with API permissions
- Event Type: Create (or Create/Edit if needed)
Scenario B: Conditional Sync via Workflow
Case: Only sync invoices to payment gateway when status changes to "Approved".
FLOW
─────────────────────────────────────────────────────────────────
Invoice status changes to "Approved"
│
▼
┌─────────────────┐
│ Workflow │
│ Condition Met │
└────────┬────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│ Workflow Action │ ───▶ │ Payment Gateway │
│ Script │ POST │ (Stripe, etc) │
└─────────────────┘ └─────────────────┘
Script: Workflow Action Script
/**
* @NApiVersion 2.1
* @NScriptType WorkflowActionScript
* @description Send approved invoice to payment gateway
*/
define(['N/https', 'N/runtime', 'N/log'], function(https, runtime, log) {
function onAction(context) {
var invoice = context.newRecord;
var payload = {
invoiceId: invoice.getValue('tranid'),
amount: invoice.getValue('total'),
currency: invoice.getValue('currency'),
customerId: invoice.getValue('entity'),
dueDate: invoice.getValue('duedate')
};
try {
var response = https.post({
url: 'https://api.stripe.com/v1/invoices',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer sk_live_xxxxx'
},
body: JSON.stringify(payload)
});
log.audit('Payment Gateway Sync', 'Invoice ' + invoice.id + ' sent');
// Return value to workflow (optional)
return response.code === 200 ? 'SUCCESS' : 'FAILED';
} catch (e) {
log.error('Payment Gateway Error', e.message);
return 'ERROR';
}
}
return { onAction: onAction };
});
Setup:
- Create Workflow on Invoice record
- Add State with condition: Status = Approved
- Add Action → Custom Action → Select your script
- Optionally store return value in workflow field
Scenario C: Batch Export via Scheduled Script
Case: Every night at midnight, export all invoices created today to the data warehouse.
FLOW
─────────────────────────────────────────────────────────────────
Midnight (Scheduled)
│
▼
┌─────────────────┐
│ Saved Search: │
│ Today's Invoices│
└────────┬────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│ Scheduled │ ───▶ │ Data Warehouse │
│ Script │ POST │ (Snowflake) │
└─────────────────┘ bulk └─────────────────┘
Script: Scheduled Script
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
* @description Nightly export of invoices to data warehouse
*/
define(['N/https', 'N/search', 'N/log', 'N/format'], function(https, search, log, format) {
function execute(context) {
// Get today's date range
var today = new Date();
var startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
// Search for today's invoices
var invoiceSearch = search.create({
type: search.Type.INVOICE,
filters: [
['mainline', 'is', 'T'],
'AND',
['trandate', 'within', 'today']
],
columns: [
'tranid', 'entity', 'total', 'trandate',
'status', 'subsidiary', 'currency'
]
});
var invoices = [];
invoiceSearch.run().each(function(result) {
invoices.push({
invoiceNumber: result.getValue('tranid'),
customerId: result.getValue('entity'),
customerName: result.getText('entity'),
amount: result.getValue('total'),
date: result.getValue('trandate'),
status: result.getText('status'),
subsidiary: result.getText('subsidiary'),
currency: result.getText('currency')
});
return true; // continue iteration
});
if (invoices.length === 0) {
log.audit('Export', 'No invoices to export');
return;
}
// Send to data warehouse
try {
var response = https.post({
url: 'https://your-warehouse.snowflakecomputing.com/api/ingest',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
},
body: JSON.stringify({
table: 'netsuite_invoices',
records: invoices,
exportDate: today.toISOString()
})
});
log.audit('Export Success', invoices.length + ' invoices exported');
} catch (e) {
log.error('Export Failed', e.message);
}
}
return { execute: execute };
});
Deployment:
- Schedule: Daily at 12:00 AM
- Status: Scheduled
Scenario D: High-Volume Batch with Map/Reduce
Case: Sync thousands of orders to 3PL system every hour with parallel processing.
Script: Map/Reduce Script
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
* @description Bulk sync orders to 3PL with parallel processing
*/
define(['N/https', 'N/search', 'N/record', 'N/log'], function(https, search, record, log) {
function getInputData() {
// Find orders pending sync
return search.create({
type: search.Type.SALES_ORDER,
filters: [
['mainline', 'is', 'T'],
'AND',
['custbody_3pl_sync_status', 'is', 'PENDING'],
'AND',
['status', 'anyof', 'SalesOrd:B'] // Pending Fulfillment
],
columns: ['tranid', 'entity', 'total', 'shipaddress']
});
}
function map(context) {
var searchResult = JSON.parse(context.value);
// Load full record for line items
var salesOrder = record.load({
type: record.Type.SALES_ORDER,
id: searchResult.id
});
var payload = {
orderId: salesOrder.getValue('tranid'),
netsuiteId: salesOrder.id,
items: []
};
var lineCount = salesOrder.getLineCount({ sublistId: 'item' });
for (var i = 0; i < lineCount; i++) {
payload.items.push({
sku: salesOrder.getSublistText({ sublistId: 'item', fieldId: 'item', line: i }),
qty: salesOrder.getSublistValue({ sublistId: 'item', fieldId: 'quantity', line: i })
});
}
// Write to reduce stage with order ID as key
context.write({
key: salesOrder.id,
value: payload
});
}
function reduce(context) {
var orderId = context.key;
var payload = JSON.parse(context.values[0]);
try {
var response = https.post({
url: 'https://api.3pl-provider.com/orders',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your-3pl-api-key'
},
body: JSON.stringify(payload)
});
if (response.code === 200 || response.code === 201) {
// Update sync status
record.submitFields({
type: record.Type.SALES_ORDER,
id: orderId,
values: {
'custbody_3pl_sync_status': 'SYNCED',
'custbody_3pl_sync_date': new Date()
}
});
context.write({ key: 'success', value: orderId });
} else {
throw new Error('API error: ' + response.code);
}
} catch (e) {
record.submitFields({
type: record.Type.SALES_ORDER,
id: orderId,
values: {
'custbody_3pl_sync_status': 'FAILED',
'custbody_3pl_sync_error': e.message
}
});
context.write({ key: 'failed', value: orderId });
}
}
function summarize(summary) {
var successCount = 0;
var failCount = 0;
summary.output.iterator().each(function(key, value) {
if (key === 'success') successCount++;
if (key === 'failed') failCount++;
return true;
});
log.audit('Sync Complete', 'Success: ' + successCount + ', Failed: ' + failCount);
}
return {
getInputData: getInputData,
map: map,
reduce: reduce,
summarize: summarize
};
});
Scenario E: Retry Queue for Failed Syncs
Case: External system is sometimes unavailable. Queue failed requests and retry with exponential backoff.
RETRY QUEUE ARCHITECTURE
─────────────────────────────────────────────────────────────────
User Event (afterSubmit)
│
│ Try sync
▼
┌─────────────────┐
│ External API │
│ Call │
└────────┬────────┘
│
┌────┴────┐
│ │
Success Failed
│ │
▼ ▼
┌────────┐ ┌─────────────────┐
│ Update │ │ Create Queue │
│ Record │ │ Record │
│ Status │ │ (PENDING) │
└────────┘ └────────┬────────┘
│
│ Every 15 minutes
▼
┌─────────────────┐
│ Map/Reduce │
│ Retry Processor │
└────────┬────────┘
│
┌────┴────┐
│ │
Success Failed
│ │
▼ ▼
┌────────┐ ┌─────────────────┐
│ Mark │ │ attempts < 5? │
│ SUCCESS│ └────────┬────────┘
└────────┘ │
┌────┴────┐
│ │
Yes No
│ │
▼ ▼
┌────────┐ ┌────────┐
│ Retry │ │ Mark │
│ Later │ │ FAILED │
│(backoff)│ │(alert) │
└────────┘ └────────┘
Queue Custom Record Setup
Create custom record customrecord_integration_queue:
| Field ID | Type | Purpose |
|---|---|---|
custrecord_queue_record_id | Integer | NetSuite record internal ID |
custrecord_queue_record_type | Text | Record type (SALES_ORDER, INVOICE, etc) |
custrecord_queue_payload | Long Text | JSON payload to send |
custrecord_queue_status | List | PENDING, PROCESSING, SUCCESS, FAILED |
custrecord_queue_attempts | Integer | Number of retry attempts |
custrecord_queue_next_retry | Date/Time | When to retry next |
custrecord_queue_error | Long Text | Last error message |
custrecord_queue_created | Date/Time | When queued |
custrecord_queue_endpoint | Text | Target API endpoint |
User Event Script with Queue Fallback
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* @description Sync with retry queue fallback
*/
define(['N/https', 'N/record', 'N/log', 'N/runtime'], function(https, record, log, runtime) {
const API_ENDPOINT = 'https://api.external-system.com/orders';
const API_KEY = 'your-api-key';
function afterSubmit(context) {
if (context.type !== context.UserEventType.CREATE) return;
var salesOrder = context.newRecord;
var payload = buildPayload(salesOrder);
// Try immediate sync
var result = trySyncWithRetry(payload, 2); // 2 immediate retries
if (result.success) {
// Update record with external ID
record.submitFields({
type: record.Type.SALES_ORDER,
id: salesOrder.id,
values: {
'custbody_sync_status': 'SYNCED',
'custbody_external_id': result.externalId,
'custbody_sync_date': new Date()
}
});
} else {
// Queue for background retry
queueForRetry(salesOrder.id, 'SALES_ORDER', payload, result.error);
record.submitFields({
type: record.Type.SALES_ORDER,
id: salesOrder.id,
values: { 'custbody_sync_status': 'QUEUED' }
});
}
}
function trySyncWithRetry(payload, maxRetries) {
var lastError = '';
for (var attempt = 1; attempt <= maxRetries; attempt++) {
try {
var response = https.post({
url: API_ENDPOINT,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + API_KEY
},
body: JSON.stringify(payload)
});
if (response.code >= 200 && response.code < 300) {
var body = JSON.parse(response.body);
return { success: true, externalId: body.id };
}
// Non-retryable error (4xx)
if (response.code >= 400 && response.code < 500) {
return { success: false, error: 'Client error: ' + response.code };
}
lastError = 'Server error: ' + response.code;
} catch (e) {
lastError = e.message;
}
// Brief pause between immediate retries
if (attempt < maxRetries) {
// Small delay (SuiteScript doesn't have sleep, but try/catch acts as delay)
log.debug('Retry', 'Attempt ' + attempt + ' failed, retrying...');
}
}
return { success: false, error: lastError };
}
function queueForRetry(recordId, recordType, payload, errorMsg) {
var queue = record.create({ type: 'customrecord_integration_queue' });
queue.setValue('custrecord_queue_record_id', recordId);
queue.setValue('custrecord_queue_record_type', recordType);
queue.setValue('custrecord_queue_payload', JSON.stringify(payload));
queue.setValue('custrecord_queue_status', 'PENDING');
queue.setValue('custrecord_queue_attempts', 0);
queue.setValue('custrecord_queue_next_retry', new Date());
queue.setValue('custrecord_queue_error', errorMsg);
queue.setValue('custrecord_queue_created', new Date());
queue.setValue('custrecord_queue_endpoint', API_ENDPOINT);
queue.save();
log.audit('Queued', 'Record ' + recordId + ' queued for retry');
}
function buildPayload(salesOrder) {
return {
externalOrderId: salesOrder.getValue('tranid'),
netsuiteId: salesOrder.id,
customer: salesOrder.getText('entity'),
total: salesOrder.getValue('total'),
items: getLineItems(salesOrder)
};
}
function getLineItems(salesOrder) {
var items = [];
var lineCount = salesOrder.getLineCount({ sublistId: 'item' });
for (var i = 0; i < lineCount; i++) {
items.push({
sku: salesOrder.getSublistText({ sublistId: 'item', fieldId: 'item', line: i }),
quantity: salesOrder.getSublistValue({ sublistId: 'item', fieldId: 'quantity', line: i }),
rate: salesOrder.getSublistValue({ sublistId: 'item', fieldId: 'rate', line: i })
});
}
return items;
}
return { afterSubmit: afterSubmit };
});
Map/Reduce Retry Processor
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
* @description Process retry queue with exponential backoff
*/
define(['N/https', 'N/record', 'N/search', 'N/log', 'N/email', 'N/runtime'], function(https, record, search, log, email, runtime) {
const MAX_ATTEMPTS = 5;
const ALERT_EMAIL = 'admin@company.com';
function getInputData() {
var now = new Date();
return search.create({
type: 'customrecord_integration_queue',
filters: [
['custrecord_queue_status', 'anyof', ['PENDING', 'PROCESSING']],
'AND',
['custrecord_queue_next_retry', 'onorbefore', now],
'AND',
['custrecord_queue_attempts', 'lessthan', MAX_ATTEMPTS]
],
columns: [
'custrecord_queue_record_id',
'custrecord_queue_record_type',
'custrecord_queue_payload',
'custrecord_queue_attempts',
'custrecord_queue_endpoint',
'custrecord_queue_error'
]
});
}
function map(context) {
var result = JSON.parse(context.value);
var queueId = result.id;
// Mark as processing
record.submitFields({
type: 'customrecord_integration_queue',
id: queueId,
values: { 'custrecord_queue_status': 'PROCESSING' }
});
context.write({
key: queueId,
value: {
queueId: queueId,
recordId: result.values.custrecord_queue_record_id,
recordType: result.values.custrecord_queue_record_type,
payload: result.values.custrecord_queue_payload,
attempts: parseInt(result.values.custrecord_queue_attempts) || 0,
endpoint: result.values.custrecord_queue_endpoint
}
});
}
function reduce(context) {
var data = JSON.parse(context.values[0]);
var queueId = data.queueId;
var attempts = data.attempts + 1;
try {
var payload = JSON.parse(data.payload);
var response = https.post({
url: data.endpoint,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + runtime.getCurrentScript().getParameter('custscript_api_key')
},
body: JSON.stringify(payload)
});
if (response.code >= 200 && response.code < 300) {
// SUCCESS
var responseBody = JSON.parse(response.body);
// Update queue record
record.submitFields({
type: 'customrecord_integration_queue',
id: queueId,
values: {
'custrecord_queue_status': 'SUCCESS',
'custrecord_queue_attempts': attempts
}
});
// Update original record
updateOriginalRecord(data.recordId, data.recordType, responseBody.id);
log.audit('Retry Success', 'Queue ' + queueId + ' succeeded on attempt ' + attempts);
context.write({ key: 'success', value: queueId });
} else {
throw new Error('HTTP ' + response.code + ': ' + response.body);
}
} catch (e) {
handleRetryFailure(queueId, attempts, e.message, data);
context.write({ key: 'failed', value: queueId });
}
}
function handleRetryFailure(queueId, attempts, errorMsg, data) {
if (attempts >= MAX_ATTEMPTS) {
// Max retries reached - mark as failed and alert
record.submitFields({
type: 'customrecord_integration_queue',
id: queueId,
values: {
'custrecord_queue_status': 'FAILED',
'custrecord_queue_attempts': attempts,
'custrecord_queue_error': errorMsg
}
});
// Update original record
record.submitFields({
type: getRecordType(data.recordType),
id: data.recordId,
values: { 'custbody_sync_status': 'FAILED' }
});
// Send alert email
sendFailureAlert(queueId, data.recordId, data.recordType, attempts, errorMsg);
log.error('Max Retries', 'Queue ' + queueId + ' failed after ' + attempts + ' attempts');
} else {
// Schedule next retry with exponential backoff
var nextRetry = calculateNextRetry(attempts);
record.submitFields({
type: 'customrecord_integration_queue',
id: queueId,
values: {
'custrecord_queue_status': 'PENDING',
'custrecord_queue_attempts': attempts,
'custrecord_queue_next_retry': nextRetry,
'custrecord_queue_error': errorMsg
}
});
log.debug('Retry Scheduled', 'Queue ' + queueId + ' retry #' + (attempts + 1) + ' at ' + nextRetry);
}
}
function calculateNextRetry(attempts) {
// Exponential backoff: 1min, 4min, 16min, 64min, 256min
var delayMinutes = Math.pow(4, attempts);
var maxDelay = 60 * 4; // Cap at 4 hours
delayMinutes = Math.min(delayMinutes, maxDelay);
var nextRetry = new Date();
nextRetry.setMinutes(nextRetry.getMinutes() + delayMinutes);
return nextRetry;
}
function updateOriginalRecord(recordId, recordType, externalId) {
record.submitFields({
type: getRecordType(recordType),
id: recordId,
values: {
'custbody_sync_status': 'SYNCED',
'custbody_external_id': externalId,
'custbody_sync_date': new Date()
}
});
}
function getRecordType(typeString) {
var typeMap = {
'SALES_ORDER': record.Type.SALES_ORDER,
'INVOICE': record.Type.INVOICE,
'CUSTOMER': record.Type.CUSTOMER,
'PURCHASE_ORDER': record.Type.PURCHASE_ORDER
};
return typeMap[typeString] || typeString;
}
function sendFailureAlert(queueId, recordId, recordType, attempts, errorMsg) {
try {
email.send({
author: -5, // System user
recipients: ALERT_EMAIL,
subject: 'Integration Sync Failed - ' + recordType + ' #' + recordId,
body: 'The following record failed to sync after ' + attempts + ' attempts:\n\n' +
'Queue ID: ' + queueId + '\n' +
'Record Type: ' + recordType + '\n' +
'Record ID: ' + recordId + '\n' +
'Attempts: ' + attempts + '\n' +
'Last Error: ' + errorMsg + '\n\n' +
'Please investigate and manually resolve.'
});
} catch (e) {
log.error('Email Failed', e.message);
}
}
function summarize(summary) {
var successCount = 0;
var failCount = 0;
summary.output.iterator().each(function(key, value) {
if (key === 'success') successCount++;
if (key === 'failed') failCount++;
return true;
});
log.audit('Retry Processing Complete',
'Success: ' + successCount + ', Failed: ' + failCount);
}
return {
getInputData: getInputData,
map: map,
reduce: reduce,
summarize: summarize
};
});
Backoff Schedule
| Attempt | Delay | Total Time Since First Try |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | 1 minute | 1 min |
| 3 | 4 minutes | 5 min |
| 4 | 16 minutes | 21 min |
| 5 | 64 minutes | 1h 25min |
| Failed | Alert sent | - |
Monitoring Dashboard Saved Search
Create a saved search to monitor queue status:
Type: Custom Record - Integration Queue
Columns: Record ID, Record Type, Status, Attempts, Next Retry, Error, Created
Filters: Created within last 7 days
Summary: Count by Status
Script Selection Guide
| Need | Use This Script |
|---|---|
| Sync immediately when record saved | User Event (afterSubmit) |
| Sync only when specific field/status changes | Workflow Action Script |
| Sync periodically (every X minutes/hours) | Scheduled Script |
| Sync thousands of records | Map/Reduce Script |
| User clicks button to sync | Client Script → Suitelet |
| Handle failures with retry | User Event + Queue + Map/Reduce |