Add Zero-Value Line Items to NetSuite Orders via API
Learn to Add Zero-Value Line Items to NetSuite Orders via API Without Overwriting Originals.

On this page
When Shopify orders sync to NetSuite, you often need to attach marketing inserts, free samples, or promotional materials that don't exist on the e-commerce side. These zero-value line items must be appended to existing sales orders without disturbing the original line data. The SuiteTalk REST API handles this through the Add or Update operation with a null lineId, but the behavior trips up developers who expect a standard PATCH or POST.
Why Standard Update Fails
A straight PATCH to /record/v1/salesOrder/{id} requires every sublist line to carry its lineId. If you omit it, the API treats the payload as a full replacement and wipes existing lines. The lineId is mandatory for Update because NetSuite must know exactly which line you're modifying. Add or Update relaxes this: when lineId is null or absent, NetSuite interprets the line as an append.
The permission that actually trips people up is REST Web Services under Setup > Company > Enable Features > SuiteCloud. Without it, the endpoint returns 403 even with valid TBA tokens.
Prerequisites in NetSuite
Enable REST Web Services Setup > Company > Enable Features > SuiteCloud > REST Web Services
Enable Token-Based Authentication Same page, TBA checkbox. Create an Integration record at Setup > Integration > Manage Integrations > New to get Consumer Key/Secret.
Generate Token User menu > Settings > Access Tokens > New. Assign the integration, role (must have Sales Order edit permission), and note Token ID/Secret.
Expose Internal IDs Home > Set Preferences > Show Internal IDs. You'll need the sales order
idand each item'siteminternal ID. NetSuite Connector documentation notes these IDs are permanent and visible in record URLs.
The Add or Update Payload
Endpoint: POST https://{accountId}.suitetalk.api.netsuite.com/services/rest/record/v1/salesOrder
Headers:
Authorization: Bearer <access_token>
Content-Type: application/json
Prefer: return=representationBody, note the null lineId on the new line:
{
"id": "10452",
"item": {
"items": [
{
"lineId": null,
"item": { "id": "487" },
"quantity": 1,
"rate": 0,
"amount": 0,
"description": "Marketing Insert - Summer Catalog",
"custcol_is_promo_item": true
}
]
}
}Key fields:
id, sales order internal ID (nottranid/order number)item.items, the sublist array; existing lines can be omitted entirelylineId: null, signals append; omit the property entirely for same effectrate: 0andamount: 0, zero-value enforcementcustcol_is_promo_item, custom column field (optional but recommended for reporting)
The response returns the full updated order with the new line's assigned lineId.
Worked Example: Append Two Promo Items
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/record', 'N/log'], (record, log) => {
const post = (context) => {
const { orderId, promoItems } = context; // promoItems: [{ itemId, qty, description }]
const so = record.load({ type: record.Type.SALES_ORDER, id: orderId, isDynamic: true });
promoItems.forEach((p) => {
so.selectNewLine({ sublistId: 'item' });
so.setCurrentSublistValue({ sublistId: 'item', fieldId: 'item', value: p.itemId });
so.setCurrentSublistValue({ sublistId: 'item', fieldId: 'quantity', value: p.qty || 1 });
so.setCurrentSublistValue({ sublistId: 'item', fieldId: 'rate', value: 0 });
so.setCurrentSublistValue({ sublistId: 'item', fieldId: 'description', value: p.description });
so.setCurrentSublistValue({ sublistId: 'item', fieldId: 'custcol_is_promo_item', value: true });
so.commitLine({ sublistId: 'item' });
});
const savedId = so.save({ enableSourcing: true, ignoreMandatoryFields: true });
return { success: true, orderId: savedId };
};
return { post };
});Deploy as a RESTlet (Setup > Scripting > Scripts > New > RESTlet) and call it with a lightweight JSON payload. This bypasses the REST API's lineId complexity entirely and runs server-side with full sourcing.
CSV Import as a Debugging Sandbox
Before coding, model the behavior in CSV Import (Setup > Import/Export > Import CSV Records):
- Record Type: Sales Order
- Mapping: Internal ID →
id, Item →item, Quantity →quantity, Rate →rate, Line ID → leave blank - Import Type: Add or Update
- Run a test file with one existing order ID and a new line, verify the line appends without deleting existing lines.
This mirrors the API's Add or Update logic exactly and takes minutes to validate.
Common Failure Modes
| Symptom | Cause | Fix |
|---|---|---|
| 400 "Line ID is required" | Used PATCH or Update operation | Switch to POST with Add or Update; ensure lineId is null/omitted |
| 403 Forbidden | TBA token missing REST Web Services scope | Re-generate token with a role that has Access REST Web Services permission |
| Existing lines disappear | Sent full item array without original lines | Omit existing lines; only send new lines with null lineId |
| Rate reverts to item base price | rate omitted or sourcing overwrites | Explicitly set rate: 0 and amount: 0; disable "Use Item Price" on the item record if persistent |
When to Use a UserEvent Script Instead
If the zero-value items follow predictable rules, e.g., "add catalog to every order over $100" or "include return label for international shipments", a beforeSubmit UserEvent script on Sales Order eliminates the external call entirely:
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
*/
define(['N/record', 'N/search', 'N/log'], (record, search, log) => {
const beforeSubmit = (ctx) => {
if (ctx.type !== ctx.UserEventType.CREATE && ctx.type !== ctx.UserEventType.EDIT) return;
const so = ctx.newRecord;
const total = so.getValue({ fieldId: 'total' });
const isInternational = so.getValue({ fieldId: 'shipcountry' }) !== 'US';
if (total >= 100 && !hasPromoLine(so, '487')) {
appendPromoLine(so, '487', 'Summer Catalog');
}
if (isInternational && !hasPromoLine(so, '512')) {
appendPromoLine(so, '512', 'International Return Label');
}
};
const hasPromoLine = (rec, itemId) => {
const lines = rec.getLineCount({ sublistId: 'item' });
for (let i = 0; i < lines; i++) {
if (rec.getSublistValue({ sublistId: 'item', fieldId: 'item', line: i }) === itemId) return true;
}
return false;
};
const appendPromoLine = (rec, itemId, desc) => {
rec.selectNewLine({ sublistId: 'item' });
rec.setCurrentSublistValue({ sublistId: 'item', fieldId: 'item', value: itemId });
rec.setCurrentSublistValue({ sublistId: 'item', fieldId: 'quantity', value: 1 });
rec.setCurrentSublistValue({ sublistId: 'item', fieldId: 'rate', value: 0 });
rec.setCurrentSublistValue({ sublistId: 'item', fieldId: 'description', value: desc });
rec.setCurrentSublistValue({ sublistId: 'item', fieldId: 'custcol_is_promo_item', value: true });
rec.commitLine({ sublistId: 'item' });
};
return { beforeSubmit };
});Deploy on Sales Order, beforeSubmit, and the logic runs automatically, no middleware, no sync lag. System Notes will capture each scripted line addition for audit trail.
Validation Step
After any method, open the sales order in the UI and confirm:
- Original Shopify lines remain intact with correct quantities and rates
- New lines show Rate 0.00, Amount 0.00
custcol_is_promo_itemis checked (add to form if not visible)- Order total unchanged, critical for revenue recognition and commission calc
Run a saved search (Transaction > Sales Order, filter: custcol_is_promo_item = T) to audit all promo lines across the account.


