Skip to main content

Pattern 3: Receive

NetSuite is PASSIVE. External systems push data TO NetSuite.


How It Works

RECEIVE PATTERN
─────────────────────────────────────────────────────────────────

EXTERNAL SYSTEM NETSUITE
┌─────────────────┐ ┌─────────────────┐
│ │ HTTP POST │ │
│ Send Webhook │ ─────────────▶ │ RESTlet or │
│ (Event occurs) │ JSON │ Suitelet │
│ │ │ │
└─────────────────┘ │ Create/Update │
│ Records │
└─────────────────┘

Endpoint Types:
├── RESTlet: Requires OAuth authentication
└── Suitelet: Can be "Available Without Login"

Choosing Between RESTlet and Suitelet

FeatureRESTletSuitelet
AuthenticationOAuth 1.0 or 2.0 requiredCan be public
Best forTrusted integrationsWebhooks from any source
SecurityBuilt-inMust implement signature validation
HTTP MethodsGET, POST, PUT, DELETEGET, POST only
URL FormatAccount-specificGeneric NetSuite URL

When to Use

ScenarioCallerScriptAuth Required?
Payment confirmationStripeRESTletYes (OAuth)
Order from e-commerceShopifyRESTletYes (OAuth)
Shipping updateFedExSuiteletNo (public)
Form submissionWebsiteSuiteletNo (public)
CRM contact syncSalesforceRESTletYes (OAuth)

Scenario A: RESTlet Endpoint (Authenticated)

Case: Receive payment confirmation from Stripe and update invoice status.

FLOW
─────────────────────────────────────────────────────────────────

Stripe Payment Succeeds


┌─────────────────┐
│ Stripe sends │
│ webhook to │
│ NetSuite RESTlet│
└────────┬────────┘
│ POST (with OAuth)

┌─────────────────┐
│ RESTlet │
│ validates & │
│ processes │
└────────┬────────┘


┌─────────────────┐
│ Create Customer │
│ Payment record │
└─────────────────┘

Script: RESTlet

/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @description Receive payment webhook from Stripe
*/
define(['N/record', 'N/search', 'N/log'], function(record, search, log) {

/**
* POST: Receive payment confirmation
*/
function post(requestBody) {
log.debug('Webhook Received', JSON.stringify(requestBody));

// Validate webhook (in production, verify Stripe signature)
if (!requestBody.type || requestBody.type !== 'payment_intent.succeeded') {
return { success: false, error: 'Invalid event type' };
}

var paymentIntent = requestBody.data.object;
var invoiceNumber = paymentIntent.metadata.invoice_number;

// Find the invoice
var invoiceId = findInvoice(invoiceNumber);
if (!invoiceId) {
return { success: false, error: 'Invoice not found: ' + invoiceNumber };
}

try {
// Create customer payment
var payment = record.transform({
fromType: record.Type.INVOICE,
fromId: invoiceId,
toType: record.Type.CUSTOMER_PAYMENT
});

payment.setValue('payment', paymentIntent.amount / 100); // Stripe uses cents
payment.setValue('memo', 'Stripe Payment: ' + paymentIntent.id);
payment.setValue('custbody_stripe_payment_id', paymentIntent.id);

var paymentId = payment.save();

log.audit('Payment Created', 'Invoice ' + invoiceNumber + ' → Payment ' + paymentId);

return {
success: true,
paymentId: paymentId,
invoiceId: invoiceId
};

} catch (e) {
log.error('Payment Failed', e.message);
return { success: false, error: e.message };
}
}

/**
* GET: Health check or lookup
*/
function get(requestParams) {
return { status: 'OK', timestamp: new Date().toISOString() };
}

function findInvoice(invoiceNumber) {
var results = search.create({
type: search.Type.INVOICE,
filters: [['tranid', 'is', invoiceNumber]]
}).run().getRange({ start: 0, end: 1 });
return results.length > 0 ? results[0].id : null;
}

return {
get: get,
post: post
};
});

Calling the RESTlet (from external system):

