---
title: "The Final Boss of ERPs: Architecting a Reliable NetSuite API Integration"
slug: the-final-boss-of-erps-architecting-a-reliable-netsuite-api-integration
date: 2026-03-08
author: Uday Gajavalli
categories: [Engineering, By Example]
excerpt: "NetSuite integrations break when treated like a normal REST API. Learn how to architect a reliable integration using SuiteQL, TBA, and dynamic feature detection."
tldr: "NetSuite requires orchestrating REST for writes, SuiteQL for reads, and SOAP/SuiteScript for gaps. You must handle OAuth 1.0a TBA signatures and dynamically adapt queries for OneWorld and multi-currency accounts."
canonical: https://truto.one/blog/the-final-boss-of-erps-architecting-a-reliable-netsuite-api-integration/
---

# The Final Boss of ERPs: Architecting a Reliable NetSuite API Integration


If you've built integrations with modern SaaS APIs - HubSpot, Stripe, Salesforce - and then tried to connect to NetSuite, you already know the feeling. The documentation is sprawling. The authentication is a math problem. And there isn't one API; there are at least three, each with capabilities the others lack. 

**NetSuite API integration** is the "final boss" of ERP connectivity - and most teams underestimate it by months. NetSuite isn't just an ERP; it is a collection of legacy systems and modules held together by SOAP, SuiteScript, and a newer REST layer that still feels like a work in progress.

If you try to ship a NetSuite integration with "just REST" or "just SOAP", you'll spend the next quarter chasing rate limits, missing metadata, and instance-specific weirdness. A reliable integration requires orchestrating across multiple API surfaces, dynamically adapting queries to the customer's specific NetSuite edition, and managing the headache of OAuth 1.0a Token-Based Authentication.

Here is the architectural playbook for getting it right.

## Navigating NetSuite's Three API Surfaces

Most developers assume they can just use the NetSuite REST API for everything. This is a mistake. NetSuite exposes multiple API surfaces that serve different purposes, and choosing the wrong one leads to performance bottlenecks or missing data.

| API Surface | Best For | Limitations |
|---|---|---|
| **SuiteQL** | Complex reads with JOINs, aggregation, multi-table queries. | Read-only. Cannot create, update, or delete records. |
| **SuiteTalk REST API** | CRUD on individual records (create vendor, update PO). | Limited filtering. Historically one record per write request. |
| **SOAP API** | Legacy record types like detailed tax rate profiles. | Being deprecated. Final removal scheduled for the 2028.2 release. |
| **RESTlets (SuiteScript)** | Custom server-side logic, PDF generation, dynamic metadata. | Requires deploying code into the customer's NetSuite account. |

### SuiteQL: Your Default for Reads

When extracting large NetSuite datasets, SuiteQL significantly outperforms traditional REST API approaches. The REST record API gives you one record at a time. If you need a vendor's address, subsidiary relationships, and currency information, that requires multiple calls and client-side joins. This is a non-starter for performance.

SuiteQL lets you do all of that in a single query executed via `POST /services/rest/query/v1/suiteql`. It also supports the `BUILTIN.DF()` function, which is essential for retrieving the human-readable display values for status fields or dropdowns instead of just internal IDs.

```sql
SELECT
  v.id,
  v.companyname,
  ea.city,
  c.symbol AS currency_code,
  s.name AS subsidiary_name,
  BUILTIN.DF(v.status) AS status_label
FROM vendor v
  LEFT JOIN entityaddress ea ON v.defaultbillingaddress = ea.nkey
  LEFT JOIN currency c ON v.currency = c.id
  LEFT JOIN vendorsubsidiaryrelationship vsr ON v.id = vsr.entity
  LEFT JOIN subsidiary s ON vsr.subsidiary = s.id
WHERE v.isinactive = 'F'
```

**Essential SuiteQL details:**
*   You must pass the `Prefer: transient` header to avoid unnecessary resource locking.
*   It supports paging via `limit` and `offset` parameters in the body.
*   A single query result set is capped at 100,000 rows, requiring cursor management for massive datasets.

### REST Record API: Best for Writes

The REST Record API (`/services/rest/record/v1/{type}/{id}`) is where you do record CRUD. Writes in NetSuite are notoriously slow because every create or update can trigger synchronous workflows, scripts, and validation logic.

To handle this, use **async jobs and idempotency keys**. Oracle supports the `Prefer: respond-async` header and `X-NetSuite-idempotency-key` for submitting work and preventing accidental duplicates when requests time out - which they will.

