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
| Feature | RESTlet | Suitelet |
|---|---|---|
| Authentication | OAuth 1.0 or 2.0 required | Can be public |
| Best for | Trusted integrations | Webhooks from any source |
| Security | Built-in | Must implement signature validation |
| HTTP Methods | GET, POST, PUT, DELETE | GET, POST only |
| URL Format | Account-specific | Generic NetSuite URL |
When to Use
| Scenario | Caller | Script | Auth Required? |
|---|---|---|---|
| Payment confirmation | Stripe | RESTlet | Yes (OAuth) |
| Order from e-commerce | Shopify | RESTlet | Yes (OAuth) |
| Shipping update | FedEx | Suitelet | No (public) |
| Form submission | Website | Suitelet | No (public) |
| CRM contact sync | Salesforce | RESTlet | Yes (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 ID | Type | Purpose |
|---|---|---|
custrecord_wq_payload | Long Text | JSON payload from webhook |
custrecord_wq_status | List | PENDING, PROCESSED, FAILED |
custrecord_wq_received | Date/Time | When received |
custrecord_wq_processed | Date/Time | When processed |
custrecord_wq_source | Text | Source system identifier |
custrecord_wq_error | Text | Error message if failed |
custrecord_wq_attempts | Integer | Retry count |
Security Best Practices
For Public Suitelets
- 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;
}
- 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');
}
- 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
| Need | Use This Script |
|---|---|
| Authenticated webhook | RESTlet |
| Public webhook (no OAuth) | Suitelet (Available Without Login) |
| High-volume webhooks | RESTlet/Suitelet + Queue + Map/Reduce |
| Simple webhook with immediate response | RESTlet or Suitelet |