Suite Utils
Back to Blog
SuiteScriptSep 19, 2026 • 8 min read

Backup NetSuite Financial Data to AWS S3 via RESTlet

Back up NetSuite financials without email limits: push structured JSON to AWS S3 from a RESTlet.

Sarah Jenkins, CPASarah Jenkins, CPAPrincipal Finance Automation Specialist
Backup NetSuite Financial Data to AWS S3 via RESTlet
On this page

Your month-end close depends on having a complete, immutable copy of every financial report, GL detail, trial balance, balance sheet, income statement, stored outside NetSuite. Scheduled email exports leave gaps: attachment size limits, formatting drift, and no audit trail of what actually landed in S3. A RESTlet-driven pipeline solves this by pushing structured JSON directly to AWS, where a Lambda function writes versioned objects to S3. Here's how to build it without the spreadsheet workaround.

Why Scheduled Email Exports Fail Financial Audits

Email-based backup creates three control weaknesses your auditors will flag. First, NetSuite's outbound email limit is 15 MB total (message plus attachments), with individual attachment caps that vary by sender type, SuiteFlow 'Send Email' actions allow 10 MB, while records and marketing merges cap at 5 MB. A consolidated trial balance for a multi-subsidiary entity often exceeds these limits. Second, CSV exports flatten account hierarchies; parent-child relationships in your chart of accounts disappear, making balance sheet rollups impossible to reconstruct. Third, there's no delivery confirmation. If the SMTP relay delays or the S3 ingestion script crashes, you have no record of the gap.

The control here is end-to-end traceability: every backup run logs a transaction ID in NetSuite and an object version ID in S3.

RESTlet Architecture: NetSuite → API Gateway → Lambda → S3

The pattern uses four components you already control:

ComponentResponsibilityNetSuite Touchpoint
RESTlet (SuiteScript 2.1)Execute saved searches, return JSONDeployed on customscript_fin_backup_restlet
API Gateway (REST API)Auth, throttling, request validationNo NetSuite config needed
Lambda (Python/Node)Transform, partition, write to S3Triggered by API Gateway
S3 BucketImmutable, versioned storageBucket policy allows Lambda PutObject

The RESTlet runs on a Scheduled Script deployment (not a Suitelet) so it executes under a dedicated integration role with REST Web Services permission and Saved Search execute access. No UI interaction required.

Handling Account Hierarchy: Saved Search vs. Financial Reports

Saved searches cannot natively reproduce the account grouping/hierarchy columns in NetSuite's native Balance Sheet or Income Statement. The account join gives you acctnumber, acctname, and parent, but not the indentation levels or rollup totals the UI renders.

Two practical workarounds:

  1. Recursive CTE in SuiteQL, Pull the full account tree in one query, then build hierarchy in Lambda.
  2. Export the Financial Report via N/render, Use render.xmlToPdf() on a Financial Report definition, then parse the PDF in Lambda (heavier, but pixel-perfect).

For most teams, Option 1 balances fidelity and maintainability. The SuiteQL below returns every account with its depth and parent path:

WITH RECURSIVE acct_tree AS (
    SELECT id, acctnumber, acctname, parent, 1 AS depth, CAST(acctnumber AS VARCHAR(4000)) AS path
    FROM account
    WHERE parent IS NULL
    UNION ALL
    SELECT a.id, a.acctnumber, a.acctname, a.parent, at.depth + 1, at.path || ' > ' || a.acctnumber
    FROM account a
    JOIN acct_tree at ON a.parent = at.id
)
SELECT * FROM acct_tree ORDER BY path;

Run this via N/query in the RESTlet, return the result set, and let Lambda reconstruct the rollups. Your auditors get a reproducible hierarchy without PDF parsing.

RESTlet Implementation (SuiteScript 2.1)

Deploy this as a RESTlet script type. The get handler accepts a report parameter (gl, tb, bs, is) and optional subsidiary and period filters.

/**
 * @NApiVersion 2.1
 * @NScriptType Restlet
 */
