Skip to content

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

NetSuite integrations break when treated like a normal REST API. Learn how to architect a reliable integration using SuiteQL, TBA, and dynamic feature detection.

Uday Gajavalli Uday Gajavalli · · 15 min read
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.

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.

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:

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.

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.

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:

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.

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)

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)

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)

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, 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.
// 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
}
  1. 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.

// 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
    };
}
  1. 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.
/**
 * @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 requires months of dedicated engineering time. When evaluating the models for product integrations, 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.
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 - you cannot afford to have your engineering team bogged down by legacy API quirks.

FAQ

Should I use SuiteQL or the NetSuite REST Record API for reads?
Use SuiteQL for most read-heavy workloads, especially anything that needs JOINs or server-side filtering. The REST Record API is better suited for individual CRUD operations (writes).
Is NetSuite OAuth 2.0 usable for production integrations?
For background machine-to-machine integrations, OAuth 2.0 is difficult because the default refresh token expires after 7 days, requiring manual user re-consent. Most teams use OAuth 1.0a Token-Based Authentication (TBA) instead.
How do I handle NetSuite OneWorld vs Standard edition differences?
You must implement runtime feature detection. Probe the NetSuite account at connection time to check for subsidiary and currency table availability, then dynamically adjust your SuiteQL queries to include or exclude those JOINs.
Why do I need a SuiteScript RESTlet for NetSuite integration?
Some capabilities are impossible through the REST API or SuiteQL alone. Generating transaction PDFs (like Purchase Orders) and fetching dynamic form field metadata both require deploying a custom SuiteScript Suitelet.
Is the NetSuite SOAP API being deprecated?
Yes. Starting with NetSuite 2026.1, no new SOAP endpoints will be issued. Complete SOAP removal is scheduled for the 2028.2 release. Treat existing SOAP dependencies as technical debt.

More from our Blog