NewNetSuite 2025.2 — What's new
intermediateSuiteScript25 min read

Building RESTlets in NetSuite: API Development Guide

Learn to create RESTlets in SuiteScript 2.1 for custom API endpoints. Handle GET, POST, PUT, and DELETE requests with authentication and error handling.

Prerequisites

  • SuiteScript 2.1 basics
  • Understanding of REST APIs
  • NetSuite developer account
SuiteScriptRESTletAPINetSuite DevelopmentIntegration

RESTlets are the backbone of custom API development in NetSuite. They let you expose NetSuite data and business logic through HTTP endpoints that external systems can consume. If you need to build an integration that goes beyond what standard web services offer, RESTlets are the tool for the job.

What Are RESTlets?

RESTlets are SuiteScript-powered HTTP endpoints deployed inside NetSuite. Unlike Suitelets (which serve HTML pages), RESTlets are designed specifically for machine-to-machine communication. They accept and return data in JSON or plain text, making them ideal for:

  • External system integrations (e-commerce platforms, CRMs, shipping providers)
  • Mobile application backends
  • Custom API layers for third-party developers
  • Webhook receivers from external services
  • Middleware connectors that bridge NetSuite with other platforms

RESTlets vs. Other Integration Options

FeatureRESTletsSOAP Web ServicesSuiteTalk RESTSuitelets
Custom logicFull SuiteScriptLimitedLimitedFull SuiteScript
Response formatJSON / TextXML (SOAP)JSONHTML / JSON
AuthenticationTBA / OAuthTBA / OAuthOAuth 2.0Session / Token
Governance5,000 unitsN/AN/A1,000 units
Use caseCustom APIsStandard CRUDStandard CRUDUI pages

The higher governance limit (5,000 units vs. 1,000 for Suitelets) makes RESTlets well-suited for complex operations that touch multiple records.

Basic RESTlet Structure

A RESTlet can define up to four entry points, each corresponding to an HTTP method:

/**
 * @NApiVersion 2.1
 * @NScriptType Restlet
 */
define([], () => {
 
  /**
   * Handles GET requests - retrieve data
   * @param {Object} requestParams - URL query parameters
   * @returns {Object|string} Response data
   */
  const get = (requestParams) => {
    return { message: 'GET response', params: requestParams };
  };
 
  /**
   * Handles POST requests - create new resources
   * @param {Object} requestBody - Parsed JSON request body
   * @returns {Object|string} Response data
   */
  const post = (requestBody) => {
    return { message: 'POST response', received: requestBody };
  };
 
  /**
   * Handles PUT requests - update existing resources
   * @param {Object} requestBody - Parsed JSON request body
   * @returns {Object|string} Response data
   */
  const put = (requestBody) => {
    return { message: 'PUT response', updated: requestBody };
  };
 
  /**
   * Handles DELETE requests - remove resources
   * @param {Object} requestParams - URL query parameters
   * @returns {Object|string} Response data
   */
  const doDelete = (requestParams) => {
    return { message: 'DELETE response', deleted: requestParams };
  };
 
  return { get, post, put, delete: doDelete };
});

A few things to note about this structure:

  • GET and DELETE receive URL query parameters as a flat object (e.g., { id: '123' })
  • POST and PUT receive the parsed JSON request body as an object
  • Return values are automatically serialized to JSON when you return an object
  • The delete keyword is reserved in JavaScript, so the convention is to name the function doDelete and map it in the return statement

Authentication Methods

RESTlets require authentication on every request. NetSuite supports two primary methods.

Token-Based Authentication (TBA)

TBA is the recommended approach for server-to-server integrations. It uses OAuth 1.0 signatures with four tokens:

  1. Consumer Key and Consumer Secret (from the integration record)
  2. Token ID and Token Secret (from a user-specific access token)

To set up TBA:

  1. Navigate to Setup > Company > Enable Features > SuiteCloud and enable Token-Based Authentication
  2. Create an Integration Record at Setup > Integration > Manage Integrations > New
  3. Record the Consumer Key and Consumer Secret (shown only once)
  4. Create an Access Token at Setup > Users/Roles > Access Tokens > New
  5. Record the Token ID and Token Secret (shown only once)