define(['N/query', 'N/log', 'N/runtime', 'N/error'], (query, log, runtime, error) => {

    const REPORT_QUERIES = {
        gl: `SELECT 
                trandate, tranid, account.acctnumber, account.acctname,
                debitamount, creditamount, memo, entity, subsidiary.name AS subsidiary
             FROM transaction
             JOIN account ON transaction.account = account.id
             LEFT JOIN entity ON transaction.entity = entity.id
             LEFT JOIN subsidiary ON transaction.subsidiary = subsidiary.id
             WHERE transaction.postingperiod = ?
               AND transaction.subsidiary = ?
             ORDER BY trandate, tranid`,
        tb: `SELECT 
                account.acctnumber, account.acctname,
                SUM(debitamount) AS total_debit,
                SUM(creditamount) AS total_credit
             FROM transaction
             JOIN account ON transaction.account = account.id
             WHERE transaction.postingperiod = ?
               AND transaction.subsidiary = ?
             GROUP BY account.acctnumber, account.acctname
             ORDER BY account.acctnumber`,
        hierarchy: `WITH RECURSIVE acct_tree AS (
                        SELECT id, acctnumber, acctname, parent, 1 AS depth, CAST(acctnumber AS VARCHAR(4000)) AS path
                        FROM account WHERE parent IS NULL
                        UNION ALL
                        SELECT a.id, a.acctnumber, a.acctname, a.parent, at.depth + 1, at.path || ' > ' || a.acctnumber
                        FROM account a JOIN acct_tree at ON a.parent = at.id
                      )
                      SELECT * FROM acct_tree ORDER BY path`
    };

    const get = (requestParams) => {
        const { report = 'gl', subsidiary, period } = requestParams;
        const scriptObj = runtime.getCurrentScript();
        const startUsage = scriptObj.getRemainingUsage();

        if (!subsidiary || !period) {
            throw error.create({
                name: 'MISSING_PARAMS',
                message: 'subsidiary and period parameters are required',
                notifyOff: true
            });
        }

        const sql = REPORT_QUERIES[report];
        if (!sql) {
            throw error.create({
                name: 'INVALID_REPORT',
                message: `Unknown report type: ${report}. Valid: gl, tb, hierarchy`,
                notifyOff: true
            });
        }

        const results = query.runSuiteQL({
            query: sql,
            params: [period, subsidiary]
        }).asMappedResults();

        log.audit({
            title: 'Financial Backup RESTlet',
            details: `report=${report} subsidiary=${subsidiary} period=${period} rows=${results.length} usage=${startUsage - scriptObj.getRemainingUsage()}`
        });

        return {
            report,
            subsidiary,
            period,
            generatedAt: new Date().toISOString(),
            rowCount: results.length,
            data: results
        };
    };

    return { get };
});

Deployment notes:

  • Script ID: customscript_fin_backup_restlet
  • Deployment ID: customdeploy_fin_backup_restlet
  • External URL: https://<accountID>.app.netsuite.com/app/site/hosting/restlet.nl?script=<script_id>&deploy=<deploy_id>
  • Authentication: Token-based (TBA), create a Consumer Key/Secret and Token ID/Secret for the integration role
  • Governance: SuiteQL consumes governance units based on query complexity and result set size. Monitor runtime.getCurrentScript().getRemainingUsage() and paginate if needed.

AWS Lambda: Transform and Partition

The Lambda receives the RESTlet JSON, adds partitioning keys, and writes to S3 with a key pattern that enables Athena/Glue querying:

s3://<bucket>/financial-backups/
  └── subsidiary=US01/
      └── year=2024/
          └── month=11/
              └── report=gl/
                  └── period=2024-11/
                      └── run=20241130T142300Z.json

Python snippet for the Lambda handler:

import json, boto3, os, uuid
from datetime import datetime

s3 = boto3.client('s3')
BUCKET = os.environ['BUCKET_NAME']

