A client came to me last week with a straightforward integration request:
"Build a RESTlet that accepts order data from their Shopify middleware and creates sales orders in NetSuite. Include input validation and proper error handling."
It's the kind of request I've handled dozens of times. I know exactly what it takes - record type selection, sublist handling, idempotency logic, governance awareness, field ID verification, the works.
However, instead of opening my editor and writing code, I assigned the project to my virtual SuiteScript Developer - one of more than 60 members of my virtual team. (And by the way, that developer is based in large part on a prompt I shared a few weeks ago, which is available here.)
What my virtual SuiteScript Developer returned is one of the more complete first-pass responses I've seen from an AI on a SuiteScript task.
In this post, I'll walk through what the AI produced, what assumptions it made, how the code is structured, and why I think the prompt used to create / define the developer is doing a lot of the heavy lifting.
The Request
Here's the exact task that I assigned to the developer:
"Write a RESTlet that accepts order data from our Shopify middleware and creates sales orders in NetSuite. Include input validation and error handling."
It's short and deliberately underspecified. The kind of request a client sends before a discovery call fills in the gaps. But I used it this way because I wanted to see how the agent would handle the ambiguity.
Assumptions, Stated Upfront
The first thing the agent did, before writing a single line of code, was surface its assumptions. This is important, because a script that assumes single-subsidiary, single-currency, TBA authentication, and SKU-based item matching can blindside you the moment any of those assumptions are wrong.
Here's what it called out explicitly:
- Authentication: Token-Based Authentication (TBA), which is standard for RESTlet integrations.
- Idempotency: Using Shopify's order_id as an external ID to prevent duplicate orders on middleware retry.
- Customer matching: Looking up existing NetSuite customers by email - not auto-creating new ones.
- Item matching: Resolving Shopify line items to NetSuite items by SKU (itemid). Unmatched SKUs fail the request.
- Error contract: Validation errors return a 400 with field-level detail. System errors return 500. No partial success - all or nothing.
- Scope: Single subsidiary, single currency. OneWorld adds complexity and was flagged for follow-up.
That last point is something I really appreciate. The prompt used to create the developer requires that it state what it is assuming, and it encourages making corrections before any code that it generates is run in production.
Architecture Decision: Multiple Files
Rather than producing a single 400-line RESTlet (the easy, unmaintainable path), the agent proposed splitting the solution across five files with clear separation of concerns:
src/
├── restlet/
│ └── so_create_restlet.js – Entry point, routing only
└── lib/
├── so_builder.js – Sales order construction logic
├── shopify_validator.js – Input validation
├── customer_resolver.js – Customer lookup by email
├── item_resolver.js – Item lookup by SKU
└── restlet_response.js – Standardized response envelope
The entry point does routing only. Business logic lives in the builder. Validation is isolated so it can be tested independently. Response formatting is consistent across every outcome. Each file has one job.
This is a detail that separates a developer who's shipped production integrations from one who hasn't. A monolithic RESTlet is fast to write and slow to debug at 2am when a middleware retry is hammering a production endpoint.
The Code
Entry Point: so_create_restlet.js
The entry point is deliberately thin. It validates the payload, calls the builder, and returns a structured response. Top-level error handling distinguishes between known business errors (400) and unexpected system failures (500).
/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @NModuleScope SameAccount
*/
define([
'../lib/shopify_validator',
'../lib/so_builder',
'../lib/restlet_response'
], (validator, soBuilder, response) => {
const post = (payload) => {
try {
const validationResult = validator.validate(payload);
if (!validationResult.isValid) {
return response.badRequest(validationResult.errors);
}
const result = soBuilder.createFromShopify(payload);
return response.created(result);
} catch (e) {
if (e.type === 'BUSINESS_ERROR') {
return response.badRequest([e.message]);
}
log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
return response.serverError(e.message);
}
};
return { post };
});
Response Envelope: restlet_response.js
Every response, whether a success or failure, comes back in the same shape. The middleware always knows what to expect.
/**
* @NApiVersion 2.1
* @NScriptType Library
* @NModuleScope SameAccount
*/
define(['N/log'], (log) => {
const build = (status, code, data = null, errors = []) => ({
status,
code,
data,
errors,
timestamp: new Date().toISOString()
});
const created = (data) => build('success', 201, data);
const badRequest = (errors) => build('error', 400, null, errors);
const serverError = (msg) => build('error', 500, null, [msg || 'An
unexpected error occurred.']);
return { created, badRequest, serverError };
});
Input Validation: shopify_validator.js
Validation runs before any record is touched. It collects all errors rather than throwing on the first one - so the middleware gets a complete list of what needs fixing, not a one-at-a-time "drip" of failures.
/**
* @NApiVersion 2.1
* @NScriptType Library
* @NModuleScope SameAccount
*/
define(['N/log'], (log) => {
const validate = (payload) => {
const errors = [];
if (!payload || typeof payload !== 'object') {
return { isValid: false, errors: ['Request body is missing or not a
valid JSON object.'] };
}
if (!payload.shopify_order_id) {
errors.push('shopify_order_id is required.');
}
if (!payload.customer_email || !isValidEmail(payload.customer_email)) {
errors.push('customer_email is required and must be a valid email
address.');
}
if (typeof payload.order_total !== 'number' || payload.order_total < 0)
{
errors.push('order_total is required and must be a non-negative
number.');
}
if (!Array.isArray(payload.line_items) || payload.line_items.length ===
0) {
errors.push('line_items is required and must be a non-empty
array.');
} else {
payload.line_items.forEach((item, index) => {
const prefix = `line_items[${index}]`;
if (!item.sku) errors.push(`${prefix}.sku is required.`);
if (typeof item.quantity !== 'number' ||
!Number.isInteger(item.quantity) || item.quantity < 1) {
errors.push(`${prefix}.quantity must be a positive
integer.`);
}
if (typeof item.unit_price !== 'number' || item.unit_price < 0)
{
errors.push(`${prefix}.unit_price must be a non-negative
number.`);
}
});
}
if (payload.shipping_date && !isValidISODate(payload.shipping_date)) {
errors.push('shipping_date must be a valid ISO 8601 date string
(YYYY-MM-DD).');
}
return { isValid: errors.length === 0, errors };
};
const isValidEmail = (email) => typeof email === 'string' &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const isValidISODate = (dateStr) => typeof dateStr === 'string' && /^\d{4}-
\d{2}-\d{2}$/.test(dateStr) && !isNaN(Date.parse(dateStr));
return { validate };
});
Customer Resolution: customer_resolver.js
Customer lookup uses a search rather than loading a record - roughly 1 governance unit instead of 10. It explicitly handles the duplicate email case, surfacing it as a business error rather than silently assigning the order to whichever customer came back first.
/**
* @NApiVersion 2.1
* @NScriptType Library
* @NModuleScope SameAccount
*/
define(['N/search', 'N/log'], (search, log) => {
// VERIFY: 'email' is the standard field ID on Customer records.
// If your account uses a custom email field, update this filter.
const resolveByEmail = (email) => {
if (!email) return null;
const results = search.create({
type: search.Type.CUSTOMER,
filters: [
['email', search.Operator.IS, email],
'AND',
['isinactive', search.Operator.IS, 'F']
],
columns: ['internalid', 'entityid']
}).run().getRange({ start: 0, end: 2 });
if (!results || results.length === 0) {
throwBusinessError(`No active customer found with email:
${email}`);
}
if (results.length > 1) {
throwBusinessError(
`Multiple active customers found with email: ${email}. ` +
`Resolve the duplicate in NetSuite or match by customer ID
instead.`
);
}
return results[0].getValue({ name: 'internalid' });
};
const throwBusinessError = (message) => {
const e = new Error(message);
e.type = 'BUSINESS_ERROR';
throw e;
};
return { resolveByEmail };
});
Item Resolution: item_resolver.js
This one is easy to get wrong. The naive approach - one search per line item - burns governance units proportionally to order size. Instead, all SKUs are resolved in a single search. A Map is built from the results, then checked against the full SKU list to identify anything that didn't resolve before the SO record is ever touched.
/**
* @NApiVersion 2.1
* @NScriptType Library
* @NModuleScope SameAccount
*/
define(['N/search', 'N/log'], (search, log) => {
// VERIFY: 'itemid' is the standard SKU/item name field.
// If your account uses a custom SKU field (e.g., custitem_sku), update
// both the filter and the column below.
const resolveSkus = (skus) => {
if (!skus || skus.length === 0) return new Map();
const uniqueSkus = [...new Set(skus)];
const results = search.create({
type: search.Type.ITEM,
filters: [
['itemid', search.Operator.ANYOF, uniqueSkus],
'AND',
['isinactive', search.Operator.IS, 'F']
],
columns: ['internalid', 'itemid']
}).run().getRange({ start: 0, end: uniqueSkus.length + 1 });
const skuMap = new Map();
(results || []).forEach(result => {
const sku = result.getValue({ name: 'itemid' });
const id = result.getValue({ name: 'internalid' });
skuMap.set(sku, id);
});
const unresolved = uniqueSkus.filter(sku => !skuMap.has(sku));
if (unresolved.length > 0) {
const e = new Error(`The following SKUs were not found in NetSuite:
${unresolved.join(', ')}`);
e.type = 'BUSINESS_ERROR';
throw e;
}
return skuMap;
};
return { resolveSkus };
});
Sales Order Builder: so_builder.js
The builder orchestrates everything. It runs an idempotency check first. If the Shopify order was already processed (middleware retry scenario), it returns the existing SO instead of creating a duplicate. Then it resolves dependencies, builds the record in dynamic mode, populates header fields and line items, and saves.
/**
* @NApiVersion 2.1
* @NScriptType Library
* @NModuleScope SameAccount
*/
define([
'N/record',
'N/search',
'N/log',
'../lib/customer_resolver',
'../lib/item_resolver'
], (record, search, log, customerResolver, itemResolver) => {
// VERIFY: Create a custom body field on Sales Order named
// custbody_shopify_order_id
// (Free-Form Text, Store Value checked) before deploying.
const SHOPIFY_ORDER_ID_FIELD = 'custbody_shopify_order_id';
const createFromShopify = (payload) => {
// Idempotency — return existing SO if this Shopify order was already
processed
const existingSoId =
findExistingSalesOrder(payload.shopify_order_id);
if (existingSoId) {
log.audit({
title: 'so_builder.createFromShopify',
details: `Duplicate — Shopify order ${payload.shopify_order_id}
already exists as SO ${existingSoId}`
});
return { soId: existingSoId, created: false };
}
// Resolve dependencies before creating the record — fail fast
const customerId =
customerResolver.resolveByEmail(payload.customer_email);
const skus = payload.line_items.map(item => item.sku);
const skuMap = itemResolver.resolveSkus(skus);
const soRecord = record.create({
type: record.Type.SALES_ORDER,
isDynamic: true
});
soRecord.setValue({ fieldId: 'entity', value: customerId });
soRecord.setValue({ fieldId: 'trandate', value: new Date() });
if (payload.po_number) soRecord.setValue({ fieldId: 'otherrefnum',
value: payload.po_number });
if (payload.memo) soRecord.setValue({ fieldId: 'memo',
value: payload.memo });
if (payload.shipping_date) soRecord.setValue({ fieldId: 'shipdate',
value: new Date(payload.shipping_date) });
soRecord.setValue({ fieldId: SHOPIFY_ORDER_ID_FIELD, value:
String(payload.shopify_order_id) });
payload.line_items.forEach((item) => {
soRecord.selectNewLine({ sublistId: 'item' });
soRecord.setCurrentSublistValue({ sublistId: 'item', fieldId:
'item', value: skuMap.get(item.sku) });
soRecord.setCurrentSublistValue({ sublistId: 'item', fieldId:
'quantity', value: item.quantity });
soRecord.setCurrentSublistValue({ sublistId: 'item', fieldId:
'rate', value: item.unit_price });
if (item.description) {
soRecord.setCurrentSublistValue({ sublistId: 'item', fieldId:
'description', value: item.description });
}
soRecord.commitLine({ sublistId: 'item' });
});
const soId = soRecord.save({ enableSourcing: true,
ignoreMandatoryFields: false });
const soNumber = getSoTransactionNumber(soId);
log.audit({
title: 'so_builder.createFromShopify',
details: `Created SO ${soNumber} (ID: ${soId}) from Shopify order
${payload.shopify_order_id}`
});
return { soId: String(soId), soNumber, created: true };
};
const findExistingSalesOrder = (shopifyOrderId) => {
const results = search.create({
type: search.Type.SALES_ORDER,
filters: [
[SHOPIFY_ORDER_ID_FIELD, search.Operator.IS,
String(shopifyOrderId)],
'AND',
['mainline', search.Operator.IS, 'T']
],
columns: ['internalid']
}).run().getRange({ start: 0, end: 1 });
return results && results.length > 0
? results[0].getValue({ name: 'internalid' })
: null;
};
const getSoTransactionNumber = (soId) => {
const result = search.lookupFields({
type: record.Type.SALES_ORDER,
id: soId,
columns: ['tranid']
});
return result.tranid || String(soId);
};
return { createFromShopify };
});
The Request/Response Contract
Claude also defined the full API contract - what the middleware sends, and what it gets back in each scenario.
Inbound payload:
{
"shopify_order_id": "5001234567890",
"customer_email": "jane.doe@example.com",
"po_number": "PO-9921",
"order_total": 1250.00,
"shipping_date": "2025-04-01",
"memo": "Priority fulfillment",
"line_items": [
{ "sku": "WIDGET-BLU-M", "quantity": 2, "unit_price": 500.00,
"description": "Blue Widget, Medium" },
{ "sku": "WIDGET-RED-L", "quantity": 1, "unit_price": 250.00 }
]
}
Success (201):
{
"status": "success",
"code": 201,
"data": { "soId": "12345", "soNumber": "SO-10042", "created": true },
"errors": [],
"timestamp": "2025-03-12T14:22:00.000Z"
}
Duplicate / idempotent retry (201):
{
"status": "success",
"code": 201,
"data": { "soId": "12345", "created": false },
"errors": [],
"timestamp": "2025-03-12T14:22:00.000Z"
}
Validation failure (400):
{
"status": "error",
"code": 400,
"data": null,
"errors": [
"customer_email is required and must be a valid email address.",
"line_items[1].quantity must be a positive integer."
],
"timestamp": "2025-03-12T14:22:00.000Z"
}
Deployment Instructions
The agent included a complete deployment checklist rather than leaving that as an exercise for the reader:
- Upload all files to Documents > Files > SuiteScripts, preserving the src/restlet/ and src/lib/folder structure.
- Create a Script Record for so_create_restlet.js only. Library files don't get their own script records.
- Create a Deployment Record and note the external URL it generates - that's what the Shopify middleware calls.
- In Setup > Integration > Manage Integrations, create an integration record for the middleware and issue TBA credentials.
- Create the custom body field custbody_shopify_order_id on the Sales Order record (type: Free-Form Text, Store Value checked) before deploying. The idempotency logic depends on it.
Caveats
This is where the response earned its keep. Rather than shipping a confident-looking script and leaving production surprises for later, the agent was explicit about what to verify and what wasn't handled.
Field IDs to Verify
Shopify Order ID — custbody_shopify_order_id Custom field — must be created before deploying.
Customer Email — email Standard field on the Customer record — verify in your Records Browser.
PO Number — otherrefnum Standard field on Sales Order.
Ship Date — shipdate Standard field on Sales Order.
Item SKU — itemid May be a custom field in your account — confirm before deploying.
Governance
One record.create() + record.save() runs approximately 20–30 units. Two searches (idempotency check and customer lookup) add around 10 units each. Item SKU resolution is a single call regardless of line item count. Total per request: roughly 50–60 units - well within the 5,000-unit RESTlet budget.
What This Script Doesn't Handle
The agent also flagged six areas that would need follow-up based on the client's specific setup:
- Customer auto-creation: Currently throws a 400 if the customer isn't found. If Shopify customers should be created in NetSuite on first order, the customer resolver needs a creation path.
- Shipping charges: Shopify shipping line items need special handling — typically mapping to a non-inventory item in NetSuite.
- Discounts: Shopify discount codes need mapping to NetSuite discount lines or price levels.
- Tax: The script uses enableSourcing: true on save and relies on NetSuite's own tax engine. The right approach depends on whether the account uses TaxJar, Avalara, or native SuiteTax.
- OneWorld / multi-subsidiary: If the account is on OneWorld, subsidiary is a mandatory field on the SO header and needs to be added as a required input or derived from the customer record.
- Item search limit: The current getRange() call assumes fewer than 1,000 unique SKUs per order. For unusually large orders, paged search is needed.
Why the Virtual Developer Works
The code above didn't come as a result of the AI (which in my case is Claude Opus 4.6) being good at SuiteScript in isolation. It came from a virtual developer that was built using a complex, well-engineered prompt that was designed to produce this kind of output specifically.
A few things make it different from a typical "you are a helpful assistant" type of prompt.
It Forces a Decision Framework Before Writing Code
The prompt includes an explicit hierarchy: check native configuration first, then workflows, then saved searches, then custom development. The AI has to evaluate and state why native options were insufficient before writing a single line of script. On this request, it noted that a RESTlet is appropriate here because there's no native inbound webhook receiver in NetSuite that can handle custom JSON payloads and perform record creation logic.
It Encodes Platform-Specific Knowledge as Hard Rules
The prompt doesn't just say "be a good developer." It encodes specific NetSuite behaviors as non-negotiable constraints: always use define() with proper dependency injection, always include JSDoc annotations, always include error handling that captures name, message, and stack, never use record.load() where search.lookupFields() would do.
These are things an experienced SuiteScript developer knows from shipping production integrations. The prompt crystallizes that experience into rules the AI follows on every response - not just when specifically reminded.
It Has an Explicit Uncertainty Protocol
The prompt requires the AI to distinguish between what it knows and what it's inferring. When a field ID, method signature, or API behavior is uncertain, it must flag it explicitly with a // VERIFY: comment and call it out in the caveats section.
A wrong field ID that looks plausible is worse than an obvious placeholder. The prompt treats fabricated confidence as a failure mode, not a feature.
It Names Common Failure Patterns
The prompt I used includes a section called "Common Failure Patterns" that reads like a post-mortem document from a team that has shipped broken NetSuite code. The "Governance Time Bomb" (code that works at 5 records, fails at 5,000). The "Context-Blind Script" (such as a User Event that fires on CSV import when it shouldn't). The "Re-Entrancy Loop." The "Native Solution Bypass."
Naming these patterns up front means that the AI being used is actively checking for them rather than rediscovering them after the fact.
It Separates Architecture from Implementation
The response pattern in the prompt requires that the AI state the approach and evaluate alternatives before producing code. The architecture decision used in this example - five files instead of one monolith - emerged from that structure. A prompt that just says "write a RESTlet" would likely produce a single file. This prompt produced a maintainable, testable structure because it required the architectural reasoning first.
Final Thoughts
The response that the AI produced here would have taken me a few hours to write from scratch - including the architecture decisions, the idempotency logic, the governance analysis, and the caveats section. The first-pass output covered roughly 80% of what I'd deliver to a client as a starting point, with clear flags on the remaining 20%.
The prompt didn't replace judgment. I still needed to verify the field IDs, assess the client's subsidiary setup, decide whether customer auto-creation support is needed, and test everything in my client's sandbox before deploying it to production. But I think that's the right division of labor. The AI handled the structural and syntactic work it can do reliably. The platform-specific judgment calls that depend on the client's account configuration were still my responsibility.
And I think that's what a well-scoped AI tool - regardless of whether it's a tool being used for software development, financial analysis, etc - should look like. It shouldn't be a replacement for expertise. Instead, it should be a "force multiplier" for expertise.
This article was originally published on LinkedIn.