### SOAP: Treat it as Technical Debt

Why use SOAP? Because for certain legacy data models - like complete tax rate profiles with nested subsidiary assignments - the modern APIs still fall short. SuiteQL's `salestaxitem` table, for example, often lacks the full rate configuration detail found in the SOAP `getList` operation.

However, Oracle has scheduled the removal of SOAP web services. No new SOAP endpoints will be issued starting in release 2026.1, and complete removal is slated for 2028.2. If you must use SOAP, isolate it behind an internal interface so you can swap it out later.

## The Authentication Nightmare: OAuth 1.0a and TBA

NetSuite supports OAuth 2.0, but the default refresh token expires after 7 days. Once it expires, a human must manually click through a consent screen. For a B2B SaaS product relying on continuous background data synchronization, this is unacceptable.

This forces integration developers to fall back to **Token-Based Authentication (TBA)**, which behaves like long-lived machine credentials. TBA is based on OAuth 1.0a.

The cost is complexity. You must compute a unique HMAC-SHA256 signature for every single HTTP request. If your base string is out of order by a single character, NetSuite rejects the request with a cryptic error.

Here is a minimal Node.js example of how to sign a NetSuite REST request:

```typescript
import crypto from 'crypto';

function percentEncode(str: string): string {
  return encodeURIComponent(str)
    .replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
}

export function netsuiteOAuthHeader(opts: {
  method: string, url: string, accountId: string,
  consumerKey: string, consumerSecret: string,
  tokenId: string, tokenSecret: string,
  extraQueryParams?: Record<string, string>
}): string {
  const oauthParams: Record<string, string> = {
    oauth_consumer_key: opts.consumerKey,
    oauth_token: opts.tokenId,
    oauth_nonce: crypto.randomBytes(16).toString('hex'),
    oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
    oauth_signature_method: 'HMAC-SHA256',
    oauth_version: '1.0'
  };

  const allParams = { ...oauthParams, ...(opts.extraQueryParams ?? {}) };
  
  const paramString = Object.entries(allParams)
    .map(([k, v]) =>[percentEncode(k), percentEncode(v)] as const)
    .sort((a, b) => (a[0] === b[0] ? a[1].localeCompare(b[1]) : a[0].localeCompare(b[0])))
    .map(([k, v]) => `${k}=${v}`)
    .join('&');

  const baseString =[
    opts.method.toUpperCase(),
    percentEncode(opts.url),
    percentEncode(paramString)
  ].join('&');

  const signingKey = `${percentEncode(opts.consumerSecret)}&${percentEncode(opts.tokenSecret)}`;
  const signature = crypto.createHmac('sha256', signingKey).update(baseString).digest('base64');

  const header = Object.entries({ ...oauthParams, oauth_signature: signature })
    .map(([k, v]) => `${percentEncode(k)}="${percentEncode(v)}"`)
    .join(',');

  return `OAuth realm="${percentEncode(opts.accountId)}",${header}`;
}
```

> [!WARNING]
> **The Sandbox Gotcha:** If you're using a sandbox NetSuite account, it will have an `sb` suffix. In the URL, it looks like `123456-sb1`. But in the OAuth `realm` parameter, it **must** be uppercased with an underscore: `123456_SB1`. Mixing these up produces silent auth failures.