def lambda_handler(event, context):
    payload = json.loads(event['body'])
    subsidiary = payload['subsidiary'].replace(' ', '_')
    period = payload['period']  # format: 2024-11
    report = payload['report']
    run_ts = datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')

    key = f"financial-backups/subsidiary={subsidiary}/year={period[:4]}/month={period[5:7]}/report={report}/period={period}/run={run_ts}.json"

    s3.put_object(
        Bucket=BUCKET,
        Key=key,
        Body=json.dumps(payload, default=str).encode('utf-8'),
        ServerSideEncryption='AES256',
        Metadata={
            'netsuite-report': report,
            'netsuite-subsidiary': subsidiary,
            'netsuite-period': period,
            'netsuite-rowcount': str(payload['rowCount'])
        }
    )
    return {'statusCode': 200, 'body': json.dumps({'s3_key': key})}

Enable S3 Versioning and Object Lock (compliance mode) on the bucket. This satisfies SEC Rule 17a-4 and India's MCA backup requirements without extra code.

Scheduling and Monitoring

Create a Scheduled Script deployment that calls the RESTlet internally via https.post(), this avoids TBA token rotation and network latency compared to external calls.

// Scheduled Script (MapReduce for large datasets)
define(['N/https', 'N/runtime', 'N/log'], (https, runtime, log) => {
    const getInputData = () => [
        { report: 'gl', subsidiary: '1', period: '2024-11' },
        { report: 'tb', subsidiary: '1', period: '2024-11' },
        { report: 'bs', subsidiary: '1', period: '2024-11' },
        { report: 'is', subsidiary: '1', period: '2024-11' },
        { report: 'hierarchy', subsidiary: '1', period: '2024-11' }
    ];

    const map = (context) => {
        const params = JSON.parse(context.value);
        const url = runtime.getCurrentScript().getParameter({ name: 'custscript_restlet_url' });
        const response = https.post({
            url: url,
            body: JSON.stringify(params),
            headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${runtime.getCurrentScript().getParameter({ name: 'custscript_bearer_token' })}` }
        });
        log.audit('Backup Result', response.body);
    };

    return { getInputData, map };
});

Schedule it daily at 2 AM (post-period-close) or on-demand via a Suitelet button on the Period Close Checklist record. Log every run to a Custom Record (customrecord_fin_backup_log) with fields: custrecord_fb_report, custrecord_fb_subsidiary, custrecord_fb_period, custrecord_fb_s3_key, custrecord_fb_status, custrecord_fb_error. This becomes your audit evidence, and you can surface these logs through System Notes for a tamper-proof history of who ran what and when.

Edge Cases That Break the Pipeline

ScenarioSymptomFix
Period not closedGL includes adjusting entries posted after backupAdd AND transaction.posting = 'T' to SuiteQL; only back up closed periods
Subsidiary elimination entriesIntercompany lines double-count in consolidated backupFilter transaction.eliminating = 'F' or run separate elimination backup
Account hierarchy changes mid-yearPrior-period backup shows new parent structureSnapshot hierarchy report per period; store period-specific tree
API Gateway 29s timeoutLarge GL (>100k rows) times outSwitch to MapReduce RESTlet with cursor pagination; Lambda processes chunks

The permission that actually trips people up: the integration role needs Lists > Accounting > Accounts (View) and Reports > Financial > Financial Reports (Run), even though the RESTlet uses SuiteQL, NetSuite enforces report-level permissions on financial data.

What to Verify Next Month

After the first full close cycle, open the S3 bucket and run an Athena query against the partitioned data:

SELECT subsidiary, period, report, COUNT(*) AS rows, MAX(run_ts) AS latest_run
FROM "financial-backups"
GROUP BY subsidiary, period, report
ORDER BY period DESC, subsidiary;

Confirm every subsidiary/period/report combination has a latest_run within your SLA window. If a row is missing, the customrecord_fin_backup_log tells you exactly which scheduled execution failed and why. That's the audit trail your CFO can hand to external auditors without explanation.

About the author

Put these ideas to work.

Suite Utils builds small NetSuite tools that fix the specific thing breaking your day. Each one runs as a native SuiteScript SuiteApp inside your account. No sales call, no onboarding.

Browse the Tools

Enjoyed this one?

Get NetSuite tips like this in your inbox. No spam. Practical guides only.

Keep reading