Production-tested NetSuite RESTlet API design for external system integration. Demonstrates sophisticated error handling, input validation, idempotent upserts, and external ID matching patterns used in enterprise field service integrations.
This pattern implements a complete RESTlet API suite for field service management integration. It showcases:
- 6 RESTlet endpoints — Customer, Job, Sales Order, Fulfillment, Invoice, Payment
- Shared validation framework — Structured input validation with custom rules
- Consistent error handling — Standardized error responses across all endpoints
- External ID matching — Idempotent upsert pattern with caching
- Postman collection — Ready-to-import API collection with examples
- Comprehensive tests — 15+ Jest tests with NetSuite module mocks
Based on a field service integration that processes 10,000+ transactions monthly across 6 synchronized transaction types. See the case study
- External system integration — Sync field service software with NetSuite
- Mobile app backend — Provide API layer for custom mobile applications
- Middleware integration — Connect NetSuite to iPaaS platforms (Celigo, Workato)
- Partner portals — Enable external partners to create orders and payments
- IoT data ingestion — Receive sensor data and create service records
| Endpoint | Methods | Purpose |
|---|---|---|
fs_customer_rl.js |
GET, POST, PUT | Customer lookup, creation, and upsert |
fs_job_rl.js |
GET, POST, PUT | Job/work order management with status tracking |
fs_sales_order_rl.js |
POST | Create sales orders with multi-line items |
fs_fulfillment_rl.js |
POST | Item fulfillment with partial fulfillment support |
fs_invoice_rl.js |
POST | Invoice generation from fulfilled orders |
fs_payment_rl.js |
POST | Customer payment application with overpayment handling |
| Library | Purpose |
|---|---|
lib/validation.js |
Input validation framework (required fields, types, custom rules) |
lib/error_handler.js |
Consistent error response formatting |
lib/external_id_matcher.js |
External ID to internal ID matching with caching |
Comprehensive validation with structured error responses:
const schema = {
required: ['customer_id', 'amount'],
types: {
customer_id: 'string',
amount: 'number',
email: 'email'
},
rules: {
amount: function(val) { return val > 0; }
}
};
const validationErr = validation.validate(context, schema);
if (validationErr) return validationErr;Validation types supported:
string,number,boolean,array,objectdate— ISO 8601 or JavaScript date stringsemail— RFC-compliant email format
All endpoints return standardized error responses:
{
"success": false,
"code": "RECORD_NOT_FOUND",
"message": "Customer not found with external_id: FS-CUST-12345"
}Standard error codes:
MISSING_REQUIRED_FIELD— Required field not providedINVALID_FIELD_TYPE— Field type mismatchINVALID_FIELD_VALUE— Value fails validation ruleRECORD_NOT_FOUND— Record lookup failedDUPLICATE_RECORD— Record already exists (POST operations)UNEXPECTED_ERROR— NetSuite or JavaScript error
External ID matching prevents duplicate record creation:
// PUT endpoint - creates if not exists, updates if exists
function put(context) {
const existingId = externalIdMatcher.findByExternalId('customer', context.external_id);
if (existingId) {
// Update existing record
customerRec = record.load({ type: record.Type.CUSTOMER, id: existingId });
} else {
// Create new record
customerRec = record.create({ type: record.Type.CUSTOMER });
customerRec.setValue({ fieldId: 'externalid', value: context.external_id });
}
// Set fields and save
// ...
}Benefits:
- Safe retry logic for external systems
- Prevents duplicate records on API retry
- Cached lookups reduce governance usage (10 units → 0 units on cache hit)
External ID matcher includes in-memory cache:
// First call: 10 governance units (search)
const customerId = externalIdMatcher.findByExternalId('customer', 'FS-CUST-123');
// Subsequent calls: 0 governance units (cache hit)
const customerId2 = externalIdMatcher.findByExternalId('customer', 'FS-CUST-123');Cache features:
- In-memory cache per script execution
- Batch lookup for multiple IDs
- Optional cache bypass for testing
Request:
POST /app/site/hosting/restlet.nl?script=123&deploy=1
{
"external_id": "FS-CUST-12345",
"company_name": "Example Field Services LLC",
"email": "contact@example.com",
"phone": "555-1234",
"subsidiary_id": "1"
}Response:
{
"success": true,
"internal_id": "9876",
"external_id": "FS-CUST-12345",
"created": true,
"message": "Customer created successfully"
}Request:
PUT /app/site/hosting/restlet.nl?script=124&deploy=1
{
"external_id": "FS-JOB-98765",
"customer_external_id": "FS-CUST-12345",
"job_name": "Water Damage Restoration - 123 Main St",
"status": "in_progress",
"assigned_technician": "John Smith"
}Response:
{
"success": true,
"internal_id": "5432",
"external_id": "FS-JOB-98765",
"created": false,
"updated": true,
"message": "Job updated successfully"
}Request:
POST /app/site/hosting/restlet.nl?script=125&deploy=1
{
"external_id": "FS-SO-55555",
"customer_external_id": "FS-CUST-12345",
"job_external_id": "FS-JOB-98765",
"order_date": "2026-03-15",
"line_items": [
{
"item_id": "100",
"quantity": 5,
"rate": 49.99,
"description": "Water extraction equipment rental"
},
{
"item_id": "101",
"quantity": 10,
"rate": 12.50,
"description": "Dehumidifier rental (daily)"
}
]
}Response:
{
"success": true,
"internal_id": "SO-12345",
"external_id": "FS-SO-55555",
"created": true,
"line_count": 2,
"message": "Sales order created successfully"
}Request:
POST /app/site/hosting/restlet.nl?script=128&deploy=1
{
"customer_external_id": "FS-CUST-12345",
"payment_date": "2026-03-18",
"amount": 1500.00,
"payment_method": "Check",
"reference_number": "CHK-9877",
"invoice_external_id": "FS-INV-11111"
}Response (Invoice balance was $1,250):
{
"success": true,
"internal_id": "PMT-999",
"customer_id": "9876",
"invoice_id": "INV-123",
"payment_amount": 1500.00,
"applied_amount": 1250.00,
"unapplied_amount": 250.00,
"created": true,
"message": "Customer payment created successfully (unapplied balance remains)"
}NetSuite RESTlets use OAuth 1.0 (Token-Based Authentication) or OAuth 2.0.
-
Enable Token-Based Authentication
- Setup → Company → Enable Features → SuiteCloud → Token-Based Authentication
-
Create Integration Record
- Setup → Integration → Manage Integrations → New
- Save the Consumer Key and Consumer Secret
-
Generate Access Token
- Setup → Users/Roles → Access Tokens → New
- Select the integration and user
- Save the Token ID and Token Secret
-
Configure Postman
- Import the Postman collection from
postman/netsuite_api_collection.json - Set environment variables:
account_id— Your NetSuite account IDconsumer_key,consumer_secret— From integration recordtoken,token_secret— From access tokencustomer_restlet_id,job_restlet_id, etc. — RESTlet script IDs
- Import the Postman collection from
Refer to NetSuite's OAuth 2.0 documentation for setup instructions.
-
Copy files to your SDF project:
src/FileCabinet/SuiteScripts/field_service_api/ ├── fs_customer_rl.js ├── fs_job_rl.js ├── fs_sales_order_rl.js ├── fs_fulfillment_rl.js ├── fs_invoice_rl.js ├── fs_payment_rl.js └── lib/ ├── validation.js ├── error_handler.js └── external_id_matcher.js -
Deploy via SDF CLI:
sdf deploy -p
-
Create script records in NetSuite for each RESTlet
-
Create deployments for each script
- Upload files via Setup → SuiteBundles → Search & Install Bundles → File Cabinet
- Navigate to Customization → Scripting → Scripts → New
- Create a new RESTlet script for each endpoint
- Set the script file path
- Create deployments and note the script IDs
Typical governance consumption per endpoint:
| Endpoint | Typical Units | Notes |
|---|---|---|
| Customer GET | 10-20 | Search (10) + record.load (10) |
| Customer POST | 20 | Search (10) + record.create (10) |
| Sales Order POST | 20 + (5 × lines) | Transform adds 5 units per line |
| Payment POST | 30-40 | Multiple record operations |
Optimization tips:
- Cache external ID lookups (reduces 10 units to 0)
- Batch operations where possible
- Use
isDynamic: falsefor better performance
NetSuite enforces concurrency limits:
- Standard — 10 concurrent RESTlet requests
- Premium — 25 concurrent RESTlet requests
Recommendation: Implement queue-based integration on the external system side.
Always validate input before performing any NetSuite operations:
// Validate required fields first
const validationErr = validation.validateRequired(context, ['customer_id', 'amount']);
if (validationErr) return validationErr;
// Then validate types
const typeErr = validation.validateTypes(context, { amount: 'number' });
if (typeErr) return typeErr;
// Finally, perform business logicReturn machine-readable error codes for integration error handling:
if (!customerId) {
return errorHandler.createError(
errorHandler.ErrorCode.RECORD_NOT_FOUND,
'Customer not found with external_id: ' + context.external_id
);
}Include operation context in error logs:
return errorHandler.formatError(err, 'fs_customer_rl.post');This creates logs like:
ERROR: fs_customer_rl.post - NetSuite Error
Details: {name: 'RCRD_DSNT_EXIST', message: 'Record does not exist', ...}
Run the test suite:
npm test- ✅ Validation framework — 12 tests (required fields, types, custom rules)
- ✅ Error handler — 8 tests (success/error formatting, NetSuite errors)
- ✅ External ID matcher — 10 tests (find, create, batch, cache)
- ✅ Customer RESTlet — 8 tests (GET, POST, PUT operations)
Total: 38 tests
Tests use custom NetSuite module mocks from shared/test_utils.js:
const { search, record } = require('../../../shared/test_utils');
// Mock search result
search.mockSearchResults([
{ id: '123', values: { externalid: 'EXT-123' } }
]);
// Mock record creation
record.mockRecordId('999');Cache hit rates significantly reduce governance usage:
// First execution: 10 units
const id1 = externalIdMatcher.findByExternalId('customer', 'EXT-123');
// Same execution: 0 units (cache hit)
const id2 = externalIdMatcher.findByExternalId('customer', 'EXT-123');
// Next execution: Cache cleared, 10 units againRecommendation: Structure your integration to batch operations per execution.
Use batch find for multiple external IDs:
// Instead of 3 separate searches (30 units):
const id1 = externalIdMatcher.findByExternalId('customer', 'EXT-1');
const id2 = externalIdMatcher.findByExternalId('customer', 'EXT-2');
const id3 = externalIdMatcher.findByExternalId('customer', 'EXT-3');
// Use batch find (10 units):
const idMap = externalIdMatcher.batchFind('customer', ['EXT-1', 'EXT-2', 'EXT-3']);Minimize record transformation overhead:
// Partial fulfillment (25 units)
const fulfillmentRec = record.transform({
fromType: record.Type.SALES_ORDER,
fromId: salesOrderId,
toType: record.Type.ITEM_FULFILLMENT
});
// Mark only specific lines for fulfillment (5 units per line)All user input is validated before database operations:
// Prevent SQL injection via search filters
const customerId = externalIdMatcher.findByExternalId('customer', externalId);
// Uses parameterized search filters internallyError messages never expose internal IDs to external systems unless explicitly included:
// Good: Controlled exposure
return errorHandler.createError(
errorHandler.ErrorCode.DUPLICATE_RECORD,
'Customer already exists',
{ internal_id: existingId } // Explicitly included
);
// Bad: Unintentional exposure avoided by error handler
throw new Error('Record 12345 failed'); // Internal ID hidden in responseRESTlet deployments should restrict access:
- Role Restrictions — Limit to integration-specific roles
- Authentication — Require OAuth token authentication
- IP Restrictions — Whitelist external system IP addresses (if applicable)
- Create RESTlet file (e.g.,
fs_vendor_rl.js) - Import shared libraries:
define(['N/record', './lib/validation', './lib/error_handler', './lib/external_id_matcher'], ...)
- Implement handlers (
get,post,put,delete) - Add validation schema
- Add tests in
test/vendor_rl.test.js - Update Postman collection
- Update
deploy.xml
Add custom field type validators in lib/validation.js:
const TypeValidators = {
// ... existing validators
phone: function(value) {
if (typeof value !== 'string') return false;
const phoneRegex = /^\d{3}-\d{4}$/;
return phoneRegex.test(value);
}
};Add business-specific error codes in lib/error_handler.js:
const ErrorCode = {
// ... existing codes
INSUFFICIENT_INVENTORY: 'INSUFFICIENT_INVENTORY',
INVALID_STATUS_TRANSITION: 'INVALID_STATUS_TRANSITION'
};Issue: RECORD_NOT_FOUND when external ID should exist
Solution: Check external ID field on the record. External IDs are case-sensitive.
Issue: INSUFFICIENT_PERMISSION error
Solution: Ensure the integration role has permissions for the record types being accessed (Customer, Job, Sales Order, etc.)
Issue: Governance limit exceeded
Solution: Reduce batch size or implement caching. Review governance usage in Execution Log.
Issue: Validation passing but record creation fails
Solution: Check mandatory fields in NetSuite. The validation schema may be incomplete.
Issue: Cache not working across requests
Solution: Cache is per-execution, not per-deployment. External systems should batch operations.
- Config-Driven Suitelet — Dynamic UI generation from config
- Scheduled Search Processor (Coming soon) — Batch processing with governance management
- Map/Reduce Template (Coming soon) — Large dataset processing
Found a bug or have a suggestion? Open an issue
Need help implementing this pattern? Book a consultation
MIT — Use this pattern freely in your NetSuite projects.