Anatomy of the payload

Everything a booking sends is one JSON document. You can read the real thing anytime: the card's View JSON payload button, or the run's Payload_Json__c field. Both show what's below.

Top-level keys

{
  "action":           "book",
  "orderId":          "801…",          // the Salesforce Order id
  "orderNumber":      "00000120",
  "isAmendment":      false,
  "orgBaseUrl":       "https://…my.salesforce.com",  // powers NS→SF deep links
  "orgId":            "00D5f000001AbCdEAO",     // 18-char org id — powers the Source Org stamp
  "customer":         { … },            // account + contacts (addresses ride the address nodes)
  "items":            [ … ],            // the order's products
  "contract":         { … },
  "order":            { … },
  "lines":            [ … ],
  "bsgs":             [ … ],            // billing schedule groups
  "schedules":        [ … ],
  "periods":          [ … ],
  "itemDefaults":     { … },            // optional: item-create config from the mapping editor
  "mappingWarnings":  [ … ],            // optional: rows skipped at build time
  "continueFrom":     "periods:638",  // only on a governance-resume leg: a section name, or "section:row"
  "configVersion":    7,                 // the activated call version this payload was built from
  "requiresVersion":  "3.0.0",           // optional: the NetSuite package this call needs
  "nodeTargets":      { … },            // which NetSuite record type each node lands on
  "nativeSalesOrder": false,             // NetSuite-side Sales Order generator on/off
  "blockNsBilling":   true              // stamps Do Not Bill on documents the sync lands
}

Every record node carries two ids: sfId, the Salesforce id that becomes the NetSuite external id, and nsId, the known NetSuite id or null. NetSuite always re-resolves by external id.

nativeSalesOrder and blockNsBilling are the org's settings flags. If the Order node itself targets a Sales Order, nativeSalesOrder is forced false, with nativeSalesOrderSuppressed: true alongside it, so the two paths never both create one.

customer

"customer": {
  "sfId": "001…", "name": "Example Industries", "nsId": "…",
  "contacts": [ { "sfId", "firstName", "lastName", "email", "phone", "title", "nsId" } ]
}

Contacts cover four order roles: bill-to, ship-to, customer, and company authorized-by. Duplicates are removed.

Addresses travel separately. They're two packaged address nodes under Customer. Their rows travel in customNodes[] as a carrier: one synthetic entry per customer. It sets no fields of its own, and only writes address book rows onto the customer already landed.

"customNodes": [
  { "nodeKey": "__sublist:Customer",       // ours, not yours: a carrier, not a record
    "recordType": "customer",
    "sfId": "001…", "eid": "001…",      // the customer the rows land on
    "values": { },                       // a carrier writes no fields of its own
    "write": { "schema": 2, "allowCreate": false, "resolveBy": "externalid",
               "sublists": [ { "id": "addressbook", "mode": "subrecord",
                              "subrecordId": "addressbookaddress",
                              "keyField": "label", "ensureDefaults": true } ] },
    "lines": [
      { "nodeKey": "Billing Address", "sublist": "addressbook",
        "sfId": "001…", "key": "Billing",      // key = the label NetSuite matches on
        "values": { "label": "Billing", "defaultbilling": true,
                    "addr1": "400 Main St", "addr2": "Suite 210",
                    "city": "San Mateo", "state": "CA",
                    "zip": "94402", "country": "US" } } ] } ]

A carrier never creates the customer: allowCreate is false. If NetSuite doesn't have the customer yet, the run is refused and names the record, instead of creating a second, half-populated one.

items

"items": [ { "sfId": "01t…", "code": "QB-API-MGMT", "name": "QB API Management Solution",
             "nsId": null, "isUsage": false,
             "itemType": "noninventoryitem",     // from the Product node's item-type config
             "revRule": "Straight-Line" } ]      // omitted when the product has none

Items run first in the NetSuite pipeline, so every later section can reference them. Item defaults, like income account, tax schedule, and subsidiaries, apply on create only.

contract and order

"contract": { "sfId", "name", "contractNumber", "startDate", "endDate", "status",
              "totalValue", "subtotal", "taxAmount", "amountInclTax", "discountPercent",
              "termMonths", "autoRenew", "renewalTerm", "nsId" }

