Breadwinner for NetSuite Apex Guide
Salesforce to NetSuite Integration via Apex
A developer guide to connecting Salesforce and NetSuite using Apex code — covering Token-Based Authentication, customer, and invoice operations.
Integration Overview
Salesforce can integrate with NetSuite using Apex HTTP callouts to NetSuite's SuiteTalk REST API. This guide covers Token-Based Authentication (TBA), reading and creating customers, and managing invoices — all from within Salesforce.
You'll need admin access to both Salesforce and NetSuite, plus experience writing Apex. NetSuite uses OAuth 1.0a with HMAC-SHA256 signatures — more involved than a simple bearer token — requiring a custom object to store credentials and a signature computed on every request.
Building a custom integration from scratch? Consider whether a native integration like Breadwinner might save your team weeks of development time — with out-of-the-box LWC components, error handling, and ongoing maintenance included.
1. Remote Site Settings
Before Salesforce can make callouts to NetSuite, register this endpoint in Setup → Remote Site Settings:
- Production & Sandbox
https://{your-account-id}.suitetalk.api.netsuite.com
Replace {your-account-id} with your NetSuite Account ID from Setup → Company → Company Information (e.g. 1234567 for production, 1234567-sb1 for sandbox).
2. Token-Based Authentication (TBA)
NetSuite uses Token-Based Authentication for server-to-server REST integrations. You'll need to create an integration record and access token in NetSuite, store credentials in a custom Salesforce object, and sign every HTTP request with HMAC-SHA256.
NetSuite Setup
In NetSuite, before writing Apex:
- 1 Setup → Integration → Manage Integrations → New — enable Token-Based Authentication and REST Web Services. Note the Consumer Key and Consumer Secret.
- 2 Create a role with permissions for Customers, Invoices, Items, Subsidiaries, and REST Web Services.
- 3 Setup → Users/Roles → Access Tokens → New — issue a token for your integration, user, and role. Note the Token ID and Token Secret (shown once).
Custom Object for Credential Storage
Create a custom object NetSuite_Details__c with these fields:
| Field | API Name | Type |
|---|---|---|
| NetSuite Account ID | Account_Id__c | Text (255) |
| Consumer Key | Consumer_Key__c | Text (255) |
| Consumer Secret | Consumer_Secret__c | Text (255) |
| Token ID | Token_Id__c | Text (255) |
| Token Secret | Token_Secret__c | Text (255) |
Create one NetSuite_Details__c record and populate all five fields before running any code.
Authentication Handler Class
This class signs every HTTP request with an OAuth 1.0a HMAC-SHA256 signature. If the URL contains query parameters (for example ?limit=100), those parameters must be included in the signature base string or NetSuite returns 401 INVALID_LOGIN_ATTEMPT.
public class NSTBAAuth {
public static HttpRequest signRequest(String method, String endpointUrl, NetSuite_Details__c creds) {
return signRequest(
method,
endpointUrl,
creds.Account_Id__c,
creds.Consumer_Key__c,
creds.Consumer_Secret__c,
creds.Token_Id__c,
creds.Token_Secret__c
);
}
public static HttpRequest signRequest(
String method,
String endpointUrl,
String accountId,
String consumerKey,
String consumerSecret,
String tokenId,
String tokenSecret
) {
String baseUrl = endpointUrl;
Map<String, String> queryParams = new Map<String, String>();
Integer queryIndex = endpointUrl.indexOf('?');
if (queryIndex > -1) {
baseUrl = endpointUrl.substring(0, queryIndex);
queryParams = parseQueryString(endpointUrl.substring(queryIndex + 1));
}
Long timestamp = DateTime.now().getTime() / 1000;
String nonce = EncodingUtil.convertToHex(Crypto.generateAESKey(128)).substring(0, 32);
Map<String, String> oauthParams = new Map<String, String>{
'oauth_consumer_key' => consumerKey,
'oauth_nonce' => nonce,
'oauth_signature_method' => 'HMAC-SHA256',
'oauth_timestamp' => String.valueOf(timestamp),
'oauth_token' => tokenId,
'oauth_version' => '1.0'
};
Map<String, String> allParams = new Map<String, String>();
allParams.putAll(oauthParams);
allParams.putAll(queryParams);
List<String> sortedKeys = new List<String>(allParams.keySet());
sortedKeys.sort();
List<String> pairs = new List<String>();
for (String key : sortedKeys) {
pairs.add(
EncodingUtil.urlEncode(key, 'UTF-8') + '=' + EncodingUtil.urlEncode(allParams.get(key), 'UTF-8')
);
}
String paramString = String.join(pairs, '&');
String baseString =
method.toUpperCase() +
'&' +
EncodingUtil.urlEncode(baseUrl, 'UTF-8') +
'&' +
EncodingUtil.urlEncode(paramString, 'UTF-8');
String signingKey =
EncodingUtil.urlEncode(consumerSecret, 'UTF-8') + '&' + EncodingUtil.urlEncode(tokenSecret, 'UTF-8');
Blob sig = Crypto.generateMac('hmacSHA256', Blob.valueOf(baseString), Blob.valueOf(signingKey));
String encodedSig = EncodingUtil.urlEncode(EncodingUtil.base64Encode(sig), 'UTF-8');
String authHeader =
'OAuth realm="' +
accountId +
'",' +
'oauth_consumer_key="' +
consumerKey +
'",' +
'oauth_token="' +
tokenId +
'",' +
'oauth_signature_method="HMAC-SHA256",' +
'oauth_timestamp="' +
timestamp +
'",' +
'oauth_nonce="' +
nonce +
'",' +
'oauth_version="1.0",' +
'oauth_signature="' +
encodedSig +
'"';
HttpRequest req = new HttpRequest();
req.setEndpoint(endpointUrl);
req.setMethod(method);
req.setHeader('Authorization', authHeader);
req.setHeader('Content-Type', 'application/json');
req.setHeader('Accept', 'application/json');
req.setTimeout(120000);
return req;
}
private static Map<String, String> parseQueryString(String queryString) {
Map<String, String> params = new Map<String, String>();
if (String.isBlank(queryString)) {
return params;
}
for (String pair : queryString.split('&')) {
List<String> parts = pair.split('=', 2);
if (parts.size() == 2) {
params.put(
EncodingUtil.urlDecode(parts[0], 'UTF-8'),
EncodingUtil.urlDecode(parts[1], 'UTF-8')
);
}
}
return params;
}
} HTTP Callout Handler Class
This class routes signed requests to the NetSuite REST Record API (for creates) and SuiteQL endpoint (for reads).
public class NSCalloutHandler {
private static final String RECORD_BASE =
'https://{0}.suitetalk.api.netsuite.com/services/rest/record/v1';
private static final String QUERY_BASE =
'https://{0}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql';
private static NetSuite_Details__c getCredentials() {
return [
SELECT Account_Id__c, Consumer_Key__c, Consumer_Secret__c, Token_Id__c, Token_Secret__c
FROM NetSuite_Details__c
LIMIT 1
];
}
public static HttpResponse post(String entityType, String body) {
NetSuite_Details__c creds = getCredentials();
String url = RECORD_BASE.replace('{0}', creds.Account_Id__c) + '/' + entityType;
HttpRequest req = NSTBAAuth.signRequest('POST', url, creds);
req.setBody(body);
return new Http().send(req);
}
public static HttpResponse suiteQL(String query, Integer limitSize, Integer offset) {
NetSuite_Details__c creds = getCredentials();
String url = QUERY_BASE.replace('{0}', creds.Account_Id__c);
if (limitSize != null) {
url += '?limit=' + limitSize;
}
if (offset != null) {
url += (url.contains('?') ? '&' : '?') + 'offset=' + offset;
}
HttpRequest req = NSTBAAuth.signRequest('POST', url, creds);
req.setHeader('Prefer', 'transient');
req.setBody(JSON.serialize(new Map<String, Object>{ 'q' => query }));
return new Http().send(req);
}
public static String extractInternalIdFromLocation(HttpResponse res) {
String location = res.getHeader('Location');
if (String.isBlank(location)) {
return null;
}
return location.substring(location.lastIndexOf('/') + 1);
}
} SuiteQL Service Class
NetSuite reads use SuiteQL — the equivalent of QuickBooks' query API (select * from Customer). The NSSuiteQLService class executes SuiteQL queries and returns results.
public class NSSuiteQLService {
public static List<Map<String, Object>> query(String sql, Integer limitSize, Integer offset) {
HttpResponse res = NSCalloutHandler.suiteQL(sql, limitSize, offset);
if (res.getStatusCode() != 200) {
throw new CalloutException('SuiteQL error: ' + res.getStatusCode() + ' ' + res.getBody());
}
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
return parseItems(body);
}
private static List<Map<String, Object>> parseItems(Map<String, Object> body) {
List<Map<String, Object>> results = new List<Map<String, Object>>();
if (body == null || !body.containsKey('items')) {
return results;
}
for (Object item : (List<Object>) body.get('items')) {
results.add((Map<String, Object>) item);
}
return results;
}
} 3. Working with NetSuite Customers
Creating Customers
Create a new customer in NetSuite with company name, contact details, subsidiary, and billing address. Update the SAMPLE_* constants with internal IDs from your NetSuite account before running.
public class NSCustomerService {
// Replace with NetSuite internal IDs from your account before running createNSCustomer().
private static final String SAMPLE_SUBSIDIARY_ID = '1';
private static final String SAMPLE_ENTITY_STATUS_ID = '13';
private static final String SAMPLE_TERMS_ID = '1';
private static final String SAMPLE_CURRENCY_ID = '1';
public static void createNSCustomer() {
String requestBody =
'{' +
'"isPerson": false,' +
'"companyName": "King Groceries",' +
'"entityId": "King\'s Groceries",' +
'"email": "jdrew@myemail.com",' +
'"phone": "(555) 555-5555",' +
'"mobilePhone": "(555) 555-5556",' +
'"fax": "(555) 555-5557",' +
'"url": "https://www.kingsgroceries.example",' +
'"comments": "Here are other details.",' +
'"subsidiary": { "id": "' +
SAMPLE_SUBSIDIARY_ID +
'" },' +
'"entityStatus": { "id": "' +
SAMPLE_ENTITY_STATUS_ID +
'" },' +
'"terms": { "id": "' +
SAMPLE_TERMS_ID +
'" },' +
'"currency": { "id": "' +
SAMPLE_CURRENCY_ID +
'" },' +
'"addressBook": {' +
' "items": [{' +
' "defaultBilling": true,' +
' "defaultShipping": true,' +
' "label": "Billing",' +
' "addressBookAddress": {' +
' "addressee": "James King Jr",' +
' "attention": "Mr James King",' +
' "addr1": "123 Main Street",' +
' "addr2": "Suite 100",' +
' "city": "Mountain View",' +
' "state": "CA",' +
' "zip": "94042",' +
' "country": "US"' +
' }' +
' }]' +
'}' +
'}';
HttpResponse res = NSCalloutHandler.post('customer', requestBody);
if (res.getStatusCode() == 200 || res.getStatusCode() == 204) {
String responseBody = res.getBody();
System.debug(String.isNotBlank(responseBody) ? responseBody : res.getHeader('Location'));
} else {
System.debug(formatErrorMessage(res));
}
}
private static String formatErrorMessage(HttpResponse res) {
String message = 'Status ' + res.getStatusCode() + ': ';
try {
Map<String, Object> errorBody = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
List<Object> errorDetails = (List<Object>) errorBody.get('o:errorDetails');
if (errorDetails != null && !errorDetails.isEmpty()) {
Map<String, Object> firstError = (Map<String, Object>) errorDetails[0];
if (firstError.get('detail') != null) {
return message + firstError.get('detail');
}
}
if (errorBody.get('title') != null) {
return message + errorBody.get('title');
}
} catch (Exception e) {
// fall through to raw body
}
return message + res.getBody();
}
} Note: You can look up subsidiary and entity status IDs with SuiteQL before creating:
System.debug(NSSuiteQLService.query('SELECT id, name FROM subsidiary WHERE isinactive = \'F\'', 100, 0)); 4. Working with NetSuite Invoices
Creating Invoices
Create an invoice by specifying the customer reference, subsidiary, posting period, and line items with item details and amounts.
public class NSInvoiceService {
// Replace with NetSuite internal IDs from your account before running createNSInvoice().
private static final String SAMPLE_NS_CUSTOMER_ID = '194775';
private static final String SAMPLE_NS_ITEM_ID = '11138';
private static final String SAMPLE_POSTING_PERIOD_ID = '385';
private static final String SAMPLE_TERMS_ID = '5';
private static final String SAMPLE_SUBSIDIARY_ID = '1';
private static final String SAMPLE_LOCATION_ID = '32';
public static void createNSInvoice() {
String requestBody =
'{' +
'"entity": { "id": "' +
SAMPLE_NS_CUSTOMER_ID +
'" },' +
'"subsidiary": { "id": "' +
SAMPLE_SUBSIDIARY_ID +
'" },' +
'"location": { "id": "' +
SAMPLE_LOCATION_ID +
'" },' +
'"tranDate": "' +
String.valueOf(Date.today()) +
'",' +
'"dueDate": "' +
String.valueOf(Date.today().addDays(30)) +
'",' +
'"memo": "Invoice created from Salesforce Apex",' +
'"otherRefNum": "SF-REF-1001",' +
'"postingperiod": "' +
SAMPLE_POSTING_PERIOD_ID +
'",' +
'"terms": { "id": "' +
SAMPLE_TERMS_ID +
'" },' +
'"item": {' +
' "items": [{' +
' "item": { "id": "' +
SAMPLE_NS_ITEM_ID +
'" },' +
' "quantity": 1,' +
' "rate": 100,' +
' "amount": 100,' +
' "description": "Professional Services"' +
' }]' +
'}' +
'}';
HttpResponse res = NSCalloutHandler.post('invoice', requestBody);
if (res.getStatusCode() == 200 || res.getStatusCode() == 204) {
String responseBody = res.getBody();
System.debug(String.isNotBlank(responseBody) ? responseBody : res.getHeader('Location'));
} else {
System.debug(formatErrorMessage(res));
}
}
private static String formatErrorMessage(HttpResponse res) {
String message = 'Status ' + res.getStatusCode() + ': ';
try {
Map<String, Object> errorBody = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
List<Object> errorDetails = (List<Object>) errorBody.get('o:errorDetails');
if (errorDetails != null && !errorDetails.isEmpty()) {
Map<String, Object> firstError = (Map<String, Object>) errorDetails[0];
if (firstError.get('detail') != null) {
return message + firstError.get('detail');
}
}
if (errorBody.get('title') != null) {
return message + errorBody.get('title');
}
} catch (Exception e) {
// fall through to raw body
}
return message + res.getBody();
}
} 5. Next Steps: Automation
Once the basics are working, you'll likely want to automate:
- 1 Flow-triggered sync — use
@InvocableMethodto create NetSuite customers and invoices when Salesforce opportunities close, triggered via Record Triggered Flow - 2 Scheduled sync — build a schedulable class to run SuiteQL queries at regular intervals (hourly or bi-hourly) to keep Salesforce up to date
What Custom Apex Doesn't Cover
The code above gets you started, but a production-grade integration needs significantly more. As you can see, NetSuite's TBA signing alone is more complex than most APIs — and that's just authentication. Building and maintaining all of this yourself takes weeks of development and ongoing engineering time:
- 1 Subsidiary and item mapping — every NetSuite record is scoped to a subsidiary. Multi-subsidiary accounts need explicit mapping between Salesforce and NetSuite before customers or invoices can be created.
- 2 Credential security — credentials should be encrypted at rest in production, access-controlled via permission sets, and never logged in debug output.
- 3 Error handling & retry logic — API rate limits, 503 timeouts, signature errors, and network failures all need retry queues and exponential backoff.
- 4 Race conditions — handling concurrent updates from both Salesforce and NetSuite users.
- 5 UI components — Lightning Web Components for viewing and managing NetSuite data inside Salesforce.
- 6 Ongoing maintenance — keeping up with NetSuite API changes, Salesforce releases, multi-currency, tax codes, SuiteBilling, and security patches.
Skip the build. Use Breadwinner.
Breadwinner for NetSuite is a native Salesforce NetSuite integration that handles all of the above — and more. Install from the AppExchange, connect to NetSuite in minutes, and start syncing customers, invoices, sales orders, and payments the same day. No custom Apex required.
- Pre-built Lightning Web Components for customers, invoices, sales orders, and payments
- Full Global API and Apex Generator for custom extensions
- Bidirectional sync with hourly, weekly, and historical import
- OAuth, error handling, retry logic, and multi-subsidiary support built in
- SOC 2 Type II compliant, continuously maintained
Building a different integration? See our QuickBooks to Salesforce Apex guide or Xero to Salesforce Apex guide.
"Breadwinner cut our SLA processing during a busy time by 80%. Client-facing teams can now assist clients without needing to loop in the finance team."
— ChowNow · Read the full story