For a deeper dive into this math problem, see our guide on [enterprise API authentication challenges](https://truto.one/blog/bearer-tokens-were-the-easy-part-the-real-challenge-of-enterprise-auth/).

### Anatomy of the Signature Base String

The signing code above computes an HMAC-SHA256 over a "base string" - and that base string is the single most common source of authentication bugs. It is three percent-encoded segments joined by `&`:

```
HTTP_METHOD & percent_encode(BASE_URL) & percent_encode(SORTED_PARAMS)
```

The parameter string is built by collecting all OAuth parameters (including `oauth_nonce` and `oauth_timestamp`), plus any URL query parameters, sorting them lexicographically by key name first, then by value if keys collide. Each `key=value` pair is joined with `&`. If any parameter is out of order - even by a single character - NetSuite returns an `INVALID_LOGIN_ATTEMPT` error with no further detail.

Rules that trip people up:

*   **Nonce**: Must be unique per request. Use a cryptographically random string (the example uses 16 random bytes as hex). The nonce must be between 6 and 64 characters. Reusing a nonce within the same timestamp window causes silent rejections.
*   **Timestamp**: Unix epoch in **seconds**, not milliseconds. If your server clock drifts more than about 5 minutes from NetSuite's servers, auth fails silently.
*   **URL**: Must be the base URL without query parameters. If the endpoint URL has query parameters (like a RESTlet URL with `?script=6&deploy=1`), those parameters get sorted into the parameter set alongside the OAuth parameters - they do not stay in the URL portion of the base string.
*   **Percent encoding**: Must follow RFC 3986. JavaScript's `encodeURIComponent` misses `!'()*`, which is why the `percentEncode` helper above manually encodes those characters.
*   **Realm**: Goes in the `Authorization` header but is **excluded** from the base string. Including it in the parameter set is a common mistake.

## Pagination and Signed Requests

Every SuiteQL call uses the same `POST /services/rest/query/v1/suiteql` endpoint, passing `limit` and `offset` in the request body. The response includes `hasMore` (boolean) and `totalResults`. Loop until `hasMore` is `false`.

The catch: each paginated request is a separate HTTP call, and each one requires a **fresh OAuth 1.0a signature** with a new nonce and timestamp. You cannot batch pagination. This makes the signing code a hot path - any bug in signature generation shows up as intermittent auth failures partway through a pagination run.

```typescript
async function paginateSuiteQL(
  query: string,
  signRequest: (url: string, method: string) => string,
  pageSize = 1000
): Promise<any[]> {
  const url = 'https://{accountId}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql';
  let offset = 0;
  let allResults: any[] = [];
  let hasMore = true;

  while (hasMore) {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Authorization': signRequest(url, 'POST'), // Fresh signature every call
        'Content-Type': 'application/json',
        'Prefer': 'transient'
      },
      body: JSON.stringify({ q: `${query} ORDER BY id`, limit: pageSize, offset })
    });

    if (response.status === 429) {
      // Back off and retry - NetSuite concurrency limit hit
      await new Promise(r => setTimeout(r, 2000 + Math.random() * 3000));
      continue;
    }

    const data = await response.json();
    allResults = allResults.concat(data.items ?? []);
    hasMore = data.hasMore ?? false;
    offset += pageSize;
  }

  return allResults;
}
```

### Troubleshooting: Pagination and Signing Failures

| Symptom | Likely Cause | Fix |
|---|---|---|
| 401 on page 2 or later | Reused nonce or stale timestamp | Generate a fresh nonce and timestamp for every request. Never cache the `Authorization` header across pages. |
| Duplicate rows across pages | Missing `ORDER BY` clause | Always include `ORDER BY` on a unique column (e.g., `id`). Without it, NetSuite does not guarantee row ordering between pages. |
| Missing rows between pages | Data changed mid-pagination | Use keyset pagination (`WHERE id > :lastSeenId`) instead of offset for consistency on mutable datasets. |
| 400 `INVALID_PARAMETER` at high offsets | Offset exceeds 100,000 row ceiling | Switch to keyset pagination or add tighter `WHERE` filters to reduce the result set. |
| Timeout on large result sets | Page size too large or query too complex | Reduce `limit` to 500, simplify JOINs, and select only the columns you need. |
| 429 `Too Many Requests` | Account concurrency limit exhausted | Add exponential backoff with jitter. NetSuite enforces concurrency limits at the account level - a tight pagination loop can exhaust your slots. |

## Handling NetSuite's Dynamic Architecture

Unlike a modern SaaS tool with a static schema, every NetSuite instance is a unique snowflake. The database tables available to query depend entirely on what modules the customer has purchased.

Two critical architectural variants will break your integration if you do not handle them dynamically:
1. **OneWorld (Multi-Subsidiary):** If a customer uses OneWorld, your SuiteQL queries must `JOIN` the `subsidiary` table. If OneWorld is disabled, querying the `subsidiary` table throws a fatal "Record not found" error.
2. **Multi-Currency:** Similarly, if a customer operates in multiple currencies, you must `JOIN` the `currency` table. If they operate in a single currency, the table does not exist.

This creates a **query matrix**. For an accounting integration with 15+ resources, that's 60+ query variants you need to manage. You must detect these features at connection time and swap the SQL templates at runtime.

### Post-Install Feature Detection
Don't guess. Detect. When a customer first connects their NetSuite account, probe the instance to determine its capabilities:

```typescript
async function detectFeatures(callSuiteQL: (q: string) => Promise<any>) {
  const multiCurrency = await callSuiteQL('SELECT 1 FROM currency')
    .then(() => true)
    .catch((e) => String(e.message).includes("Record 'currency' was not found") ? false : Promise.reject(e));

  const oneWorld = await callSuiteQL('SELECT 1 FROM subsidiary')
    .then(() => true)
    .catch((e) => String(e.message).includes("Record 'subsidiary' was not found") ? false : Promise.reject(e));

  return { multiCurrency, oneWorld };
}
```

Store these flags on the connection record and use them to dynamically include or exclude JOINs. This level of adaptation is exactly [why schema normalization is the hardest problem in SaaS integrations](https://truto.one/blog/why-schema-normalization-is-the-hardest-problem-in-saas-integrations/).

### Pre-Flight Checklist

Run these checks when a new NetSuite account connects. Store results on the connection record and reference them in every subsequent query.

- [ ] **Validate credentials** - Make any authenticated SuiteQL call (e.g., `SELECT 1 FROM account`). A 401 means bad token or consumer credentials. A 403 means the integration role lacks Web Services permissions.
- [ ] **Detect OneWorld** - `SELECT 1 FROM subsidiary`. Error with "Record not found" = standard account. Success = OneWorld.
- [ ] **Detect multi-currency** - `SELECT 1 FROM currency`. Error = single-currency account. Success = multi-currency.
- [ ] **Fetch company identity** - Call the token info endpoint to retrieve the account name and store it as a label on the connection.
- [ ] **Parse SuiteScript URL** - If the customer provided a Suitelet/RESTlet URL, extract the `script` and `deploy` parameters. Validate with a test call.
- [ ] **Probe key tables** - Run `SELECT 1 FROM employee`, `SELECT 1 FROM vendor`, `SELECT 1 FROM customer`, and `SELECT 1 FROM transaction`. Permission errors here mean the integration role is missing record-level access.
- [ ] **Store context flags** - Persist `multi_currency`, `multi_subsidiary`, and `suitescript_available` as booleans. Every query template branches on these values.

## SuiteQL Recipes: Common Queries Without SOAP

The biggest payoff of a SuiteQL-first architecture is eliminating SOAP dependencies for reads. Here are production-tested query patterns for three commonly requested data sets.

### Invoices (Vendor Bills and Customer Invoices)

```sql
SELECT
  t.id,
  t.tranid AS invoice_number,
  t.type AS transaction_type,
  BUILTIN.DF(t.entity) AS entity_name,
  TO_CHAR(t.trandate, 'MM/DD/YYYY') AS issue_date,
  TO_CHAR(t.duedate, 'MM/DD/YYYY') AS due_date,
  t.foreigntotal AS total_amount,
  BUILTIN.DF(t.status) AS status_label,
  t.status AS status_code,
  tl.item,
  BUILTIN.DF(tl.item) AS item_name,
  tl.quantity,
  tl.rate,
  tl.amount AS line_amount