"order":    { "name", "orderNumber", "effectiveDate", "startDate", "endDate", "status",
              "orderType",             // "New"/"Amendment", derived when Order.Type is blank
              "totalAmount", "subtotal", "taxAmount", "amountInclTax",
              "discountAmount", "discountPercent",
              "subsidiary",            // the NS subsidiary id from the order's Legal Entity
              "nsId": null }

lines

"lines": [ { "sfId", "productSfId", "name", "quantity", "unitPrice", "listPrice",
             "totalPrice", "netUnitPrice", "netTotalPrice", "amountInclTax", "taxAmount",
             "discountPct", "discountAmount",
             "serviceDate", "endDate", "billingFrequency", "subscriptionTerm",
             "sfAction",              // the order action, e.g. "Add · New" / "Amend · …"
             "delivered", "deliveredDate", "fulfilled", "fulfilledDate",
             "pctComplete",           // % Complete in points: 40 = 40%, or null
             "nsId": null } ]
Net price is derived from line totals, not read from the per-unit fields. netUnitPrice is TotalPrice ÷ Quantity, and the discount percent comes from ListPrice × Quantity versus TotalPrice. Revenue Cloud can leave the per-unit fields stale after repricing, so the totals are always authoritative.

On an amendment, only lines with a non-zero TotalPrice are included.

A booking sends pctComplete only to set a value. A blank never clears NetSuite's value. Order Line Sync is the call that can clear it.

bsgs, schedules, and periods

"bsgs":      [ { "sfId", "name", "number", "method", "type", "term", "termUnit",
                 "billed", "pending", "nextBill",
                 "startDate", "endDate", "itemSfId",   // derived from member schedules
                 "nsId" } ]
"schedules": [ { "sfId", "name", "lineSfId", "itemSfId", "bsgSfId", "frequency",
                 "periodAmount", "total", "billed", "pending", "startDate", "endDate",
                 "status", "category",                 // "Amendment" on amendment orders
                 "origScheduleSfId",                   // chains amendments to originals
                 "nsId": null } ]
"periods":   [ { "sfId", "name", "scheduleSfId", "bsgSfId", "startDate", "endDate",
                 "amount", "status", "nsId": null } ]

The group's dates and item come from its member schedules, since the Salesforce group record carries none. A usage schedule's item resolves through its usage resource to the consumption product, not the order line it references. A period's status reads Invoiced once its Salesforce invoice posts.

nodeTargets, omitted sections, and bare references

Once a call has been activated, the payload names the NetSuite record type each of its core nodes lands on. NetSuite reads the map to decide how to treat each section:

"nodeTargets": { "Customer": "customer",
                 "Product": "noninventoryitem",
                 "Contract": "customrecord_cc_contract",
                 "Order": "salesorder",          // a native transaction target
                 "Order Line": "salesorder.item", // its line sublist — always paired
                 "Billing Schedule Group": "customrecord_cc_bs_group",
                 "Billing Schedule": "customrecord_cc_billing_schedule",
                 "Billing Period": "customrecord_cc_bs_period" }

A node that's switched off or deleted is missing from the map, and its section is missing from the payload. NetSuite skips it and names it in skippedNodes. Customer, Contract, and Product still ship a bare reference in their usual key, so anything that links to them still resolves:

"customer": { "sfId": "001…", "nsId": "…" }   // no name, contacts or address rows
"contract": { "sfId": "800…", "nsId": "…" }
"items":    [ { "sfId": "01t…", "nsId": "…" } ]

A bare reference with no nsId means the record was never matched to NetSuite. The run is refused before it starts, rather than landing something half-linked. That's unconditional for Customer and Product, and for Contract when the booking still lands an RC Order, the record that needs the contract link.

No nodeTargets key at all means the call has never been activated, and the payload is byte-for-byte what earlier releases sent.

Extension nodes and customNodes[]

Objects an admin added to a call travel in customNodes[], each carrying its own record type. An entry with lines[] is a document. A transaction nests its lines inside itself, since NetSuite can't save one with none. A customer nests its address rows the same way. A transaction also names two references: the customer it belongs to, and the item each line sells.