The external client must sign each request with an OAuth 1.0 header:

Authorization: OAuth
  realm="ACCOUNT_ID",
  oauth_consumer_key="CONSUMER_KEY",
  oauth_token="TOKEN_ID",
  oauth_nonce="UNIQUE_NONCE",
  oauth_timestamp="UNIX_TIMESTAMP",
  oauth_signature_method="HMAC-SHA256",
  oauth_version="1.0",
  oauth_signature="COMPUTED_SIGNATURE"

OAuth 2.0 (Client Credentials)

NetSuite also supports OAuth 2.0 with the Client Credentials flow, which is simpler for machine-to-machine scenarios:

  1. Register an OAuth 2.0 client in NetSuite under Setup > Integration > Manage Integrations
  2. Request an access token from the token endpoint
  3. Include the Bearer token in subsequent requests
POST https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=CLIENT_ID
&client_secret=CLIENT_SECRET

Then use the access token:

Authorization: Bearer ACCESS_TOKEN

Request and Response Handling

Returning JSON Responses

RESTlets automatically serialize objects to JSON. Structure your responses consistently:

/**
 * @NApiVersion 2.1
 * @NScriptType Restlet
 */
define(['N/record', 'N/search', 'N/log'], (record, search, log) => {
 
  /**
   * Standard response wrapper for consistent API output
   * @param {boolean} success - Whether the operation succeeded
   * @param {Object|Array|null} data - Response payload
   * @param {string|null} errorMessage - Error details if applicable
   * @returns {Object} Formatted response
   */
  const buildResponse = (success, data, errorMessage) => {
    return {
      success: success,
      timestamp: new Date().toISOString(),
      data: data || null,
      error: errorMessage || null
    };
  };
 
  /**
   * GET: Retrieve a customer record by internal ID
   */
  const get = (requestParams) => {
    const { id, fields } = requestParams;
 
    if (!id) {
      return buildResponse(false, null, 'Missing required parameter: id');
    }
 
    try {
      const customerRecord = record.load({
        type: record.Type.CUSTOMER,
        id: parseInt(id, 10),
        isDynamic: false
      });
 
      // Return specific fields or defaults
      const requestedFields = fields ? fields.split(',') : [
        'companyname', 'email', 'phone', 'entitystatus'
      ];
 
      const customerData = { id: customerRecord.id };
      requestedFields.forEach((fieldId) => {
        customerData[fieldId.trim()] = customerRecord.getValue({
          fieldId: fieldId.trim()
        });
      });
 
      return buildResponse(true, customerData);
 
    } catch (e) {
      log.error('GET Customer Error', e.message);
      return buildResponse(false, null, e.message);
    }
  };
 
  return { get };
});

Handling POST Data

POST requests deliver the parsed JSON body directly to your function. Use this for creating records:

/**
 * POST: Create a new sales order from external system data
 * @param {Object} requestBody - Order data from external system
 * @returns {Object} Created order details
 */
const post = (requestBody) => {
  log.audit('POST Received', JSON.stringify(requestBody));
 
  // Validate the incoming payload
  const validation = validateOrderPayload(requestBody);
  if (!validation.valid) {
    return buildResponse(false, null, validation.errors.join('; '));
  }
 
  try {
    const salesOrder = record.create({
      type: record.Type.SALES_ORDER,
      isDynamic: true
    });
 
    // Set header fields
    salesOrder.setValue({ fieldId: 'entity', value: parseInt(requestBody.customerId, 10) });
    salesOrder.setValue({ fieldId: 'memo', value: requestBody.memo || '' });
    salesOrder.setValue({ fieldId: 'otherrefnum', value: requestBody.externalOrderId || '' });
 
    // Add line items
    requestBody.items.forEach((item) => {
      salesOrder.selectNewLine({ sublistId: 'item' });
      salesOrder.setCurrentSublistValue({
        sublistId: 'item',
        fieldId: 'item',
        value: parseInt(item.itemId, 10)
      });
      salesOrder.setCurrentSublistValue({
        sublistId: 'item',
        fieldId: 'quantity',
        value: item.quantity
      });
      if (item.rate) {
        salesOrder.setCurrentSublistValue({
          sublistId: 'item',
          fieldId: 'rate',
          value: item.rate
        });
      }
      salesOrder.commitLine({ sublistId: 'item' });
    });
 
    const orderId = salesOrder.save();
 
    log.audit('Sales Order Created', `ID: ${orderId}`);
 
    return buildResponse(true, {
      orderId: orderId,
      externalOrderId: requestBody.externalOrderId,
      status: 'created'
    });
 
  } catch (e) {
    log.error('POST Order Error', e.message);
    return buildResponse(false, null, `Failed to create order: ${e.message}`);
  }
};

