How to download invoice PDFs from NetSuite
The Apideck Accounting unified API doesn't expose invoice PDF download as a unified operation, but you can retrieve them via the Proxy API. This guide shows how to do this for NetSuite.
NetSuite is different from the other accounting connectors: it doesn't offer a dedicated PDF endpoint in either its REST or SOAP API. There are two viable approaches, and which one you use depends on whether the customer already saves invoice PDFs to their File Cabinet.
Approach 1: SOAP File Cabinet (no NetSuite-side setup)
Use this when the customer's NetSuite account already saves invoice PDFs to the File Cabinet - typically via a workflow, scheduled script, or manual save action. You search the File Cabinet for the file, then fetch its contents.
This approach uses NetSuite's SOAP API via the Proxy. Apideck auto-injects all Token-Based Authentication credentials, so you only need to use the right placeholder names. See NetSuite: How to use the SOAP API via the Proxy for the full SOAP/Proxy reference.
Step 1: Find the file's internal ID
Either look it up in the NetSuite UI (Documents → Files → File Cabinet, then click the file - the URL contains id=…), or search by name via SOAP:
curl --location 'https://unify.apideck.com/proxy' \
--header 'Authorization: Bearer {APIDECK_API_KEY}' \
--header 'x-apideck-app-id: {APP_ID}' \
--header 'x-apideck-consumer-id: {CONSUMER_ID}' \
--header 'x-apideck-service-id: netsuite' \
--header 'x-apideck-downstream-url: https://{account_id}.suitetalk.api.netsuite.com/services/NetSuitePort_2022_2' \
--header 'Content-Type: application/xml' \
--header 'Accept: application/xml' \
--header 'SOAPAction: search' \
--data '<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:platformMsgs="urn:messages_2022_2.platform.webservices.netsuite.com"
xmlns:platformCore="urn:core_2022_2.platform.webservices.netsuite.com"
xmlns:platformCommon="urn:common_2022_2.platform.webservices.netsuite.com">
<soap:Header>
<platformMsgs:tokenPassport>
<platformCore:account>{account}</platformCore:account>
<platformCore:consumerKey>{consumer_key}</platformCore:consumerKey>
<platformCore:nonce>{nonce}</platformCore:nonce>
<platformCore:timestamp>{timestamp}</platformCore:timestamp>
<platformCore:token>{token_id}</platformCore:token>
<platformCore:version>1.0</platformCore:version>
<platformCore:signature algorithm="HMAC_SHA256">{signature}</platformCore:signature>
</platformMsgs:tokenPassport>
</soap:Header>
<soap:Body>
<search xmlns="urn:messages_2022_2.platform.webservices.netsuite.com">
<searchRecord
xsi:type="platformCommon:FileSearchBasic"
xmlns:platformCommon="urn:common_2022_2.platform.webservices.netsuite.com">
<platformCommon:name
xsi:type="platformCore:SearchStringField"
xmlns:platformCore="urn:core_2022_2.platform.webservices.netsuite.com">
<platformCore:searchValue>Invoice {INVOICE_NUMBER}.pdf</platformCore:searchValue>
<platformCore:operator>contains</platformCore:operator>
</platformCommon:name>
</searchRecord>
</search>
</soap:Body>
</soap:Envelope>'
The response contains a recordList with one or more matches. Note the internalId of the file you want.
Step 2: Fetch the file contents
curl --location 'https://unify.apideck.com/proxy' \
--header 'Authorization: Bearer {APIDECK_API_KEY}' \
--header 'x-apideck-app-id: {APP_ID}' \
--header 'x-apideck-consumer-id: {CONSUMER_ID}' \
--header 'x-apideck-service-id: netsuite' \
--header 'x-apideck-downstream-url: https://{account_id}.suitetalk.api.netsuite.com/services/NetSuitePort_2022_2' \
--header 'Content-Type: application/xml' \
--header 'Accept: application/xml' \
--header 'SOAPAction: get' \
--data '<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:platformMsgs="urn:messages_2022_2.platform.webservices.netsuite.com"
xmlns:platformCore="urn:core_2022_2.platform.webservices.netsuite.com">
<soap:Header>
<platformMsgs:tokenPassport>
<platformCore:account>{account}</platformCore:account>
<platformCore:consumerKey>{consumer_key}</platformCore:consumerKey>
<platformCore:nonce>{nonce}</platformCore:nonce>
<platformCore:timestamp>{timestamp}</platformCore:timestamp>
<platformCore:token>{token_id}</platformCore:token>
<platformCore:version>1.0</platformCore:version>
<platformCore:signature algorithm="HMAC_SHA256">{signature}</platformCore:signature>
</platformMsgs:tokenPassport>
</soap:Header>
<soap:Body>
<get xmlns="urn:messages_2022_2.platform.webservices.netsuite.com">
<baseRef internalId="{FILE_INTERNAL_ID}" type="file"
xsi:type="platformCore:RecordRef"/>
</get>
</soap:Body>
</soap:Envelope>'
The response is a SOAP envelope containing the file as a base64-encoded string in the <docFileCab:content> element. Decode it to get the PDF bytes:
echo '{base64-content}' | base64 -d > invoice.pdf
For sandbox connections, swap the host to https://{account_id}-sb1.suitetalk.api.netsuite.com/....
Approach 2: Customer-deployed RESTlet (on-demand generation)
Use this when you need to generate the PDF on demand - for example, the customer doesn't save invoice PDFs to the File Cabinet, or you want to render a specific template. This requires the customer to deploy a small SuiteScript in their NetSuite account.
Step 1: Customer deploys a RESTlet
A minimal RESTlet that accepts an invoice internal ID and returns the rendered PDF:
/**
* @NApiVersion 2.1
* @NScriptType Restlet
*/
define(['N/render'], function(render) {
function doGet(requestParams) {
var invoiceId = requestParams.invoice_id;
if (!invoiceId) {
return JSON.stringify({ error: 'invoice_id is required' });
}
try {
var pdf = render.transaction({
entityId: parseInt(invoiceId),
printMode: render.PrintMode.PDF
});
return JSON.stringify({
invoice_id: invoiceId,
pdf_base64: pdf.getContents()
});
} catch (e) {
return JSON.stringify({ error: e.message });
}
}
return { 'get': doGet };
});
To deploy:
In NetSuite, go to Customization → Scripting → Scripts → New.
Upload the JS file, click Create Script Record, fill in name and ID, save.
Click Deploy Script on the saved record. Set Status: Released, set Roles to whichever role the connection uses, save.
Copy the External URL field from the deployment record. It looks like
https://{account_id}.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script={n}&deploy={n}.
The customer also needs SuiteCloud features enabled (Setup → Company → Enable Features → SuiteCloud → Client SuiteScript and Server SuiteScript), which is typically a one-time toggle.
Step 2: Find the invoice's internal ID
Use the unified Accounting API:
curl --location 'https://unify.apideck.com/accounting/invoices' \
--header 'Authorization: Bearer {APIDECK_API_KEY}' \
--header 'x-apideck-app-id: {APP_ID}' \
--header 'x-apideck-consumer-id: {CONSUMER_ID}' \
--header 'x-apideck-service-id: netsuite'
The id field on each invoice is the NetSuite internal ID.
Step 3: Call the RESTlet via the Proxy
curl --location 'https://unify.apideck.com/proxy' \
--header 'Authorization: Bearer {APIDECK_API_KEY}' \
--header 'x-apideck-app-id: {APP_ID}' \
--header 'x-apideck-consumer-id: {CONSUMER_ID}' \
--header 'x-apideck-service-id: netsuite' \
--header 'x-apideck-downstream-url: https://{account_id}.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script={SCRIPT_ID}&deploy={DEPLOY_ID}&invoice_id={INVOICE_ID}' \
--header 'Accept: application/json'
The response is JSON containing pdf_base64. Decode it to get the raw PDF bytes.
Which approach to choose
Approach 1 (SOAP File Cabinet) - best when the customer already saves invoice PDFs to the File Cabinet. No NetSuite-side development required.
Approach 2 (RESTlet) - best when you need on-demand PDF generation, or when PDFs aren't already saved as files. Requires ~5 minutes of customer setup.
Gotchas
Use {account} in the SOAP body, {account_id} in the URL. These are two different placeholders. {account_id} is only resolved in the downstream URL; {account} is only resolved inside the SOAP body's <platformCore:account> element. Mixing them up causes Invalid login attempt errors because the credential placeholders pass through to NetSuite literally.
The <platformCore:version>1.0</platformCore:version> element is required in the tokenPassport. Omitting it produces a generic SOAP fault.
Set Accept: application/xml for SOAP requests. Setting Accept: application/json on a SOAP route causes the proxy to fail when parsing the response.
Sandbox vs production hosts. Sandbox SuiteTalk uses {account_id}-sb1.suitetalk.api.netsuite.com. Production uses {account_id}.suitetalk.api.netsuite.com. The {account_id} placeholder resolves to the bare account ID — you write the -sb1 suffix yourself if your connection is sandbox.
SOAP namespace versions. The examples here use 2022_2. Newer accounts may be on a more recent schema (e.g. 2024_2). If you see schema-related errors, update the namespace strings to match.
