SuitePortalSuitePortal
Guides

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:

  1. Records are fetched from NetSuite via SuiteQL as usual
  2. After all records are collected, SuitePortal calls the Universal RESTlet's batchlistfiles action
  3. The RESTlet searches the NetSuite File Cabinet for files attached to each record
  4. Attachment metadata (file name, type, size) is stored alongside each record in MongoDB
  5. 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

  1. Copy the script below into a file called sp_universal_restlet.js
  2. In NetSuite, go to Documents > Files > File Cabinet
  3. Create a folder called SuitePortal under SuiteScripts (or use an existing scripts folder)
  4. Upload sp_universal_restlet.js to that folder

If you've already deployed the Universal RESTlet for PDF generation, skip to Update the Script to add the batchlistfiles action.

sp_universal_restlet.js
/**
 * 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

  1. Go to Customization > Scripting > Scripts > New
  2. Select the uploaded sp_universal_restlet.js file
  3. Click Create Script Record
  4. Set the following fields:
FieldValue
NameSuitePortal Universal
IDcustomscript_sp_universal
StatusReleased
  1. Click Save

Create the Script Deployment

  1. On the script record, click the Deployments tab
  2. Click New Deployment
  3. Set the following fields:
FieldValue
TitleSuitePortal Universal
IDcustomdeploy_sp_universal
StatusReleased
Log LevelError
Execute As RoleThe same role used for TBA credentials
Audience > RolesAdd the TBA role
  1. 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: 1

Role Permissions

The TBA role needs these permissions for the RESTlet to function:

PermissionLevelUsed By
SuiteScriptFullRequired to execute RESTlets
RESTletsFullRequired to call the deployment
TransactionsViewpdf action — render transaction PDFs
File CabinetViewfile, listfiles, batchlistfiles — access attached files

Step 2: Configure the RESTlet in SuitePortal

  1. In your SuitePortal tenant, go to Organization > NetSuite tab
  2. Scroll to the Universal RESTlet section
  3. Enter the Script ID and Deploy ID (numeric values from the previous step)
  4. Click Test — this sends a ping action to verify the RESTlet is reachable
  5. Click Save

Step 3: Enable Attachments on a Sync Config

  1. Navigate to Syncs in your tenant portal
  2. Click into the sync configuration you want (e.g., Invoices)
  3. In the Trigger panel (the first node in the flow), check Sync File Attachments
  4. 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=23

Attachment 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):

  1. Copy the updated script code from above
  2. In NetSuite, go to the script record's Files tab
  3. Replace the script file with the new version
  4. 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 SizeAdditional RESTlet CallsTypical Added Time
1-100 records12-5 seconds
101-500 records2-55-15 seconds
500+ records5+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

ErrorCauseFix
SSS_INVALID_SCRIPT_ID_1Script/Deploy ID wrong or role not in audienceVerify IDs and add TBA role to deployment audience
INSUFFICIENT_PERMISSIONRole lacks required permissionsAdd File Cabinet and Transaction View permissions
Universal RESTlet not configuredScript/Deploy IDs not set in SuitePortalEnter IDs in Organization > NetSuite
Ping works but PDF failsRole can't render transactionsAdd Transaction View permission to the TBA role
No attachments after syncFiles not attached to records, or missing File Cabinet permissionCheck the Communication > Files subtab on the transaction in NetSuite
Attachment badge shows but download failsTBA credentials expired, file deleted, or folder permissionsRe-authenticate in Settings, re-sync, or check role's folder access

On this page