Input Validation

Never trust incoming data. Validate everything before it touches your business logic:

/**
 * Validate the incoming order payload structure and data types
 * @param {Object} payload - Request body to validate
 * @returns {Object} Validation result { valid: boolean, errors: string[] }
 */
const validateOrderPayload = (payload) => {
  const errors = [];
 
  // Required fields
  if (!payload.customerId) {
    errors.push('customerId is required');
  } else if (isNaN(parseInt(payload.customerId, 10))) {
    errors.push('customerId must be a valid number');
  }
 
  if (!payload.items || !Array.isArray(payload.items)) {
    errors.push('items must be a non-empty array');
  } else if (payload.items.length === 0) {
    errors.push('At least one line item is required');
  } else {
    payload.items.forEach((item, index) => {
      if (!item.itemId) {
        errors.push(`items[${index}].itemId is required`);
      }
      if (!item.quantity || item.quantity <= 0) {
        errors.push(`items[${index}].quantity must be greater than 0`);
      }
      if (item.rate !== undefined && item.rate < 0) {
        errors.push(`items[${index}].rate cannot be negative`);
      }
    });
  }
 
  // Optional field validation
  if (payload.memo && typeof payload.memo !== 'string') {
    errors.push('memo must be a string');
  }
 
  if (payload.memo && payload.memo.length > 999) {
    errors.push('memo cannot exceed 999 characters');
  }
 
  return {
    valid: errors.length === 0,
    errors: errors
  };
};

Sanitizing Input

Beyond structural validation, sanitize values to prevent injection and data corruption:

/**
 * Sanitize a string value for safe use in NetSuite fields
 * @param {string} value - Raw input string
 * @param {number} maxLength - Maximum allowed length
 * @returns {string} Sanitized string
 */
const sanitizeString = (value, maxLength) => {
  if (typeof value !== 'string') return '';
  return value
    .replace(/<[^>]*>/g, '')    // Strip HTML tags
    .replace(/[\x00-\x1F]/g, '') // Remove control characters
    .trim()
    .substring(0, maxLength);
};

Error Handling Patterns

Robust error handling is critical for any production API. RESTlets should never return unhandled exceptions to callers.

Structured Error Handling

/**
 * @NApiVersion 2.1
 * @NScriptType Restlet
 */