curl -X POST \
'https://ACCOUNT_ID.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=123&deploy=1' \
-H 'Content-Type: application/json' \
-H 'Authorization: OAuth oauth_consumer_key="...",oauth_token="...",oauth_signature="..."' \
-d '{
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_123",
"amount": 10000,
"metadata": { "invoice_number": "INV-001" }
}
}
}'

Scenario B: Public Suitelet (No Auth)

Case: Receive shipping updates from carrier without authentication.

FLOW
─────────────────────────────────────────────────────────────────

Carrier (FedEx, UPS, etc)

│ POST webhook (no auth)

┌─────────────────┐
│ Suitelet │
│ (Public URL) │
└────────┬────────┘

│ Validate signature

┌─────────────────┐
│ Update Item │
│ Fulfillment │
│ record │
└─────────────────┘

Script: Suitelet

/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* @description Public webhook endpoint for shipping updates
*/
define(['N/record', 'N/search', 'N/log', 'N/crypto', 'N/encode'], function(record, search, log, crypto, encode) {

const WEBHOOK_SECRET = 'your-webhook-secret';

function onRequest(context) {
// Only accept POST
if (context.request.method !== 'POST') {
context.response.setHeader({ name: 'Content-Type', value: 'application/json' });
context.response.write(JSON.stringify({ error: 'Method not allowed' }));
return;
}

var body = context.request.body;
var signature = context.request.headers['x-webhook-signature'];

// Validate webhook signature (IMPORTANT for security!)
if (!validateSignature(body, signature)) {
log.error('Invalid Signature', 'Webhook rejected');
context.response.write(JSON.stringify({ error: 'Invalid signature' }));
return;
}

var payload = JSON.parse(body);
log.debug('Shipping Update', JSON.stringify(payload));

try {
// Find fulfillment by tracking number
var fulfillmentId = findFulfillment(payload.tracking_number);

if (fulfillmentId) {
// Update fulfillment status
record.submitFields({
type: record.Type.ITEM_FULFILLMENT,
id: fulfillmentId,
values: {
'custbody_shipping_status': payload.status,
'custbody_delivery_date': payload.delivered_at || ''
}
});

log.audit('Updated', 'Fulfillment ' + fulfillmentId);
}

context.response.write(JSON.stringify({ received: true }));

} catch (e) {
log.error('Error', e.message);
context.response.write(JSON.stringify({ error: e.message }));
}
}

function validateSignature(body, signature) {
// Implement HMAC validation
var hmac = crypto.createHmac({
algorithm: crypto.HashAlg.SHA256,
key: crypto.createSecretKey({ secret: WEBHOOK_SECRET })
});
hmac.update({ input: body });
var computedSignature = encode.convert({
string: hmac.digest(),
inputEncoding: encode.Encoding.BASE_64,
outputEncoding: encode.Encoding.HEX
});
return computedSignature === signature;
}

function findFulfillment(trackingNumber) {
var results = search.create({
type: search.Type.ITEM_FULFILLMENT,
filters: [['trackingnumbers', 'contains', trackingNumber]]
}).run().getRange({ start: 0, end: 1 });
return results.length > 0 ? results[0].id : null;
}

return { onRequest: onRequest };
});

Deployment:

  • Available Without Login: Yes
  • Execute As: Role with appropriate permissions

Scenario C: Queue Pattern for High Volume

Case: Receive high-volume webhooks, queue them, process asynchronously.

QUEUE PATTERN
─────────────────────────────────────────────────────────────────

External System

│ (hundreds of requests)

┌─────────────────┐
│ RESTlet │ ← Fast response (< 1 second)
│ (Receiver) │ Just queue the request
└────────┬────────┘
│ Create custom record

┌─────────────────┐
│ Queue Record │ ← Custom record stores payload
│ (Custom Record) │ Status: PENDING
└────────┬────────┘
│ Processed by

┌─────────────────┐
│ Map/Reduce │ ← Runs every 15 minutes
│ (Processor) │ Processes pending items
└────────┬────────┘


