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 IDAccount_Id__cText (255)
Consumer KeyConsumer_Key__cText (255)
Consumer SecretConsumer_Secret__cText (255)
Token IDToken_Id__cText (255)
Token SecretToken_Secret__cText (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.

NSTBAAuth.cls
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).

NSCalloutHandler.cls
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.

NSSuiteQLService.cls
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.

NSCustomerService.cls
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:

Anonymous Apex
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.

NSInvoiceService.cls
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 @InvocableMethod to 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, and start syncing customers, invoices, sales orders, and payments in hours. 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