define(['N/record', 'N/error', 'N/log'], (record, error, log) => {
 
  /**
   * Custom error class for API-specific errors
   * @param {string} code - Machine-readable error code
   * @param {string} message - Human-readable error message
   * @param {number} httpStatus - Suggested HTTP status
   */
  const ApiError = function(code, message, httpStatus) {
    this.code = code;
    this.message = message;
    this.httpStatus = httpStatus || 400;
  };
 
  /**
   * Wrap entry point logic with consistent error handling
   * @param {Function} handler - The business logic function
   * @returns {Function} Wrapped handler with error catching
   */
  const withErrorHandling = (handler) => {
    return (input) => {
      try {
        return handler(input);
      } catch (e) {
        // Handle our custom API errors
        if (e instanceof ApiError) {
          log.error(`API Error [${e.code}]`, e.message);
          return {
            success: false,
            error: {
              code: e.code,
              message: e.message
            }
          };
        }
 
        // Handle NetSuite-specific errors
        if (e.name && e.id) {
          log.error(`NetSuite Error [${e.name}]`, e.message);
          return {
            success: false,
            error: {
              code: e.name,
              message: e.message,
              details: e.id
            }
          };
        }
 
        // Handle unexpected errors - don't expose internals
        log.error('Unexpected Error', {
          message: e.message,
          stack: e.stack
        });
        return {
          success: false,
          error: {
            code: 'INTERNAL_ERROR',
            message: 'An unexpected error occurred. Please contact support.'
          }
        };
      }
    };
  };
 
  const getHandler = (requestParams) => {
    const { id } = requestParams;
 
    if (!id) {
      throw new ApiError('MISSING_PARAMETER', 'The id parameter is required');
    }
 
    const rec = record.load({
      type: record.Type.CUSTOMER,
      id: parseInt(id, 10)
    });
 
    return {
      success: true,
      data: {
        id: rec.id,
        name: rec.getValue({ fieldId: 'companyname' }),
        email: rec.getValue({ fieldId: 'email' })
      }
    };
  };
 
  const deleteHandler = (requestParams) => {
    const { id, type } = requestParams;
 
    if (!id || !type) {
      throw new ApiError('MISSING_PARAMETER', 'Both id and type parameters are required');
    }
 
    // Whitelist allowed record types for safety
    const allowedTypes = ['customrecord_api_log', 'customrecord_temp_data'];
    if (!allowedTypes.includes(type)) {
      throw new ApiError(
        'FORBIDDEN_TYPE',
        `Deletion of record type '${type}' is not permitted through this API`
      );
    }
 
    record.delete({ type: type, id: parseInt(id, 10) });
 
    return {
      success: true,
      data: { id: id, type: type, status: 'deleted' }
    };
  };
 
  return {
    get: withErrorHandling(getHandler),
    delete: withErrorHandling(deleteHandler)
  };
});

HTTP Status Codes

By default, a RESTlet returns 200 for any response and throws a 400-series error only on unhandled exceptions. To signal errors to callers within your JSON response, use a consistent success boolean and error object pattern as shown above. The calling system should check response.success rather than relying solely on the HTTP status code.

If you need the RESTlet to return an actual non-200 HTTP status, throw a standard N/error with a specific error code:

// This causes NetSuite to return a 400-level response
throw error.create({
  name: 'INVALID_REQUEST',
  message: 'The request payload is malformed',
  notifyOff: true
});

Practical Example: External Integration API

Here is a complete RESTlet that serves as an integration layer between an external e-commerce platform and NetSuite. It supports looking up items, creating orders, updating order status, and cancelling orders.

/**
 * @NApiVersion 2.1
 * @NScriptType Restlet
 * @NModuleScope SameAccount
 *
 * E-Commerce Integration RESTlet
 * Provides endpoints for external platforms to interact with
 * NetSuite orders and inventory.
 */
