Sync File Attachments
Deploy the SuitePortal Universal RESTlet and enable file attachment syncing so portal users can view and download documents from NetSuite transactions.
Sync file attachment metadata from NetSuite so portal users can see and download files (PDFs, tax documents, contracts, etc.) attached to transactions.
How It Works
When attachment syncing is enabled on a sync configuration:
- Records are fetched from NetSuite via SuiteQL as usual
- After all records are collected, SuitePortal calls the Universal RESTlet's
batchlistfilesaction - The RESTlet searches the NetSuite File Cabinet for files attached to each record
- Attachment metadata (file name, type, size) is stored alongside each record in MongoDB
- Portal users see an attachment count badge and can expand the row to view and download files
Only metadata is synced (name, type, size, file ID). The actual file content is fetched on-demand when a user clicks download, keeping sync fast and storage minimal.
Step 1: Deploy the Universal RESTlet
The Universal RESTlet is a single SuiteScript 2.1 script that handles all document and file operations between SuitePortal and NetSuite — PDF generation, file downloads, and attachment syncing.
Upload the Script
- Copy the script below into a file called
sp_universal_restlet.js - In NetSuite, go to Documents > Files > File Cabinet
- Create a folder called
SuitePortalunder SuiteScripts (or use an existing scripts folder) - Upload
sp_universal_restlet.jsto that folder
If you've already deployed the Universal RESTlet for PDF generation, skip to Update the Script to add the batchlistfiles action.
/**
* SuitePortal Universal RESTlet
*
* A single RESTlet deployed per tenant that handles all document/file operations
* for the SuitePortal customer portal.
*
* Script ID: customscript_sp_universal
* Deployment ID: customdeploy_sp_universal
*
* @NApiVersion 2.1
* @NScriptType Restlet
* @NModuleScope SameAccount
*/
define(["N/render", "N/record", "N/file", "N/search", "N/encode", "N/runtime", "N/query"], function (
render,
record,
file,
search,
encode,
runtime,
query,
) {
function response(action, data, error) {
if (error) {
return {
success: false,
action: action,
data: null,
error: typeof error === "string" ? error : error.message || String(error),
};
}
return { success: true, action: action, data: data, error: null };
}
/** action=pdf — Render a transaction record as PDF. */
function handlePdf(params) {
var recordType = params.recordType;
var recordId = params.recordId;
if (!recordType || !recordId) {
return response("pdf", null, "recordType and recordId are required");
}
var rec = record.load({ type: recordType, id: Number(recordId) });
var pdfFile = render.transaction({
entityId: Number(recordId),
printMode: render.PrintMode.PDF,
});
var contents = encode.convert({
string: pdfFile.getContents(),
inputEncoding: encode.Encoding.BASE_64,
outputEncoding: encode.Encoding.BASE_64,
});
var tranId = rec.getValue({ fieldId: "tranid" }) || recordId;
return response("pdf", {
filename: recordType + "-" + tranId + ".pdf",
content: contents,
mimeType: "application/pdf",
});
}
/** action=file — Fetch a single file from the File Cabinet by internal ID. */
function handleFile(params) {
var fileId = params.fileId;
if (!fileId) {
return response("file", null, "fileId is required");
}
var f = file.load({ id: Number(fileId) });
return response("file", {
filename: f.name,
content: encode.convert({
string: f.getContents(),
inputEncoding: encode.Encoding.BASE_64,
outputEncoding: encode.Encoding.BASE_64,
}),
mimeType: f.fileType,
size: f.size,
});
}
/**
* Find files attached to a transaction by searching the transaction
* type and joining to "file" (record-level attachments via Communication > Files).
*/
function getTransactionFiles(recordType, recordId) {
var rid = Number(recordId);
var files = [];
var seen = {};
var tranSearch = search.create({
type: recordType,
filters: [["internalidnumber", "equalto", rid]],
columns: [
search.createColumn({ name: "internalid", join: "file" }),
search.createColumn({ name: "name", join: "file" }),
search.createColumn({ name: "documentsize", join: "file" }),
search.createColumn({ name: "filetype", join: "file" }),
search.createColumn({ name: "created", join: "file" }),
],
});
tranSearch.run().each(function (result) {
var fileId = result.getValue({ name: "internalid", join: "file" });
if (fileId && !seen[fileId]) {
seen[fileId] = true;
files.push({
id: String(fileId),
name: result.getValue({ name: "name", join: "file" }) || "unknown",
type: result.getValue({ name: "filetype", join: "file" }) || "",
size: result.getValue({ name: "documentsize", join: "file" }) || "0",
created: result.getValue({ name: "created", join: "file" }) || null,
});
}
return true;
});
return files;
}
/** action=listfiles — List files attached to a single record. */
function handleListFiles(params) {
var recordType = params.recordType;
var recordId = params.recordId;
if (!recordType || !recordId) {
return response("listfiles", null, "recordType and recordId are required");
}
var files = getTransactionFiles(recordType, recordId);
return response("listfiles", { files: files });
}
/** action=batchlistfiles — List files for multiple records at once (POST only). */
function handleBatchListFiles(params) {
var recordType = params.recordType;
var recordIds = params.recordIds;
if (!recordType || !recordIds || !recordIds.length) {
return response("batchlistfiles", null, "recordType and recordIds are required");
}
var attachments = {};
var errors = [];
for (var i = 0; i < recordIds.length; i++) {
var rid = Number(recordIds[i]);
try {
attachments[String(rid)] = getTransactionFiles(recordType, rid);
} catch (e) {
attachments[String(rid)] = [];
errors.push({ recordId: rid, error: e.message || String(e) });
}
}
return response("batchlistfiles", { attachments: attachments, errors: errors });
}
/**
* Search for messages linked to records by transaction or entity.
* Returns messages grouped by record ID.
*/
function getRecordMessages(linkType, recordId) {
var rid = Number(recordId);
var messages = [];
var sql =
"SELECT m.id, m.subject, m.messagedate, m.authoremail, m.recipientemail, " +
"m.incoming, m.hasattachment, m.message " +
"FROM message m " +
"WHERE m." + linkType + " = ? " +
"ORDER BY m.messagedate DESC";
var results = query.runSuiteQL({ query: sql, params: [rid] });
var rows = results.asMappedResults();
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
messages.push({
id: String(row.id),
subject: row.subject || "(No Subject)",
date: row.messagedate || "",
authorName: row.authoremail || "",
recipientName: row.recipientemail || "",
incoming: row.incoming === "T",
hasAttachment: row.hasattachment === "T",
body: row.message || "",
});
}
return messages;
}
/** action=batchlistmessages — List messages for multiple records at once (POST only). */
function handleBatchListMessages(params) {
var linkType = params.linkType;
var recordIds = params.recordIds;
if (!linkType || !recordIds || !recordIds.length) {
return response("batchlistmessages", null, "linkType and recordIds are required");
}
var messages = {};
var errors = [];
for (var i = 0; i < recordIds.length; i++) {
var rid = Number(recordIds[i]);
try {
messages[String(rid)] = getRecordMessages(linkType, rid);
} catch (e) {
messages[String(rid)] = [];
errors.push({ recordId: rid, error: e.message || String(e) });
}
}
return response("batchlistmessages", { messages: messages, errors: errors });
}
/** action=getmessagebody — Load a single message's full body. */
function handleGetMessageBody(params) {
var messageId = params.messageId;
if (!messageId) {
return response("getmessagebody", null, "messageId is required");
}
var rec = record.load({ type: "message", id: Number(messageId) });
return response("getmessagebody", {
messageId: String(messageId),
subject: rec.getValue({ fieldId: "subject" }) || "",
body: rec.getValue({ fieldId: "message" }) || "",
});
}
/** action=ping — Health check / connection test. */
function handlePing() {
return response("ping", {
status: "ok",
timestamp: new Date().toISOString(),
account: runtime.accountId,
});
}
function get(context) {
var action = context.action || "ping";
try {
switch (action) {
case "pdf": return handlePdf(context);
case "file": return handleFile(context);
case "listfiles": return handleListFiles(context);
case "batchlistfiles": return handleBatchListFiles(context);
case "batchlistmessages": return handleBatchListMessages(context);
case "getmessagebody": return handleGetMessageBody(context);
case "ping": return handlePing();
default: return response(action, null, "Unknown action: " + action);
}
} catch (e) {
log.error({ title: "SP Universal RESTlet Error", details: e.message || e });
return response(action, null, e.message || String(e));
}
}
function post(context) {
var action = context.action || "ping";
try {
switch (action) {
case "pdf": return handlePdf(context);
case "file": return handleFile(context);
case "listfiles": return handleListFiles(context);
case "batchlistfiles": return handleBatchListFiles(context);
case "batchlistmessages": return handleBatchListMessages(context);
case "getmessagebody": return handleGetMessageBody(context);
case "ping": return handlePing();
default: return response(action, null, "Unknown action: " + action);
}
} catch (e) {
log.error({ title: "SP Universal RESTlet Error", details: e.message || e });
return response(action, null, e.message || String(e));
}
}
return { get: get, post: post };
});Create the Script Record
- Go to Customization > Scripting > Scripts > New
- Select the uploaded
sp_universal_restlet.jsfile - Click Create Script Record
- Set the following fields:
| Field | Value |
|---|---|
| Name | SuitePortal Universal |
| ID | customscript_sp_universal |
| Status | Released |
- Click Save
Create the Script Deployment
- On the script record, click the Deployments tab
- Click New Deployment
- Set the following fields:
| Field | Value |
|---|---|
| Title | SuitePortal Universal |
| ID | customdeploy_sp_universal |
| Status | Released |
| Log Level | Error |
| Execute As Role | The same role used for TBA credentials |
| Audience > Roles | Add the TBA role |
- Click Save
The deployment's Execute As Role and Audience must include the role used by your TBA access token. If the role doesn't have access to the deployment, API calls will return SSS_INVALID_SCRIPT_ID_1.
Note the Script and Deploy IDs
After saving the deployment, you need the numeric Script ID and Deploy ID (not the string IDs). Find them by looking at the URL when viewing the script or deployment:
app/common/scripting/scriptrecord.nl?id=1234 → Script ID: 1234
app/common/scripting/scriptdeployment.nl?script=1234&deploy=1 → Deploy ID: 1Role Permissions
The TBA role needs these permissions for the RESTlet to function:
| Permission | Level | Used By |
|---|---|---|
| SuiteScript | Full | Required to execute RESTlets |
| RESTlets | Full | Required to call the deployment |
| Transactions | View | pdf action — render transaction PDFs |
| File Cabinet | View | file, listfiles, batchlistfiles — access attached files |
Step 2: Configure the RESTlet in SuitePortal
- In your SuitePortal tenant, go to Organization > NetSuite tab
- Scroll to the Universal RESTlet section
- Enter the Script ID and Deploy ID (numeric values from the previous step)
- Click Test — this sends a
pingaction to verify the RESTlet is reachable - Click Save
Step 3: Enable Attachments on a Sync Config
- Navigate to Syncs in your tenant portal
- Click into the sync configuration you want (e.g., Invoices)
- In the Trigger panel (the first node in the flow), check Sync File Attachments
- Click Save Changes
Step 4: Run a Sync
Trigger a manual sync on the updated configuration. The sync log will show attachment fetch progress:
Attachment fetch complete: totalRecords=142, recordsWithAttachments=23Attachment fetching is non-blocking. If the RESTlet call fails (e.g., permissions issue), the sync still completes successfully — you'll see a warning in the logs but no records are lost.
Portal Experience
Once attachments are synced, portal users will see:
- Attachment badge — A file icon with count appears in the actions column for records that have attachments
- Expandable file list — Clicking the badge (or expanding the row) reveals the Attachments section below line items
- Download links — Each file shows its name, size, and a download link that fetches the file on-demand from NetSuite
Update an Existing RESTlet
If you've already deployed the Universal RESTlet and need to update it (e.g., to add the batchlistfiles action):
- Copy the updated script code from above
- In NetSuite, go to the script record's Files tab
- Replace the script file with the new version
- Click Save
No deployment changes needed — the existing deployment picks up the new script file automatically.
Performance
Attachment metadata is fetched in batches of 100 record IDs per RESTlet call to avoid oversized requests.
| Sync Size | Additional RESTlet Calls | Typical Added Time |
|---|---|---|
| 1-100 records | 1 | 2-5 seconds |
| 101-500 records | 2-5 | 5-15 seconds |
| 500+ records | 5+ | 15-30 seconds |
Supported Record Types
File attachments can be synced for any record type that supports the NetSuite Files subtab, including:
- Invoices
- Sales Orders
- Purchase Orders
- Quotes / Estimates
- Credit Memos
- Vendor Bills
- Cash Sales
- Customer Deposits
- Return Authorizations
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
SSS_INVALID_SCRIPT_ID_1 | Script/Deploy ID wrong or role not in audience | Verify IDs and add TBA role to deployment audience |
INSUFFICIENT_PERMISSION | Role lacks required permissions | Add File Cabinet and Transaction View permissions |
Universal RESTlet not configured | Script/Deploy IDs not set in SuitePortal | Enter IDs in Organization > NetSuite |
| Ping works but PDF fails | Role can't render transactions | Add Transaction View permission to the TBA role |
| No attachments after sync | Files not attached to records, or missing File Cabinet permission | Check the Communication > Files subtab on the transaction in NetSuite |
| Attachment badge shows but download fails | TBA credentials expired, file deleted, or folder permissions | Re-authenticate in Settings, re-sync, or check role's folder access |
Configure Scheduled Syncs
Set up automatic NetSuite data synchronization schedules. Configure hourly, daily, or custom sync intervals to keep your customer portal data current.
Public API
Use SuitePortal's Public API to query your synced NetSuite data at scale. Build integrations, dashboards, and workflows without hitting NetSuite's API limits.