Inside the RESTlet
Every inbound sync lands on one NetSuite endpoint: the Continuous Control (RESTlet) script, cc_control_rl. It takes POST, treats PUT the same way, and answers a handful of read-only actions over GET. It dispatches on the payload's action, resolves everything by external id before writing, and answers with the id map Salesforce uses for writebacks.
The endpoint
The endpoint is the Continuous Control (RESTlet) script record, customscript_cc_control_rl / customdeploy_cc_control_rl. The install creates it, and NetSuite registers POST, PUT, and GET from the start. There's nothing to re-save by hand.
Everything on this page is that one dispatch. The revenue engine that runs right after a sync is separate: Salesforce calls its Update POBs and Update Revenue Plans Suitelets directly, not through here. See The engine Suitelets.
One endpoint, many actions
A single deployment serves every sync type, routed by the payload's action value; the table below lists each one and its module. POST carries writes, and PUT works the same way for an endpoint that requires it. Seven actions are read-only and also answer GET: ping, setupinfo, installcheck, listrecordtypes, refdata, itemlist, and describe. An unknown action returns {ok:false, errors:["unknown action: …"]}.
describe reads a record type's shape for the two editors. It goes one level deeper, too. A sublist column that's itself a NetSuite record returns its own fields. That's what lets an address book row map field by field, not as an opaque blob. See the RESTlet actions reference for the full catalog.
A dispatcher, and a module per action
The entry script writes nothing, in about a hundred lines. It only owns the version identity, the per-call requiresVersion refusal, and the routing table below. Everything else lives in its own module, so you can read one action on its own.
| Module | Answers | What it owns |
|---|---|---|
cc_control_ctx | none | The shared context: deployment parameters, helpers, the external-id resolvers, the upsert and apply-sublist primitives, the write-only-what-changed helpers, the Source Org stamp, the custom-node id maps, and the booking response envelope |
cc_action_book | book, pobs | The order graph's twelve sections, and the per-node target map. The native Sales Order leg, stood down with a note when Disable Native Sales Orders is checked in Continuous Revenue Settings. Orphan cleanup. The POB read-back (the pobs action): the same graph in reverse. See Writebacks & the POB mirror for the field list |
cc_action_invoices | invoices | Posted invoices and credit memos, Salesforce-owned tax, and the billing-mirror refresh |
cc_action_payments | payments, refunds, cashapps | Everything that drives the apply sublist, plus the NetSuite-to-Salesforce settlement pull |
cc_action_items | items | The booking's items section, standalone |
cc_action_customer | customer | The booking's customer and contacts sections, standalone |
cc_action_orderlines | orderlines | Order Line Sync. It updates the delivery, fulfillment and % Complete fields on RC Order Lines a booking made, writing only what changed. It answers which contracts changed |
cc_action_custom | custom | The admin-mapped extension nodes. It's also the engine every other action reaches for when its payload carries customNodes |
cc_action_relay | relay | Handing a call's payload on to another RESTlet in this account, under the same role, and wrapping its answer |
cc_action_setup | ping, setupinfo, refdata, itemlist, describe, listrecordtypes, installcheck, settingsoptions, savesettings, postinstall, taxsetup, files | What an operator asks the account, not asks it to write.itemlist pages the item catalogue for the wizard's product matching.installcheck also reports the two Continuous Revenue Settings switches, engineEnabled and nativeSoAllowed.setupinfo resolves the contract, order, and POB record types, and the run-invoices search, by scriptid, since SuiteQL can only look up a saved search by name, not its scriptid |
cc_record_writer_lib | none | The one record writer every action's entries go through (see below) |
The sharing runs one way. Standalone product and account syncs are the booking's own sections, so they call cc_action_book instead of carrying a second copy. Invoice sync re-runs three billing sections to refresh the mirrors an invoice consumed. Six actions share one custom-node engine. None of this changes the wire format.
The booking pipeline
A booking executes as an ordered list of sections. The order is a contract of the implementation.
items → customer → contacts → contract → order → lines
→ bsgs → schedules → periods → customnodes → cleanup → nativeso
- items run first, so lines, schedules, and usage billing can reference resolved item ids.
- contract runs before order and lines, since it's the parent of the graph. It also looks up the contract's existing POBs by item, so new lines and schedules auto-attach to the right POB when one exists. Only the engine creates POBs, never the sync.
- customnodes runs the entries an admin configured, including the customer's addresses. Addresses arrive as address book rows rather than inside the customer section, which is why they land after the customer instead of with it.
- cleanup deletes billing-period mirrors Salesforce has dropped, the pipeline's one deliberate delete, scoped to this payload's schedules.
Two preconditions guard the whole run: the payload must carry an order id and a contract ("Order has no Contract. Add a Contract to the order, then book again.").
Resolve by external id
Before any section runs, the RESTlet bulk-resolves every incoming external id, one SuiteQL query per table: items, customers, contacts, contracts, orders, lines, billing groups, schedules, and periods. A resolved id means update; a miss means create.
This is why re-sends are idempotent, and why the sync tolerates records made by earlier runs, other tools, or hand entry, as long as the external id convention holds. See Sync runs & idempotency.
Caller-sent NetSuite ids
Salesforce sometimes already knows a record's NetSuite id from an earlier sync, and sends it along. NetSuite checks that id before it trusts it.
The id is used only when the record exists and no other Salesforce record already claims it. A blank claim never counts as someone else's. That lets a product matched by hand in wizard step 7 keep working when a booking or Product Sync sends its id. Otherwise NetSuite ignores the id, looks the record up by its Salesforce id instead, and the run gets a note: "NetSuite id <n> belongs to a different record. It was ignored, and the record was looked up by its Salesforce id."
A line on a document you add, or on a call you build, sends no item id. This check never runs for it. Its item is found by External ID alone. See Add a transaction and its lines.
A payment's or refund's application target is checked the same way. If the target doesn't exist, or isn't the right kind of document, that one application is skipped, and the run gets an error asking you to sync the target again.
Query safety
Every value a payload, a mapping row, or a record supplies goes into a query as a bound parameter, never pasted into the query text. The only things a query can't bind are the table name and, for a transaction, its type code.
Both are checked against a fixed shape before any query runs. A bad one is refused for that field or entry, naming the value, and the rest of the payload still lands. A transaction's type code is one of NetSuite's own codes, or Custom<n> for a custom transaction type.
Upsert semantics
All writes funnel through one upsert helper.
- Empty values are stripped before writing. A null or blank value in the payload never clears a NetSuite field.
- Record names are set only on create, so NetSuite-side renames survive re-syncs.
- Mapping extras merge last, behind a blocklist of structural fields:
id,externalid,name,entity,recordtype,customform,isinactive,item. - A subrecord is the one exception. Inside a nested record, like the address on an address book row, a blank key does clear that field. That's because an address has to agree with itself.
countryalone is never cleared.
id, externalid, and recordtype can never be written. Everything else can be lifted for one entry through write.allowFields, except entity on a document that already exists: reassigning a saved transaction to another customer always gets refused.
The Source Org stamp
Every request names the Salesforce org it came from, as orgBaseUrl and orgId on the envelope. cc_control_rl stores them once per request, and every write that follows carries the stamp.
upsert() stamps the field for its record's family, one of thirteen: the ten Continuous custom records, plus one field each for customers and contacts, items, and the applicable transaction types. The admin-mapped extension path stamps itself the same way, since it writes through cc_record_writer_lib instead of upsert().
No orgId on the wire sets nothing and clears nothing, and a mapping row can never override the stamp either way.
Per-node targets
Before the pipeline runs, the RESTlet reads the payload's nodeTargets map and gives every section a mode:
| Mode | Meaning |
|---|---|
| default | The Continuous custom record the node ships with. This is the classic path, byte for byte. |
| omitted | The node is switched off or deleted. The section is skipped and named in skippedNodes, with a note. Links into the record still resolve from the bare reference in the payload. |
| transaction | The Order lands as a native document (Sales Order, Estimate, Return Authorization, or a posting type if the admin insisted). |
| lines | The order lines become that document's item sublist rather than their own records. |
Order and Order Line are enforced as a pair: a transaction Order whose line node points anywhere else is refused instead of half-written. An unsupported record type is reported as an error naming the version needed, and only that section is skipped.
One generic record writer
One shared module, cc_record_writer_lib, writes every record a payload entry can target: a Continuous custom record, a native transaction, a customer. No action carries its own writing logic; each hands an entry to this module, through the optional write block, and the module decides how it gets written.
- The allow-list is gone. A live metadata probe proves a record type exists, so one you created this morning works this morning. A type that doesn't exist is refused by name, and the other entries in the same payload still land.
- Sublists are declared, not assumed. An entry says which sublists it drives and in what mode. That's what lets an
.applynode, a payment or credit memo and its allocations, write through the generic path instead of needing a bespoke handler. - A sublist row can be a record. In
subrecordmode, a row holds a key and a flag or two, and the rest lives in a nested record it opens. An entity's address book is the shipped example, and it's how the packaged address nodes write. The writer keys each row on a native column, never deletes one, and settles the default billing and shipping flags afterward. - An entity is created only when an entry asks. Customers and contacts stay update-only by default, since creating one by accident is the expensive mistake. An entry can opt in with
write.allowCreateand name what a create needs. Vendors, employees, and partners stay update-only until the package has per-type rules for them.
The writer throws only when a document can't be written at all. Everything survivable, like an unknown field id or unresolved lookup, collects into errors[] instead, so one bad mapping row doesn't fail the run.
The transaction writer
A transaction target is written in standard, non-dynamic mode, and it's append-only. The document is resolved by external id, never by a NetSuite id Salesforce happens to be holding, since after a re-point that id could belong to a different record type. Every line is keyed by custcol_cc_sf_line_id, the Salesforce line id.
- A keyed line that matches is updated in place.
- A line Salesforce sent with no match is appended.
- A line NetSuite holds that Salesforce stopped sending is left alone, counted, and reported once in
notes[]. Nothing is deleted: a billed or fulfilled line can't be.
A quantity change makes NetSuite recompute amount as rate times quantity, so the Salesforce amount gets re-asserted on the next sync, needing one extra run to converge. A document type whose item sublist lacks the key column refuses its lines outright, instead of duplicating them on every re-sync.
A Sales Order the Order node lands also carries the RC contract link and the Do Not Bill (Continuous) flag, set from the Allow NetSuite billing setting. A transaction an extension node lands gets neither. It carries only the fields that node maps.
Governance and resume
NetSuite meters script work in governance units per request. The RESTlet checks the usage left before every row, not only between sections, so one big section can't run out of usage mid-write. Below a floor of 300 units, it stops cleanly and returns partial:true, with continueFrom naming where to resume.
continueFrom names a section, or a section and a row, like periods:638. Salesforce echoes it back and resends the same payload. Resume re-resolves every id first, then only the named section skips the rows already done in it.
Order Line Sync resumes by line: continueFrom is the index of the next line to update.
Invoices, payments, and refunds resume by document instead of by row. A document's id comes back only once it and everything it carries has landed, so a leg that runs low mid-document sends that whole document again next time.
Salesforce keeps resending for up to 25 legs, and stops the chain if a leg's resume point doesn't move. Operators never see the seam: it's one run with extra legs.
The response
{
"ok": true,
"partial": false, // true + continueFrom on a governance pause
"contractNsId": "…",
"orderNsId": "…", // the RC Order, or the transaction the Order node landed
"counts": { "customer": 1, "contacts": 2, "items": 7, "contract": 1, "order": 1,
"lines": 7, "bsgs": 9, "schedules": 9, "periods": 22, "orphansDeleted": 0,
"txnLinesAdded": 7, "txnLinesUpdated": 0, "txnLinesUnmatched": 0,
"schedulesWithoutLine": 9 }, // the last four only when a transaction was written
"ids": { "<each Salesforce id>": "<its NetSuite internal id>" },
"orderNsType": "salesorder", // present when the Order landed as a transaction
"salesOrderNsId": "…", // the mapped SO, or the generated one — never both
"notes": [ "native sales order skipped - Order targets salesorder" ], // or "… - Disable Native Sales Orders is checked in Continuous Revenue Settings"
"skippedNodes": [ "Customer" ], // present when the payload omitted a node
"version": "3.0.1",
"errors": []
}
One gate applies before any of that runs. A payload can carry requiresVersion, the package version its call needs. NetSuite refuses a payload asking for more, with {ok:false, requiresVersion, errors:[…]} and nothing written, so a downgraded account fails with the upgrade instruction instead of landing part of the graph.
The ids map is the writeback contract: every record touched, keyed by Salesforce id. counts feed the card's summary, and errors append to the run's error log. The deployment also carries account-level script parameters, like the default subsidiary, the Deferred Revenue account, and tax settings. See the settings reference; the wizard's install check validates them.