define(['N/record', 'N/search', 'N/log', 'N/error', 'N/runtime'],
  (record, search, log, error, runtime) => {
 
  // ─── Configuration ────────────────────────────────────────
  const CONFIG = {
    MAX_ITEMS_PER_REQUEST: 50,
    ALLOWED_ORDER_STATUSES: ['pendingApproval', 'pendingFulfillment', 'closed'],
    LOG_PREFIX: 'EcomIntegration'
  };
 
  // ─── Helpers ──────────────────────────────────────────────
 
  /**
   * Build a consistent API response
   */
  const respond = (success, data, errorInfo) => {
    return {
      success: success,
      timestamp: new Date().toISOString(),
      scriptUsage: runtime.getCurrentScript().getRemainingUsage(),
      data: data || null,
      error: errorInfo || null
    };
  };
 
  /**
   * Look up a customer by external ID
   * @param {string} externalId - External system customer ID
   * @returns {number|null} NetSuite internal ID
   */
  const findCustomerByExternalId = (externalId) => {
    const results = search.create({
      type: search.Type.CUSTOMER,
      filters: [
        ['externalid', 'is', externalId]
      ],
      columns: ['internalid']
    }).run().getRange({ start: 0, end: 1 });
 
    return results.length > 0
      ? results[0].getValue({ name: 'internalid' })
      : null;
  };
 
  /**
   * Look up an item by SKU
   * @param {string} sku - Item SKU
   * @returns {Object|null} Item details
   */
  const findItemBySku = (sku) => {
    const results = search.create({
      type: search.Type.ITEM,
      filters: [
        ['itemid', 'is', sku],
        'AND',
        ['isinactive', 'is', 'F']
      ],
      columns: [
        'internalid', 'itemid', 'displayname',
        'baseprice', 'quantityavailable'
      ]
    }).run().getRange({ start: 0, end: 1 });
 
    if (results.length === 0) return null;
 
    const result = results[0];
    return {
      internalId: result.getValue({ name: 'internalid' }),
      sku: result.getValue({ name: 'itemid' }),
      name: result.getValue({ name: 'displayname' }),
      price: parseFloat(result.getValue({ name: 'baseprice' })) || 0,
      quantityAvailable: parseInt(result.getValue({ name: 'quantityavailable' }), 10) || 0
    };
  };
 
  // ─── GET: Retrieve inventory or order data ────────────────
 
  /**
   * Handle GET requests
   * Supported actions:
   *   ?action=inventory&sku=ITEM-SKU
   *   ?action=order&id=ORDER_ID
   *   ?action=inventory_bulk&skus=SKU1,SKU2,SKU3
   */
  const get = (requestParams) => {
    const { action } = requestParams;
 
    log.audit(`${CONFIG.LOG_PREFIX} GET`, `Action: ${action}`);
 
    switch (action) {
      case 'inventory': {
        const { sku } = requestParams;
        if (!sku) return respond(false, null, 'Parameter sku is required');
 
        const item = findItemBySku(sku);
        if (!item) return respond(false, null, `Item not found: ${sku}`);
 
        return respond(true, item);
      }
 
      case 'inventory_bulk': {
        const { skus } = requestParams;
        if (!skus) return respond(false, null, 'Parameter skus is required');
 
        const skuList = skus.split(',').slice(0, CONFIG.MAX_ITEMS_PER_REQUEST);
        const items = skuList.map((sku) => {
          const item = findItemBySku(sku.trim());
          return item || { sku: sku.trim(), error: 'Not found' };
        });
 
        return respond(true, { items: items, count: items.length });
      }
 
      case 'order': {
        const { id } = requestParams;
        if (!id) return respond(false, null, 'Parameter id is required');
 
        try {
          const so = record.load({
            type: record.Type.SALES_ORDER,
            id: parseInt(id, 10)
          });
 
          const lineCount = so.getLineCount({ sublistId: 'item' });
          const lines = [];
          for (let i = 0; i < lineCount; i++) {
            lines.push({
              item: so.getSublistText({ sublistId: 'item', fieldId: 'item', line: i }),
              quantity: so.getSublistValue({ sublistId: 'item', fieldId: 'quantity', line: i }),
              rate: so.getSublistValue({ sublistId: 'item', fieldId: 'rate', line: i }),
              amount: so.getSublistValue({ sublistId: 'item', fieldId: 'amount', line: i })
            });
          }
 
          return respond(true, {
            orderId: so.id,
            status: so.getText({ fieldId: 'orderstatus' }),
            customer: so.getText({ fieldId: 'entity' }),
            total: so.getValue({ fieldId: 'total' }),
            lines: lines
          });
 
        } catch (e) {
          return respond(false, null, `Order not found: ${e.message}`);
        }
      }
 
      default:
        return respond(false, null, `Unknown action: ${action}. Supported: inventory, inventory_bulk, order`);
    }
  };
 
  // ─── POST: Create new orders ──────────────────────────────
 
  /**
   * Handle POST requests - create a sales order
   * Expected body:
   * {
   *   "externalOrderId": "EXT-12345",
   *   "customerExternalId": "CUST-001",
   *   "items": [
   *     { "sku": "WIDGET-A", "quantity": 2, "rate": 29.99 },
   *     { "sku": "WIDGET-B", "quantity": 1 }
   *   ],
   *   "memo": "Order from web store",
   *   "shippingMethod": "fedexground"
   * }
   */
  const post = (requestBody) => {
    log.audit(`${CONFIG.LOG_PREFIX} POST`, JSON.stringify(requestBody));
 
    // Validate payload
    if (!requestBody.customerExternalId) {
      return respond(false, null, 'customerExternalId is required');
    }
    if (!requestBody.items || requestBody.items.length === 0) {
      return respond(false, null, 'At least one item is required');
    }
 
    // Resolve customer
    const customerId = findCustomerByExternalId(requestBody.customerExternalId);
    if (!customerId) {
      return respond(false, null, `Customer not found: ${requestBody.customerExternalId}`);
    }
 
    try {
      const salesOrder = record.create({
        type: record.Type.SALES_ORDER,
        isDynamic: true
      });
 
      salesOrder.setValue({ fieldId: 'entity', value: parseInt(customerId, 10) });
      salesOrder.setValue({ fieldId: 'otherrefnum', value: requestBody.externalOrderId || '' });
      salesOrder.setValue({ fieldId: 'memo', value: requestBody.memo || '' });
 
      // Resolve and add each line item by SKU
      const lineErrors = [];
      requestBody.items.forEach((lineItem, index) => {
        const item = findItemBySku(lineItem.sku);
        if (!item) {
          lineErrors.push(`Line ${index}: SKU '${lineItem.sku}' not found`);
          return;
        }
 
        salesOrder.selectNewLine({ sublistId: 'item' });
        salesOrder.setCurrentSublistValue({
          sublistId: 'item',
          fieldId: 'item',
          value: parseInt(item.internalId, 10)
        });
        salesOrder.setCurrentSublistValue({
          sublistId: 'item',
          fieldId: 'quantity',
          value: lineItem.quantity
        });
        if (lineItem.rate) {
          salesOrder.setCurrentSublistValue({
            sublistId: 'item',
            fieldId: 'rate',
            value: lineItem.rate
          });
        }
        salesOrder.commitLine({ sublistId: 'item' });
      });
 
      if (lineErrors.length > 0) {
        return respond(false, null, lineErrors.join('; '));
      }
 
      const orderId = salesOrder.save();
 
      log.audit(`${CONFIG.LOG_PREFIX} Order Created`, `ID: ${orderId}`);
 
      return respond(true, {
        orderId: orderId,
        externalOrderId: requestBody.externalOrderId,
        status: 'created'
      });
 
    } catch (e) {
      log.error(`${CONFIG.LOG_PREFIX} POST Error`, e.message);
      return respond(false, null, `Order creation failed: ${e.message}`);
    }
  };
 
  // ─── PUT: Update order status ─────────────────────────────
 
  /**
   * Handle PUT requests - update order fields
   * Expected body:
   * {
   *   "orderId": 12345,
   *   "status": "pendingFulfillment",
   *   "memo": "Updated by integration"
   * }
   */
  const put = (requestBody) => {
    log.audit(`${CONFIG.LOG_PREFIX} PUT`, JSON.stringify(requestBody));
 
    if (!requestBody.orderId) {
      return respond(false, null, 'orderId is required');
    }
 
    try {
      const values = {};
 
      if (requestBody.status) {
        if (!CONFIG.ALLOWED_ORDER_STATUSES.includes(requestBody.status)) {
          return respond(false, null,
            `Invalid status. Allowed: ${CONFIG.ALLOWED_ORDER_STATUSES.join(', ')}`);
        }
        values.orderstatus = requestBody.status;
      }
 
      if (requestBody.memo) {
        values.memo = requestBody.memo;
      }
 
      record.submitFields({
        type: record.Type.SALES_ORDER,
        id: parseInt(requestBody.orderId, 10),
        values: values
      });
 
      return respond(true, {
        orderId: requestBody.orderId,
        updatedFields: Object.keys(values),
        status: 'updated'
      });
 
    } catch (e) {
      log.error(`${CONFIG.LOG_PREFIX} PUT Error`, e.message);
      return respond(false, null, `Update failed: ${e.message}`);
    }
  };
 
  // ─── DELETE: Close/cancel an order ────────────────────────
 
  /**
   * Handle DELETE requests - close a sales order
   * ?id=12345&reason=customer_cancelled
   */
  const doDelete = (requestParams) => {
    const { id, reason } = requestParams;
 
    log.audit(`${CONFIG.LOG_PREFIX} DELETE`, `Order: ${id}, Reason: ${reason}`);
 
    if (!id) {
      return respond(false, null, 'Parameter id is required');
    }
 
    try {
      // Load and close the order rather than deleting it
      const salesOrder = record.load({
        type: record.Type.SALES_ORDER,
        id: parseInt(id, 10),
        isDynamic: true
      });
 
      salesOrder.setValue({
        fieldId: 'orderstatus',
        value: 'C' // Closed
      });
 
      salesOrder.setValue({
        fieldId: 'memo',
        value: `Closed via API. Reason: ${reason || 'Not specified'}`
      });
 
      salesOrder.save();
 
      return respond(true, {
        orderId: id,
        status: 'closed',
        reason: reason || 'Not specified'
      });
 
    } catch (e) {
      log.error(`${CONFIG.LOG_PREFIX} DELETE Error`, e.message);
      return respond(false, null, `Close failed: ${e.message}`);
    }
  };
 
  return { get, post, put, delete: doDelete };
});