FROM transaction t
  INNER JOIN transactionline tl ON t.id = tl.transaction
WHERE t.type IN ('VendBill', 'CustInvc')
  AND tl.mainline = 'F'
  AND tl.taxline = 'F'
ORDER BY t.id
```

Filter on `t.type = 'VendBill'` for AP bills or `t.type = 'CustInvc'` for AR invoices. NetSuite's single-character status codes map as follows: `A` = Open, `B` = Paid, `C` = Cancelled. Add `t.trandate >= '01/01/2025'` for date-range filtering. The `mainline = 'F'` and `taxline = 'F'` filters exclude the summary line and tax lines, giving you only the actual line items.

### Subsidiaries (OneWorld Accounts)

```sql
SELECT
  s.id,
  s.name,
  s.fullname AS legal_name,
  s.email,
  s.isinactive,
  s.iselimination,
  BUILTIN.DF(s.parent) AS parent_subsidiary,
  BUILTIN.DF(s.currency) AS base_currency,
  sa.addr1 AS street,
  sa.city,
  sa.state,
  sa.zip,
  sa.country
FROM subsidiary s
  LEFT JOIN subsidiarymainaddress sa ON s.mainaddress = sa.nkey
ORDER BY s.id
```

> [!WARNING]
> This query only works on OneWorld accounts. On standard (non-OneWorld) accounts, the `subsidiary` table does not exist and the query throws `"Record 'subsidiary' was not found"`. Always run the OneWorld detection check first (see the pre-flight checklist above).

### Tax Items (What SuiteQL Can - and Cannot - Give You)

```sql
SELECT
  st.id,
  st.itemid AS tax_name,
  st.rate,
  st.isinactive,
  BUILTIN.DF(st.taxtype) AS tax_type_name,
  st.description