"customNodes": [
  { "nodeKey": "Fulfillment SO", "recordType": "salesorder",
    "sfId": "a0X…", "eid": "fso-a0X…",
    "entityNsId": "…",        // the customer's NetSuite id when Salesforce knows it
    "entityRef": { "parentNodeKey": "Customer", "parentSfId": "001…",
                   "parentTable": "customer", "parentEid": "001…" },
    "values":    { "trandate": "2026-02-01", "memo": "…" },
    "dates":     [ "trandate" ],
    "lines": [
      { "nodeKey": "Fulfillment SO Line", "recordType": "salesorder.item", "sfId": "a0Y…",
        "sublist": "item",        // which sublist this row rides — on every nested row
        "itemRef": { "parentNodeKey": "Product", "parentSfId": "01t…",
                    "parentTable": "item", "parentEid": "01t…" },
        "values":  { "quantity": 2, "rate": 150, "amount": 300 } } ] } ]

Both references carry four keys, but NetSuite only resolves through two of them:

KeyRole
parentTable, parentEidWhat NetSuite actually resolves against: the ids this run already landed, then a lookup by external id
parentNodeKey, parentSfIdWhich node the reference came from, for people reading the payload
entityNsIdOn a transaction entry, takes precedence when Salesforce already knows the customer's NetSuite id
entityRefThe fallback for a brand-new account. NetSuite resolves it against the customer the same payload created. See A customer created first

A line's itemRef has no NetSuite id beside it. NetSuite finds the item by the ids this run landed, then by External ID. So the item's External ID must be the product's Salesforce id. An item matched in step 7 of the Connection wizard keeps the External ID it had. See Add a transaction and its lines.

A line entry never appears at the top level of customNodes[], since a sublist row isn't a record of its own. A nested row is also smaller than the entry it rides in: it never carries write, parentRef, or writeback, because it has no identity of its own to configure.

A customer created first

When a document needs an account NetSuite has not got yet, the payload creates it rather than dropping the document. One synthetic customer entry is ordered ahead of the documents that need it, deduplicated per account:

"customNodes": [
  { "nodeKey": "__entity:Fulfillment SO",   // ours, not yours — "__" is a reserved prefix
    "recordType": "customer",
    "sfId": "001…", "eid": "001…",      // the same external id every other path stamps
    "name": "Example Industries",
    "values": { … },                    // the org's own Customer Sync mapping rows
    "writeback": "NetSuiteRecordId__c",
    "write": { "schema": 2, "allowCreate": true,
               "requiredOnCreate": [ "subsidiary" ] } },
  { "nodeKey": "Fulfillment SO", … }              // resolves against it in the same run
]

The entry opts in explicitly, since NetSuite keeps the entity family update-only unless asked. It names what a create needs, falling back to the deployment's default subsidiary when the mapping supplies none. Its __ prefix keeps its landed id under a key of its own, so it never collides with a node you named. The writeback stamps the new NetSuite id on the account, which is what makes the second run skip all of it.

write: telling NetSuite how to write the entry

Everything above is what to write. write says how to write it: which table resolves the external id, which sublists it drives, and whether it can be created. It also says which fields are set by name instead of id, which can be cleared, and which NetSuite should resolve as references. Every key is optional, and an entry without one writes the way it always has:

"write": {
  "schema": 2,
  "table": "transaction", "tableType": "CustPymt",  // how the external id resolves; or "Custom42" for a custom transaction type
  "resolveBy": "externalid",        // or "none" — always create, never look first
  "dynamic": false,
  "form": "112",                   // a custom form, chosen before any field is set
  "allowCreate": true,
  "requiredOnCreate": [ "subsidiary" ],  // falls back to the deployment default
  "nameMode": "create",            // or "always" / "never"
  "setMode": "value",             // or "text" — set selects by their label
  "textFields": [ "terms" ], "valueFields": [ ],
  "allowClear": true, "clearFields": [ "custbody_hold_reason" ],
  "allowFields": [ "isinactive" ],   // lift one default-blocked field id
  "lookups": { "custbody_project": { "table": "job", "value": "a0Y…" } },
  "sublists": [ { "id": "apply", "keyField": "doc", "mode": "match" },
                 { "id": "addressbook", "mode": "subrecord",
                   "subrecordId": "addressbookaddress",  // the column holding the nested record
                   "keyField": "label", "ensureDefaults": true } ]
}

tableType must be one of NetSuite's own transaction type codes, or Custom<n> naming a custom transaction type by its internal id. This applies wherever it appears: here, in a lookups entry, or in a nested row's keyRef. NetSuite checks the table name and the type code before it looks anything up, and refuses a bad one by name instead of querying it.