Governance and Performance

RESTlets have a 5,000-unit governance limit per execution. Every API call you make within the script consumes units. Understanding the cost of common operations helps you stay within budget.

Governance Usage by Operation

OperationGovernance Cost
record.load()5 units
record.create() + .save()10 units
record.submitFields()2 units
record.delete()4 units
search.create() + .run()5 units
search.lookupFields()1 unit
N/log calls0 units

Performance Optimization Tips

Use search.lookupFields() instead of record.load() when you only need a few field values:

// Expensive: 5 governance units
const rec = record.load({ type: 'customer', id: 123 });
const name = rec.getValue({ fieldId: 'companyname' });
 
// Cheaper: 1 governance unit
const fields = search.lookupFields({
  type: 'customer',
  id: 123,
  columns: ['companyname', 'email']
});
const name = fields.companyname;

Use record.submitFields() instead of load-modify-save for field updates:

// Expensive: 5 (load) + 10 (save) = 15 units
const rec = record.load({ type: 'salesorder', id: 456 });
rec.setValue({ fieldId: 'memo', value: 'Updated' });
rec.save();
 
// Cheaper: 2 units
record.submitFields({
  type: 'salesorder',
  id: 456,
  values: { memo: 'Updated' }
});

Monitor governance at runtime:

const script = runtime.getCurrentScript();
const remaining = script.getRemainingUsage();
 