┌─────────────────┐
│ Business Logic │ ← Create/update records
│ (Records) │ Update queue status: PROCESSED
└─────────────────┘

RESTlet (Fast Queue):

/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @description Fast queue receiver - just store and respond
*/
define(['N/record', 'N/log'], function(record, log) {

function post(requestBody) {
try {
// Create queue record (fast operation)
var queue = record.create({ type: 'customrecord_webhook_queue' });
queue.setValue('custrecord_wq_payload', JSON.stringify(requestBody));
queue.setValue('custrecord_wq_status', 'PENDING');
queue.setValue('custrecord_wq_received', new Date());
queue.setValue('custrecord_wq_source', requestBody.source || 'unknown');
var queueId = queue.save();

// Return immediately
return { success: true, queued: true, queueId: queueId };

} catch (e) {
log.error('Queue Failed', e.message);
return { success: false, error: e.message };
}
}

return { post: post };
});

Map/Reduce (Processor):

/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
* @description Process queued webhooks
*/
define(['N/record', 'N/search', 'N/log'], function(record, search, log) {

function getInputData() {
return search.create({
type: 'customrecord_webhook_queue',
filters: [['custrecord_wq_status', 'is', 'PENDING']],
columns: ['custrecord_wq_payload', 'custrecord_wq_source']
});
}

function map(context) {
var result = JSON.parse(context.value);
var queueId = result.id;
var payload = JSON.parse(result.values.custrecord_wq_payload);

try {
// Process the payload (your business logic)
processWebhook(payload);

// Mark as processed
record.submitFields({
type: 'customrecord_webhook_queue',
id: queueId,
values: {
'custrecord_wq_status': 'PROCESSED',
'custrecord_wq_processed': new Date()
}
});

} catch (e) {
// Mark as failed
record.submitFields({
type: 'customrecord_webhook_queue',
id: queueId,
values: {
'custrecord_wq_status': 'FAILED',
'custrecord_wq_error': e.message
}
});
}
}

function processWebhook(payload) {
// Your actual business logic here
// Create orders, update records, etc.
}

function summarize(summary) {
log.audit('Queue Processing Complete',
'Processed: ' + summary.mapSummary.keys.length);
}

return { getInputData: getInputData, map: map, summarize: summarize };
});

Queue Custom Record Structure

Create a custom record customrecord_webhook_queue with these fields:

Field IDTypePurpose
custrecord_wq_payloadLong TextJSON payload from webhook
custrecord_wq_statusListPENDING, PROCESSED, FAILED
custrecord_wq_receivedDate/TimeWhen received
custrecord_wq_processedDate/TimeWhen processed
custrecord_wq_sourceTextSource system identifier
custrecord_wq_errorTextError message if failed
custrecord_wq_attemptsIntegerRetry count

Security Best Practices

For Public Suitelets

  1. Always validate signatures:
function validateSignature(body, signature, secret) {
var hmac = crypto.createHmac({
algorithm: crypto.HashAlg.SHA256,
key: crypto.createSecretKey({ secret: secret })
});
hmac.update({ input: body });
return hmac.digest() === signature;
}
  1. Validate source IP (if known):
var allowedIPs = ['192.168.1.1', '10.0.0.1'];
var sourceIP = context.request.headers['x-forwarded-for'];
if (allowedIPs.indexOf(sourceIP) === -1) {
throw new Error('Unauthorized IP');
}
  1. Rate limit by source:
// Check recent requests from same source
var recentCount = search.create({
type: 'customrecord_webhook_queue',
filters: [
['custrecord_wq_source', 'is', source],
'AND',
['created', 'within', 'lasthour']
]
}).runPaged().count;

if (recentCount > 100) {
throw new Error('Rate limit exceeded');
}

Script Selection Guide

NeedUse This Script
Authenticated webhookRESTlet
Public webhook (no OAuth)Suitelet (Available Without Login)
High-volume webhooksRESTlet/Suitelet + Queue + Map/Reduce
Simple webhook with immediate responseRESTlet or Suitelet

  • Push Out - Send data to external systems
  • Expose - Let external systems query NetSuite