Two more keys matter on an entry or a nested row:

KeyWhat it does
datetimesNames keys that carry a time as well as a date, read as wall-clock in the NetSuite account's timezone. Map a Salesforce Date/Time field and the key lands here automatically, as unzoned ISO-8601. A plain Date field lands in dates instead. A value not declared this way stays a plain string, which a NetSuite datetime field refuses
lookups vs keyRefBoth resolve by external id only, always the row's own Salesforce id. A lookups entry says which field to fill and what to fill it from. A keyRef only says where to resolve. Anything else inside a keyRef is refused at Activate, not ignored at run time

Sublist modes

modeWhat it does
append (default)A row whose key is already on the document updates in place. A new key appends. A row Salesforce stops sending is left alone and reported. Nothing is ever deleted. This is what every transaction line does.
matchNever inserts: the rows are NetSuite's, and the payload only says which to check and for how much, capped at each row's live open balance. This is what makes an .apply node work, with one nested row per allocation. It runs in dynamic mode, and it can't create the document it also matches, so land the document with one node and match it with a second.
replaceThe only mode that deletes rows the payload stops sending. It takes two opt-ins: the mode, plus confirmDelete on the entry.
subrecordThe row is a record of its own, not a row of columns: one column opens a nested record, and the row's values are set inside it. An entity's address book is the shipped example. subrecordId names the holding column, addressbookaddress on addressbook. ensureDefaults leaves one row flagged default per flag when the payload names none.

Every nested row names the sublist it belongs to, with sublist. Which key carries its references depends on which sublist:

SublistReference key
itemitemRef
Everything elsekeyNsId when Salesforce already knows the NetSuite row, keyRef when it can only name it by external id, or lookups for any other reference field

These come from the line object's own Write options. An .apply row never carries an itemRef, since an application row has no item:

"lines": [
  { "nodeKey": "Payment Allocation", "sublist": "apply", "sfId": "a0P…",
    "keyRef": { "table": "transaction", "tableType": "CustInvc", "value": "a0I…" },
    // or "keyNsId": "…" when the invoice's NetSuite id is already known
    "values": { "amount": 4180.00 } },
  { "nodeKey": "SO Line", "sublist": "item", "sfId": "a0Y…",
    "itemRef": { "parentNodeKey": "Product", "parentSfId": "01t…",
                "parentTable": "item", "parentEid": "01t…" },
    "values": { "quantity": 2, "rate": 150 } },
  { "nodeKey": "Shipping Address", "sublist": "addressbook", "sfId": "001…",
    "key": "Shipping",        // the native key column's value: label, not sfId
    "values": { "label": "Shipping", "defaultshipping": true,
                "addr1": "1 Dock Rd", "city": "Atlanta",
                "state": "GA", "country": "US" } } ]

A subrecord row is keyed by key, not by sfId, since every address on one account shares that account's id. The key field holds the sublist's native key column value, the label for an address book. The sfId field is provenance only, showing which Salesforce record built the row. The key column is an ordinary mapping row, so what an address matches on is configuration, not code.

NetSuite reads both shapes on every sublist; itemRef is a lookup in disguise. This is a rule about what Salesforce emits, not what the wire allows. A row with neither key present is dropped, with the reason, instead of applied to the wrong document.

A call that uses any of this says so. Salesforce stamps requiresVersion from two places: the activated configuration's verdict, and what the payload actually needed. A customer created on the way in can raise it even when the configuration alone wouldn't. Every 3.0 call already asks for the floor version, 3.0.0, and a call can stamp higher for a future release.

NetSuite refuses a payload asking for more than the installed package can do, before writing anything: {ok:false, requiresVersion, errors:["this call needs … package 3.0.0; this account runs …"]}. The check runs ahead of every action, so a downgraded account fails with the instruction instead of landing half a graph.

On the way back, the ids these entries landed come home twice. The customIds key is the flat {sfId: nsId} map. The customIdsByNode key files the same ids under the node that wrote each one:

"customIds":       { "a0X…": "…" },
"customIdsByNode": { "Fulfillment SO": { "a0X…": "…" } }

The by-node map keeps two nodes over the same Salesforce record honest. Each writes back its own document's id, instead of both taking whichever landed last. Writebacks read it first and fall back to the flat map.

