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 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