RESTlet actions
One endpoint handles every sync, dispatched on the payload's action. Every packaged sync pushes with POST. PUT works as an alias, and eight read-only actions also answer GET. This is the wire-level catalog. For the pipeline mechanics, see Inside the RESTlet.
Calling the endpoint
The endpoint is the Continuous Control (RESTlet) script record, the one entry point for every action below. Salesforce's revenue engine also calls the Update POBs and Update Revenue Plans Suitelets directly, outside this dispatch. See The engine Suitelets for those two, and Inside the RESTlet for how a call reaches an action here.
POST https://<account>.restlets.api.netsuite.com/app/site/hosting/restlet.nl
?script=customscript_cc_control_rl&deploy=customdeploy_cc_control_rl
Authorization: OAuth 1.0a (HMAC-SHA256; query params included in the signature)
Content-Type: application/json
{ "action": "…", … }
PUT same URL, same body // alias of POST, for endpoints that require it
GET same URL &action=ping // read-only actions only: ping, setupinfo,
// installcheck, listrecordtypes, refdata, itemlist, describe,
// settingsoptions
Each call can be pointed at a different URL, method, and action. The values above are the defaults every packaged call uses. The install registers GET and PUT on the script record along with POST.
Every push call's body carries orgBaseUrl and orgId. These are the Salesforce org's My Domain host and its 18-character org id. Together they let NetSuite build "Open in NetSuite" deep links and stamp the Source Org field. See Anatomy of the payload for the full envelope shape.
Action reference
| action | Purpose | Request carries → response returns |
|---|---|---|
book (default) | Book an order, the full graph | The booking payload, including nodeTargets →{ok, partial?, continueFrom?, contractNsId, orderNsId,counts, ids, errors, notes, skippedNodes?,orderNsType?, salesOrderNsId, version} |
invoices | Land invoices + credit memos (one chunk) | invoices[] docs (typed invoice/creditmemo, per-line taxRate1, CM applications[]) + optional billing mirror refresh → ids per document. A document lands whole, with its billing mirrors and extension records, or not at all this leg |
payments | Customer payments with applications | payments[] with applications[] and replace semantics → ids. A payment lands whole, with its extension records, or not at all this leg |
refunds | Customer refunds against open credits | refunds[] with credit applications → ids. A refund lands whole, with its extension records, or not at all |
items | Standalone product sync | The booking payload's items section alone → ids. A product lands whole, with its extension records, or not at all |
customer | Standalone account/contacts sync | The customer section, with its addresses riding customNodes as the address nodes' carrier entry. → ids. The account lands whole, with its contacts and extension records, or not at all |
orderlines | Order Line Sync: a booked order's line status (ns-v3.3.0+) | {requiresVersion:"3.3.0", orderId, orderNumber,lines:[{sfId, delivered, deliveredDate,fulfilled, fulfilledDate, pctComplete}], continueFrom} →{ok, version, partial, continueFrom,counts:{lines, updated, unchanged, skipped},ids, skipped, affectedContracts, errors, notes}.Updates only the RC Order Lines a booking made, and only the fields that changed. A blank clears the field. It never creates a record. See the orderlines payload. |
custom | Run a call you've built yourself | A root entry inside customNodes[], shaped like any call's extension node. An entry with nested lines[] is a document, whether it's a transaction with item rows or a customer with address rows. Each row names its sublist. A node key prefixed __sublist: is a reserved carrier. It sets no fields of its own. It only writes sublist rows onto a record another node, or an earlier sync, already landed.→ {ok, partial?, customNodesFrom?, customIds,customIdsByNode, counts, errors, notes, version}.counts carries customNodes (records and documents landed), one custom:<nodeKey> per node, and addresses when address rows landed. |
pobs | Read a contract's POBs (the mirror source) | {contractNsId} →{ok, pobs:[…]}, each row{id, name, contract, item, booked, billed, recognized, qty,rule, startdate, enddate, delivered, delivereddate,fulfilled, fulfilleddate, pct}.pct is the POB's % Complete in points, such as 40, or null (ns-v3.3.0+).Read-only. Runs whether or not the Continuous Revenue Engine is on. |
cashapps | Reverse settlement pull | Watermark + scope → changed payments/CMs/refunds with applications, paged, maxLastModified |
files | Attach Salesforce documents | File content + target record → File Cabinet id, attached. A file lands only in the Continuous Documents folder, or a subfolder of it named by the payload's folderName (one plain name, no path separators). The folder's id is kept on Continuous Revenue Settings. Only a document or image extension is accepted; a name with no extension is stored as MISCBINARY, and a script or markup extension (.js, .html, .svg, and similar) is refused. The attach target must be a record type Continuous syncs, with a numeric NetSuite id. Every check runs before the same-name file it would replace is deleted, so a refused file never removes anything. See Files, folders, and extensions below. |
ping | Connection test | →{ok, pong:true, script, version, writeSchema, featureVersion, sublistModes, role, remainingUsage}.sublistModes lists the sublist write modes this package understands: append, match, replace, subrecord. Salesforce reads this floor instead of assuming it.role is {id, scriptId, name} for the connection's NetSuite role, or null when no user answers (never fails the call). |
setupinfo | Environment discovery (wizard step 5) | →{contractTypeId, orderTypeId, pobRecordTypeId, invoiceSearchId, …}.These are the RC Contract, RC Order, and RC POB custom record-type ids. They also include the numeric internal id of customsearch_cc_run_invoices, found by its scriptid so it resolves even on a fresh install. The pobRecordTypeId field is what lets a mirrored Performance Obligation get an "Open in NetSuite" link. See the POB mirror. |
refdata | Reference data (wizard step 7) | → subsidiaries (with addresses) + currencies |
itemlist | Item catalogue (wizard step 7 product matching) | Optional {page, pageSize, includeInactive}. page is 0-based, pageSize ranges 5–1000 and defaults to 1000, and inactive items are left out unless asked for.→ {ok, items:[{id, itemId, name, externalId, type, inactive}],page, pageSize, pageCount, total, hasMore, version}.Every item in the account, ordered by Name/Number, one page per call. The itemId field is the NetSuite Name/Number the wizard matches product codes against. The type field is the NetSuite item type code, as reported. Read-only, GET-able. |
describe | Live field metadata for the mapping and call editors | Record type →{ok, fields, types, mandatory, sublists:[{id, fields?}],sublistSource, sublistFields, subrecords?, options?}.Returns field ids and their types, the fields NetSuite won't save without, and the record's sublists. Sublist ids come back always. Their fields come back only when the request sets deep, since that costs one metadata read per sublist. The sublistSource field says how the ids were found. getSublists means the account enumerated them. probe means a curated list of common ids was checked one by one.fieldId asks for one list field's options alone. That narrower request skips sublists, sublistSource, and mandatory, since it never runs the full sweep that fills them.subrecords answers one level deeper: a sublist column that's itself a NetSuite record, filed as subrecords[sublistId][columnId] = {fields, types, mandatory}. That's how the editor offers an address book row its own addr1, city, state, and country. Naming both sublist and subrecord probes that one column directly, even without deep. A miss there lands in errors while ok stays true. |
listrecordtypes | Live record-type list for the call wizards' picker | →{ok, types:[{scriptid, name}],transactions:[{scriptid, name, txnType, lineSublist}],standardTypes:[{family, scriptid, name, txnType?, lineSublist?}]}.Returns every active custom record type in the account, plus the native transaction types the writer supports, each naming the sublist its lines land on. It also returns a curated catalogue of standard types grouped by family: entity, item, transaction, billing, other. Each entry is filtered against the features the account has switched on.The catalogue is a convenience, not an authority. There's no allow-list, so a type it doesn't list is still usable by typing it. describe is the real gate. The feature filter fails open, so a probe that errors can never hide a type the account has. Read-only. |
installcheck | Component inventory (wizard step 4) | → Present and missing record types, scripts, and searches. Package version. Parameters still on demo defaults. The fields key lists the transaction body and column fields the sync needs. That includes the three Source Org probes: custbody_cc_source_org on Sales Order, custentity_cc_source_org on Customer, and custitem_cc_source_org on a Service Item standing in for every item type. From ns-v3.3.0 it also probes custrecord_cc_line_pct_complete on the RC Order Line, the field Order Line Sync writes.The sublistModes key reports the same modes as ping. entryPoints comes back as {post, get, put}. The two Continuous Revenue Settings switches come back as booleans, engineEnabled and nativeSoAllowed, absent (meaning both on) when the settings row doesn't exist yet.role is the same {id, scriptId, name} object ping reports. When it's anything other than Administrator or the Continuous Control Integration role, notes carries: "This connection uses the <name> role. Move it to the Continuous Control Integration role. A later release limits the endpoint to that role and Administrator." This never fails the check. Every RESTlet deployment stays open to all roles in this release.Also notes, always present and possibly empty, for non-fatal diagnostics: any field or entry-point probe that couldn't run this pass. Read-only. The wizard shows a hint for a false switch, but never overrides NetSuite. |
settingsoptions | The eight account settings and their choices (wizard step 4, ns-v3.1.0+) | {requiresVersion:"3.1.0"} →{ok, version, settingsId,settings:[{key, label, value, valueText, onDefault, options, truncated}], errors}Each setting's current value, whether it's still on a shipped default, and the choices NetSuite allows. Read-only. Also answers GET. |
savesettings | Save the account settings (wizard step 4, ns-v3.1.0+) | {requiresVersion:"3.1.0", values:{key: id, …}} →The settingsoptions answer plus saved. All or nothing: one bad value writes nothing and names the setting. |
postinstall | Post-install provisioning (wizard step 4) | → Creates the settings record, and creates any missing saved searches. Idempotent. Each entry in searches[] carries a detail of present, meaning the search already existed and was left alone, or created, meaning this run built it. It's never "refreshed": a rebuild only fills what's missing. |
taxsetup | One-time US nexus provisioning | Optional state list → tax agency, nexuses, zero-rate codes per state |
relay | Hand the call on to another RESTlet in this account | target:{scriptId, deployId, action?} plus the payload to forward, or an explicit body.→ {ok, relay:{scriptId, deployId, action, sent, status, body},errors, notes, customIds?, customIdsByNode?, counts?,partial?, customNodesFrom?}.See Relaying to another RESTlet below. |
Any other action value returns {ok:false, errors:["unknown action: …"]}.
Files, folders, and extensions
The files action attaches a Salesforce-generated document to a NetSuite record. Everything lands under one shared folder:
- Folder. The top-level "Continuous Documents" folder, created once, with its id kept on Continuous Revenue Settings. A payload's optional
folderNamecreates or reuses a subfolder of it: one plain name, no path separators. A name outside that shape is refused before anything is created or deleted. - Extension. Only document and image extensions are accepted: pdf, doc, docx, xls, xlsx, ppt, pptx, png, jpg, jpeg, gif, bmp, tif, tiff, zip, csv, txt, rtf, odt, and ods. A name with none of these is saved as a plain file that NetSuite never runs. A name with no extension at all works the same way, so "DOC-1 - Kingsbridge Digital Ltd." still lands. A script or markup extension, like
.js,.html, or.svg, is refused. - Target. The record type must be one Continuous syncs, named with a numeric NetSuite internal id. That's an invoice, credit memo, customer payment, customer refund, sales order, estimate, cash sale, return authorization, customer, or the RC Contract or RC Order custom record.
Every check runs before the same-name file it would replace is deleted, so a refused file never removes an existing one. A same-name file that passes every check replaces it, since a regenerated document should refresh in place.
Governance and dispatcher errors
NetSuite meters script work per request. Every write action checks the usage left before each row, so a large request stops cleanly instead of failing outright. A call that resumes by document, rather than by section, reports a document synced only once everything under it landed. A single-document call that can't finish fails with a message telling you to sync it again.
An error the RESTlet can't otherwise handle answers {ok:false, version, errors:[…]} instead of NetSuite's own error page. The message names the action, and tells you to check the script log and run it again.
Relaying to another RESTlet
When an admin points a call at another RESTlet in this account, a script your team or a vendor already owns, Salesforce doesn't call it directly. It posts to the Continuous endpoint as usual, with action:'relay' and the target from the endpoint row. NetSuite forwards the payload from the inside:
{ "action": "relay",
"target": { "scriptId": "customscript_partner_thing",
"deployId": "customdeploy_partner_thing",
"action": "ingest" }, // optional: what the TARGET calls it
"callKey": "…", "customNodes": [ … ] // or "body": { … } to send exactly that
}
Going through us means the forward happens inside NetSuite, under the same role. There's no second credential to store, no second Remote Site entry, no OAuth signature for someone else's endpoint, and no secret ever leaves the account. Both target ids are required, since NetSuite's own RESTlet-to-RESTlet call needs the deployment as well as the script.
The target's own deployment audience must include this connection's role. A relay carries no second credential, so it runs as whichever role the connection's token uses. If the target RESTlet's deployment is scoped to a different set of roles, NetSuite refuses the in-account call itself. It comes back as an HTTP error under relay.status, not a Continuous-worded refusal. Check the target's deployment audience before pointing a call at it.
Two more limits are checked before anything is sent, both from ns-v3.2.0 (audit N16):
- The relay allowlist. Relay Allowlist on Continuous Revenue Settings (script ids, one per line). Blank allows any RESTlet except this endpoint, as before. Set, only a listed script id is allowed, matched with or without the
customscript_prefix; a numeric target matches only a numeric entry. A settings record that exists but can't be read refuses the relay rather than treating the list as blank. - Loops. Every hop appends its own script id to
relayChainin the forwarded body. NetSuite refuses to send when the chain already names this endpoint, meaning the call already came back around. It also refuses a target already in the chain, or a chain that would pass three relays.
What goes over the wire is your payload minus the two routing keys that are ours. The target key is removed. The action key becomes target.action, or is dropped entirely when the target doesn't use one, since relay is our word, not theirs. Everything else rides verbatim. Send body instead when the call's own shape and the target's differ. Then that object is sent as-is.
What comes back is the target's answer, wrapped and never rewritten. relay.sent says whether the request ever left NetSuite. When it's true, relay.status is the HTTP status, and relay.body is its answer: parsed when it's JSON, raw text otherwise.
If the target speaks the same wire format Continuous does, its customIds, customIdsByNode, counts, ids, pobs, errors, notes, partial, and customNodesFrom are lifted to the top level. That's what lets Salesforce's writeback and continuation loops treat a relayed call the same way as a direct one.
Don't relay Order Line Sync. The relay doesn't lift its skipped, affectedContracts or continueFrom keys, so a relayed run loses its skipped-line warnings and can't resume.
If it speaks something else, nothing is lifted. ok follows the HTTP status, and a note says so. The run records "sent" rather than failing.
relay.body is capped at 65,536 characters. Without the cap, a chatty target could carry its answer twice: once whole, and once as the lifted keys. That could turn a working relay into an opaque "response too large" failure. The lift happens first, so the machine-readable half always survives. Past the cap, the body is replaced by a marker saying how big it was, and relay.truncated is set.
One leg per call. The relay never chunks a payload. It forwards what it was given, cursor and all, and passes the target's own partial and customNodesFrom back up. Governance sizing belongs to the two ends, not to the courier between them.
NetSuite refuses a relay before sending anything, for any of these reasons:
- The target is this endpoint itself. That's a loop, or a second and weaker door into an action you could name directly. NetSuite checks every id form it accepts, the script id string and the internal id the Scripts list shows, and names whichever half matched.
- The target is missing its script or deployment id.
- A numeric target can't be checked against our own ids, because the lookup failed. The message names whichever id was unverifiable, never the half that already answered.
- The chain already names this endpoint or the target, or the chain would pass three relays. The message names which.
- The target isn't on the Relay Allowlist, when one is set. The message names the script id and where to add it.
- The transport itself fails: the id doesn't exist, it's a Suitelet rather than a RESTlet, the connection's role isn't in the target's own deployment audience, or the call times out. Nothing was delivered, so NetSuite's own message rides in
errorsinstead of being dressed up as a status.
Nothing answered means no status. Every refusal comes back ok:false, partial:false, relay.sent:false, and relay.status:null. It never fakes a 0, which downstream would read as "the target answered badly" when really it was never asked. Salesforce reports these as not sent, with NetSuite's reason, and fails the run. The same payload would be refused the same way again.
There's one other not-sent answer, and it means the opposite, told apart by partial. A leg NetSuite stopped for governance comes back ok:true, partial:true, with the same customNodesFrom cursor, and the run re-posts it.
The relay is a courier, not an adapter. It doesn't know or care what the target's payload means, and it doesn't translate. Vendor-specific adapters aren't built until a specific vendor needs one.
The write block
Every push action can carry record entries an admin configured, in customNodes[]. The book action carries them alongside the fixed order graph. The invoices, payments, refunds, items, and customer actions carry them alongside their own documents. The custom action is nothing but those entries. They all go through one engine and one record writer, so an entry means the same thing no matter which action carries it.
Any entry can add an optional write block saying how the record should be written. Every key is optional, and an entry without a write block uses the defaults. The full schema is annotated in Anatomy of the payload.
What write controls | |
|---|---|
| External id | Which table resolves it, and the type code for a transaction |
| Resolution | Whether to resolve by external id at all |
| Sublists | Which ones it drives, and in what mode: append, match, replace, subrecord |
| Creation | Whether the entry may be created, and what a create needs |
| Form | A custom form to use |
| Field naming | Which fields are set by label instead of internal id |
| Clearing | Which fields may be cleared |
| Field access | Which default-blocked field ids are lifted for this entry |
| Lookups | Field-level lookups |
A sublist row can be a record. Alongside append, match, and replace, a sublist can be driven in subrecord mode. An entity's address book works this way. The row itself holds only a label and the two default flags. The address lives in a nested record hanging off one of its columns.
Two keys go with the mode. subrecordId names that column. It defaults to addressbookaddress on an addressbook, and is required on anything else, since guessing wrong would write the address into a plain column. The ensureDefaults key, on by default for an addressbook, settles the default billing and shipping flags across the record after the rows are written.
The rules, in short:
- Keyed by a native column. That's
labelfor an address book, taken from the row's ownkey, never from its Salesforce id, since every address on one account shares that id. - Routed by field id. A sublist column lands on the row. Everything else lands on the subrecord.
- Mirrors the payload. A key sent blank blanks that field. A key nobody sent is left alone.
countryis the one exception: it's written first, and never blanked, since an address with no country validates nothing. - Never deletes. A row a NetSuite user added isn't even reported as unmatched.
Settling the flags is a whole-record pass, not a per-row one. The row the payload marks default is the only one that keeps that flag. A record left with only one address carries both flags. A lone existing row already carrying a flag gets adopted and relabeled instead of duplicated. That's what keeps a matched customer's existing NetSuite addresses from being copied.
Lookups resolve by external id, and only by external id. A lookups entry, and a nested row's keyRef, names a table and the external id of the record it wants. There's no by key: the writer doesn't read one, and Salesforce doesn't emit one, so a resolution mode isn't something a payload can select. A miss becomes a per-field error naming the value, and the record still writes.
Any action's payload can also carry requiresVersion, the package version the call needs. Salesforce stamps it from the activated configuration and from what the built payload turned out to need. NetSuite checks it first, ahead of the action. A payload asking for more than the installed package can do is refused with {ok:false, requiresVersion, errors:[…]}, and nothing is written.
Response envelope
Every write action shares this envelope:
| Field | Holds |
|---|---|
ok | Overall success |
errors[] | Accumulated errors, including non-fatal ones |
counts | Per-section record counts |
ids | The Salesforce id to NetSuite internal id map that drives writebacks |
version | The NetSuite package version that answered |
Two informational arrays ride alongside. The notes[] array covers things worth knowing that aren't errors: a section skipped, a line NetSuite kept that the payload stopped sending, or the native Sales Order generator standing down. That last one reads native sales order skipped - Order targets salesorder when a Sales Order node booked the run. It reads native sales order skipped - Disable Native Sales Orders is checked in Continuous Revenue Settings when NetSuite refuses it instead. The skippedNodes[] array names each node the payload deliberately omitted.
Long-running writes can return partial:true with continueFrom. The Salesforce worker resumes automatically. See governance and resume.
A response that landed records for extension nodes carries two id maps instead of one. The customIds key is the flat Salesforce id to NetSuite internal id map. The customIdsByNode key files the same ids under the node that wrote them, as {nodeKey: {sfId: nsId}}. That's what keeps two nodes over the same Salesforce record unambiguous: each one writes back its own document's id, instead of both taking whichever landed last. Writebacks prefer the by-node map and fall back to the flat one. Both keys are absent when a run landed no extension records at all.