FROM salestaxitem st
WHERE st.isinactive = 'F'
ORDER BY st.itemid
```

This covers the basics: name, rate percentage, tax type display name, and active status. However, SuiteQL's `salestaxitem` table does not expose the complete record structure - subsidiary assignments as record references and nested tax type internal IDs are missing. If you need that level of detail, you have two options: the legacy SOAP `getList` operation (which you should plan to retire before the 2028.2 deadline), or a RESTlet that loads the record server-side using `record.load()`. See the RESTlet example in the next section.

## Beyond the API: Using SuiteScript for Missing Capabilities

Even if you master SuiteQL and TBA, you will eventually hit edge cases the standard APIs cannot support. 

1. **Purchase Order PDFs:** If you are building an [AP automation integration](https://truto.one/blog/ap-automation-apis-syncing-purchase-orders-to-payments/), you'll quickly find that the REST API cannot generate or download binary PDFs of transactions. You must use the server-side `N/render` module in SuiteScript to generate these.

```javascript
// Conceptual Suitelet logic for PDF generation
const render = require('N/render');

function getPurchaseOrderPdf(id) {
    const pdfFile = render.transaction({
        entityId: parseInt(id),
        printMode: render.PrintMode.PDF
    });
    return pdfFile.getContents(); // Returns binary string
}
```

2. **Dynamic Form Metadata:** NetSuite allows admins to heavily customize forms. A field might be optional in the database schema but marked as mandatory on a specific custom vendor form. The REST metadata catalog only exposes the database schema, not the runtime form state.

The only technical solution is to bypass the standard APIs and deploy a custom **RESTlet** (SuiteScript) into the customer's account. By calling `record.create({type: 'purchaseorder', isDynamic: true})` in memory within the SuiteScript, you can introspect the actual runtime state - including which fields are mandatory and which select options are available for the current user's role.

```javascript
// Conceptual logic to detect mandatory fields and select options
const record = require('N/record');

function getFieldMetadata(recordType, formId) {
    const objRecord = record.create({ type: recordType, isDynamic: true, defaultValues: { customform: formId } });
    const field = objRecord.getField({ fieldId: 'department' });
    
    return {
        isMandatory: field.isMandatory,
        isVisible: field.isDisplay,
        options: field.getSelectOptions() // Returns valid options for this specific account
    };
}
```

3. **Tax Rate Details (Replacing SOAP):** As SOAP approaches its end-of-life, you can replace the `getList` operation for tax rates with a RESTlet that loads the full record server-side. This gives you the subsidiary assignments and nested tax type references that SuiteQL cannot expose.

```javascript
/**
 * @NApiVersion 2.1
 * @NScriptType Restlet
 */
