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.

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:
| Component | Responsibility | NetSuite Touchpoint |
|---|---|---|
| RESTlet (SuiteScript 2.1) | Execute saved searches, return JSON | Deployed on customscript_fin_backup_restlet |
| API Gateway (REST API) | Auth, throttling, request validation | No NetSuite config needed |
| Lambda (Python/Node) | Transform, partition, write to S3 | Triggered by API Gateway |
| S3 Bucket | Immutable, versioned storage | Bucket 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:
- Recursive CTE in SuiteQL, Pull the full account tree in one query, then build hierarchy in Lambda.
- Export the Financial Report via
N/render, Userender.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.jsonPython 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
| Scenario | Symptom | Fix |
|---|---|---|
| Period not closed | GL includes adjusting entries posted after backup | Add AND transaction.posting = 'T' to SuiteQL; only back up closed periods |
| Subsidiary elimination entries | Intercompany lines double-count in consolidated backup | Filter transaction.eliminating = 'F' or run separate elimination backup |
| Account hierarchy changes mid-year | Prior-period backup shows new parent structure | Snapshot hierarchy report per period; store period-specific tree |
| API Gateway 29s timeout | Large GL (>100k rows) times out | Switch 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.