How field mappings ride along

The mapping service attaches its output to each node as a reserved extra map of NetSuite field id to value, plus extraDates naming the keys needing date coercion:

{ "sfId": "5eXa…", "amount": 12000.00, …,
  "extra":      { "custrecord_cc_bsperiod_amount": 12000.00,
                  "custrecord_cc_bsperiod_period_start": "2026-01-01",
                  "custrecord_cc_bsperiod_period_end":   "2026-12-31" },
  "extraDates": [ "custrecord_cc_bsperiod_period_start", "custrecord_cc_bsperiod_period_end" ] }

NetSuite merges extra last: an unedited built-in row is a no-op, an edited row wins, and a custom row adds a field. Null or blank Salesforce values are skipped at build time, since mappings never clear NetSuite fields, and skipped rows surface in mappingWarnings and the run's error log.

Address rows are the deliberate exception. A Billing Address or Shipping Address row mirrors Salesforce instead of overriding NetSuite. Every mapped field is sent even when empty, as "", so clearing a street in Salesforce clears it in NetSuite too.

Two things bound this. A row with no street and no city isn't sent at all, so an Account with only a country still writes nothing. Country itself is never blanked on the NetSuite end, since an address with no country validates nothing. Literal rows, like the label and the default flag, are unaffected: a literal is a statement, not a mirror.

relay: the same payload, addressed elsewhere

A call pointed at another RESTlet in this account keeps the payload it would otherwise have sent and adds one key:

{ "action": "relay",
  "target": { "scriptId": "customscript_partner_thing",
              "deployId": "customdeploy_partner_thing",
              "action":   "ingest" },
  "callKey": "partner-feed", "configVersion": 4,
  "customNodes": [ … ]                       // exactly as a `custom` call would carry them
}

NetSuite strips target, swaps action for target.action (or drops it), and forwards the rest verbatim from inside the account. It also adds a relayChain array: this endpoint's own script id, appended to whatever chain the call arrived with. A relay you configure never sets this key yourself. NetSuite always replaces whatever a body carries with the chain it worked out for this hop, so a call can't reset its own history. The chain is what stops a loop, a target that relays back to you, and a run of more than three relays. The answer comes back wrapped under relay, with the target's ids and counts lifted to the top level when it speaks the same wire format. Full shape and refusals: Relaying to another RESTlet.

orderlines: a booked order's line status

Order Line Sync sends the status of an order's booked lines, without the rest of the graph. One request carries one order.

{ "action": "orderlines", "callKey": "order_line_sync", "configVersion": 3,
  "requiresVersion": "3.3.0",
  "orgId": "00D…", "orgBaseUrl": "https://….my.salesforce.com",
  "orderId": "801…", "orderNumber": "00000100",
  "lines": [ { "sfId": "802…", "delivered": true, "deliveredDate": "2026-07-15",
               "fulfilled": false, "fulfilledDate": null, "pctComplete": 40 } ],
  "continueFrom": 0 }

Only lines a booking already sent are included. Each line carries the Salesforce values as they are, blanks included. Here a blank clears the NetSuite field, so un-delivering a line clears its date.

NetSuite answers:

{ "ok": true, "version": "3.3.0", "partial": false, "continueFrom": null,
  "counts": { "lines": 3, "updated": 2, "unchanged": 1, "skipped": 0 },
  "ids": { "802…": "4521" },
  "skipped": [], "affectedContracts": [ "701" ],
  "errors": [], "notes": [] }

ids lists every line NetSuite found. skipped lists lines it has no record for yet. affectedContracts lists the contracts whose lines changed. A bad value refuses only its own line, and the others still land. When NetSuite found any line, Salesforce then refreshes the contract's obligations.

Other payload families

Invoice, payment, and refund syncs use the same envelope discipline, with their own document arrays. Invoices carry a per-line taxRate1, the Salesforce-owned tax, and an optional billing mirror-refresh section. Payments carry applications with replace semantics. Refunds carry credit applications. Credit memos are an invoices-family document too, and land with the same custbody_cc_sf_url deep link a payment carries.

Every application row, on a payment, refund, or credit memo, carries its own sfId, the allocation's Salesforce id, plus the document it applies to and the amount. Their shapes are summarized in the RESTlet actions reference.