define(['N/record', 'N/search'], (record, search) => {
    function get(context) {
        if (context.id) {
            // Load a single tax item with full detail
            const taxItem = record.load({ type: 'salestaxitem', id: context.id });
            return {
                id: taxItem.getValue('id'),
                name: taxItem.getValue('itemid'),
                rate: taxItem.getValue('rate'),
                taxType: taxItem.getText('taxtype'),
                taxTypeId: taxItem.getValue('taxtype'),
                isInactive: taxItem.getValue('isinactive'),
                description: taxItem.getValue('description'),
                // Subsidiary list - only present on OneWorld accounts
                subsidiaries: getSublistIds(taxItem, 'subsidiary')
            };
        }
        // List all active tax items
        const items = [];
        search.create({
            type: 'salestaxitem',
            filters: [['isinactive', 'is', 'F']],
            columns: ['internalid']
        }).run().each(result => {
            const loaded = record.load({ type: 'salestaxitem', id: result.id });
            items.push({
                id: loaded.getValue('id'),
                name: loaded.getValue('itemid'),
                rate: loaded.getValue('rate'),
                taxType: loaded.getText('taxtype'),
                isInactive: loaded.getValue('isinactive')
            });
            return true; // continue iteration
        });
        return items;
    }

    function getSublistIds(rec, sublistId) {
        const count = rec.getLineCount({ sublistId });
        const ids = [];
        for (let i = 0; i < count; i++) {
            ids.push(rec.getSublistValue({ sublistId, fieldId: 'subsidiary', line: i }));
        }
        return ids;
    }

    return { get };
});
```

This RESTlet replaces the SOAP `getList` call entirely. It uses `record.load()` to access the full tax item record - including subsidiary assignments that SuiteQL cannot return. When Oracle removes SOAP in 2028.2, this pattern will still work.

Yes, asking customers to install a SuiteScript bundle adds friction. But being honest with your product team is better than promising capabilities the native API cannot deliver.

### Deploying a RESTlet: Step by Step

Deploying a RESTlet requires admin access to the customer's NetSuite account. Here is the process:

1. **Enable SuiteScript and Web Services.** In NetSuite, go to Setup > Company > Enable Features > SuiteCloud. Ensure "Client SuiteScript", "Server SuiteScript", and "REST Web Services" are all checked.

2. **Upload the script file.** Go to Documents > Files > File Cabinet. Create a folder (e.g., `/SuiteScripts/Integrations/`) and upload your `.js` file.

3. **Create the Script record.** Go to Customization > Scripting > Scripts > New. Select the uploaded file, choose **RESTlet** as the script type, and map your exported functions to HTTP methods (e.g., `get` to GET, `post` to POST).

4. **Deploy the script.** On the script record, click Deploy Script. Set Status to **Released** - "Testing" mode blocks external calls. Set the Audience to the integration role only, not "All Roles".

5. **Capture the External URL.** After saving the deployment, NetSuite generates an External URL like `https://{accountId}.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=123&deploy=1`. This is the URL your integration will call.

6. **Test externally.** Call the External URL with your TBA credentials from outside NetSuite (e.g., using curl or Postman) to verify authentication and response shape before wiring it into your integration.

> [!TIP]
> **Reduce friction with SuiteApp bundles.** Instead of asking each customer to manually upload scripts, package your RESTlet as a SuiteApp bundle. Customers install it with a single click via a bundle ID, and the script records and deployments are created automatically.

## How Truto Solves the NetSuite API Integration Challenge

[Building this infrastructure in-house](https://truto.one/blog/build-vs-buy-the-true-cost-of-building-saas-integrations-in-house/) requires months of dedicated engineering time. When evaluating the [models for product integrations](https://truto.one/blog/3-models-for-product-integrations-a-choice-between-control-and-velocity/), you'll find that traditional iPaaS tools give you a visual builder but leave your engineering team manually managing HMAC-SHA256 signatures, while many unified APIs lock NetSuite sandbox access behind enterprise tiers or force you into rigid models that strip away custom fields.

At Truto, we built our Unified Accounting API with an architecture specifically designed to handle enterprise ERPs like NetSuite:

*   **SuiteQL-First:** We use SuiteQL for almost all read operations, bypassing the REST API's single-record limitations.
*   **Automated Feature Detection:** The moment a user connects, Truto automatically detects OneWorld and multi-currency configurations, adjusting the underlying SQL queries on the fly.
*   **Zero-Code JSONata Normalization:** We use powerful JSONata expressions to map NetSuite's complex responses into a clean, unified schema. Custom fields (`custbody42`) are automatically extracted.
*   **Polymorphic Routing:** NetSuite treats vendors and customers as separate tables. Truto exposes a single unified `contacts` resource. Pass `?contact_type=vendor`, and our proxy layer dynamically routes the request and executes the correct SuiteQL JOINs.

```mermaid
sequenceDiagram
    participant App as Your Application
    participant Truto as Truto Unified API
    participant NS_SQL as NetSuite SuiteQL

    App->>Truto: GET /unified/accounting/contacts?contact_type=vendor
    Truto->>Truto: Detect multi-currency & OneWorld status
    Truto->>NS_SQL: POST /query/v1/suiteql (HMAC-SHA256 Signed)
    NS_SQL-->>Truto: Raw Vendor Data
    Truto->>Truto: JSONata Normalization
    Truto-->>App: Standardized Contact Array
```

If you are building a product that requires deep ERP data - whether for e-commerce syncs or [automating financial controls](https://truto.one/blog/breaking-the-sox-barrier-automating-financial-controls-with-unified-accounting-apis/) - you cannot afford to have your engineering team bogged down by legacy API quirks.

> Stop fighting legacy ERP APIs. Let Truto handle the authentication math, SuiteQL queries, and schema normalization so your team can focus on your core product.
>
> [Talk to us](https://cal.com/truto/partner-with-truto)