log.debug('Governance', `Remaining units: ${remaining}`);
 
if (remaining < 500) {
  log.warning('Low Governance', 'Less than 500 units remaining');
  // Consider returning partial results
}

Batch search results efficiently:

// Process search results in pages to control governance
const mySearch = search.create({
  type: 'salesorder',
  filters: [['mainline', 'is', 'T']],
  columns: ['tranid', 'entity', 'total']
});
 
const pagedResults = mySearch.runPaged({ pageSize: 100 });
 
const allResults = [];
pagedResults.pageRanges.forEach((pageRange) => {
  const page = pagedResults.fetch({ index: pageRange.index });
  page.data.forEach((result) => {
    allResults.push({
      id: result.id,
      tranId: result.getValue({ name: 'tranid' }),
      customer: result.getText({ name: 'entity' }),
      total: result.getValue({ name: 'total' })
    });
  });
});

Deployment and Testing

Creating the Script Record

  1. Upload your RESTlet file to the File Cabinet under SuiteScripts (e.g., SuiteScripts/restlets/ecom_integration_rl.js)
  2. Navigate to Customization > Scripting > Scripts > New
  3. Select your file and set the Script Type to RESTlet
  4. Map the entry points: GET, POST, PUT, DELETE to their corresponding function names
  5. Save the script record

Creating a Deployment

  1. From the Script record, click Deploy Script
  2. Set Status to Released
  3. Set the Audience (roles that can access this endpoint)
  4. Note the External URL - this is your API endpoint:
https://ACCOUNT_ID.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=SCRIPT_ID&deploy=DEPLOY_ID

Testing with Postman

Postman makes it straightforward to test RESTlets with TBA authentication.

Setting up authentication in Postman:

  1. Create a new request and set the URL to your RESTlet's External URL
  2. Go to the Authorization tab
  3. Select OAuth 1.0 as the type
  4. Fill in:
    • Consumer Key
    • Consumer Secret
    • Access Token
    • Token Secret
    • Signature Method: HMAC-SHA256
    • Add empty string to realm or your Account ID
  5. Under Advanced, set "Add authorization data to" to Request Headers

Example GET request:

GET https://1234567.restlets.api.netsuite.com/app/site/hosting/restlet.nl
    ?script=123&deploy=1&action=inventory&sku=WIDGET-A

Example POST request:

Set the body type to raw JSON:

{
  "externalOrderId": "WEB-99001",
  "customerExternalId": "CUST-001",
  "items": [
    { "sku": "WIDGET-A", "quantity": 2, "rate": 29.99 },
    { "sku": "WIDGET-B", "quantity": 1 }
  ],
  "memo": "Test order from Postman"
}

Headers to include:

Content-Type: application/json

Debugging Tips

  • Check the Execution Log on the Script Deployment page for log.audit() and log.error() entries
  • Use the Script Debugger in NetSuite for step-through debugging during development
  • Monitor governance usage in logs to catch performance issues early
  • Test with minimal permissions to verify role-based access works correctly

Security Best Practices

Principle of Least Privilege

Deploy your RESTlet with the most restrictive role possible. Create a dedicated integration role that only has access to the record types your API touches:

  1. Create a custom role at Setup > Users/Roles > Manage Roles > New
  2. Grant only the specific record permissions needed (e.g., Sales Order: Create/Edit, Customer: View)
  3. Assign this role to the integration user
  4. Set the deployment audience to this role only

Rate Limiting and Abuse Prevention

NetSuite does not provide built-in rate limiting for RESTlets. Implement your own tracking:

/**
 * Simple request tracking using a custom record
 * Check if the caller has exceeded their request quota
 * @param {string} clientId - Identifier for the calling system
 * @param {number} maxRequestsPerHour - Threshold
 * @returns {boolean} Whether the request is allowed
 */
const checkRateLimit = (clientId, maxRequestsPerHour) => {
  const oneHourAgo = new Date();
  oneHourAgo.setHours(oneHourAgo.getHours() - 1);
 
  const results = search.create({
    type: 'customrecord_api_request_log',
    filters: [
      ['custrecord_api_client_id', 'is', clientId],
      'AND',
      ['created', 'onorafter', oneHourAgo.toISOString()]
    ],
    columns: [search.createColumn({ name: 'internalid', summary: 'COUNT' })]
  }).run().getRange({ start: 0, end: 1 });
 
  const count = parseInt(results[0].getValue({
    name: 'internalid',
    summary: 'COUNT'
  }), 10) || 0;
 
  return count < maxRequestsPerHour;
};

Additional Security Measures

  • Whitelist record types - Never allow callers to specify arbitrary record types in API parameters. Maintain an explicit allowlist of supported types.
  • Validate external IDs - When accepting external system identifiers, always resolve them to internal IDs and verify the record exists before operating on it.
  • Audit all write operations - Use log.audit() for every create, update, and delete. Include the caller identity, timestamp, and affected records.
  • Avoid exposing internal errors - Return generic error messages to external callers. Log the full stack trace internally for debugging.
  • Use @NModuleScope SameAccount - Prevent your RESTlet from being accessed across NetSuite accounts in multi-account environments.
  • Rotate tokens periodically - Establish a token rotation schedule and revoke old tokens promptly.
  • Never log sensitive data - Avoid logging full request bodies that might contain credentials, payment data, or PII. Log only what you need for debugging.

Common Pitfalls

  1. Forgetting Content-Type: application/json - Without this header, NetSuite may not parse the request body correctly on POST/PUT requests.
  2. Returning undefined - If your function does not explicitly return a value, the caller receives an empty response. Always return an object or string.
  3. String vs. number IDs - NetSuite internal IDs are numbers, but they often arrive as strings from external systems. Always parse with parseInt().
  4. Governance exhaustion on bulk operations - If your RESTlet processes many records in a single call, monitor getRemainingUsage() and bail out gracefully before hitting the limit.
  5. Testing only with admin roles - An admin role has access to everything. Test with the actual integration role to catch permission issues before production.

Next Steps

Now that you can build RESTlets, explore related SuiteScript capabilities:


Need custom API development for your NetSuite instance? Contact our development team for a consultation.

Need hands-on training?

Our corporate training programs go beyond tutorials with personalized instruction using your actual NetSuite environment.

Get in Touch