NetSuite SuiteQL Connector – Schema Discovery Failures & Custom Ingestion Workaround

Problem

While integrating NetSuite data into Palantir Foundry, we encountered persistent failures when attempting to preview or ingest certain tables using the native SuiteQL connector. The connector’s metadata/schema discovery process would fail before any data could be retrieved.

In our case, we first hit this issue with the TransactionLine table, but this can happen with any NetSuite table depending on the metadata dependencies the connector attempts to resolve during schema generation.

Symptoms

  • Schema discovery failure: The connector could not retrieve columns for the target table, throwing errors during the previewSource request.

  • Metadata parsing errors: The connector returned invalid object name errors (e.g., sys_disconnect) during its internal metadata introspection.

  • Permission violations: During schema generation, the connector attempted to access unrelated metadata objects, resulting in permission errors such as:

    Permission Violation: You need the “Custom Item Fields” permission to access this page.

Investigation

Step Action Result
1 Ran the same SuiteQL queries directly via Postman against the NetSuite REST API :white_check_mark: Data returned successfully — confirmed the table exists and credentials are valid
2 Granted additional permissions (including custom field access) :cross_mark: Schema discovery still failed
3 Tested other tables through the same connector and role :white_check_mark: Many succeeded — only specific tables failed consistently

Root Cause

The failure occurred during Foundry’s schema and metadata introspection, not during actual data retrieval. The native connector attempts to discover additional metadata objects and custom field definitions as part of its schema generation process. For certain tables, this process triggers cascading failures — schema discovery errors, metadata parsing issues, and permission errors on internal NetSuite metadata endpoints.

Additionally, NetSuite’s SuiteQL REST API requires OAuth 1.0 authentication (Token-Based Authentication with HMAC-SHA256 signatures). Foundry’s standard REST source does not natively support OAuth 1.0 in the way NetSuite requires, which ruled out a simple REST-based workaround.


Solution: Custom SuiteQL Ingestion via External Systems Connector

Instead of relying on the native connector’s table preview and schema discovery, we implemented a custom ingestion pipeline that bypasses the metadata layer entirely and queries the SuiteQL REST API directly. This approach works for any NetSuite table or custom SuiteQL query.

Approach

  1. Used Foundry’s External Systems connector to securely store and retrieve NetSuite OAuth credentials.
  2. Implemented OAuth 1.0 (HMAC-SHA256) request signing using the requests-oauthlib library.
  3. Executed SuiteQL queries directly against NetSuite’s REST API endpoint.
  4. Loaded results into Foundry using a Python transform.
  5. Added pagination and retries for reliability.

Key Code

Below is the core of the solution — OAuth setup, the SuiteQL request with pagination, and writing results to Foundry. You can adapt the SQL query to target any table or join in NetSuite:

import logging
import time

import pandas as pd
from requests.sessions import Session
from requests_oauthlib.oauth1_auth import OAuth1

from transforms.api import transform, Output
from transforms.external.systems import (
    external_systems,
    Source,
    ResolvedSource,
)

logger = logging.getLogger(__name__)

PAGE_LIMIT = 100
REQUEST_TIMEOUT = (30, 600)
MAX_RETRIES = 5
RETRY_WAIT_SECONDS = 15


@external_systems(
    netsuite_source=Source("<your-source-rid>")
)
@transform(
    output=Output("<your-output-dataset-rid>"),
)
def compute(output, netsuite_source: ResolvedSource):
    logger.info("Starting transform...")

    # --- OAuth 1.0 setup using External Systems secrets ---
    connection = netsuite_source.get_https_connection()
    base_url = connection.url
    client: Session = connection.get_client()

    oauth = OAuth1(
        client_key=netsuite_source.get_secret("additionalSecretConsumerKey"),
        client_secret=netsuite_source.get_secret("additionalSecretConsumerSecret"),
        resource_owner_key=netsuite_source.get_secret("additionalSecretTokenId"),
        resource_owner_secret=netsuite_source.get_secret("additionalSecretTokenSecret"),
        signature_method="HMAC-SHA256",
        realm=netsuite_source.get_secret("additionalSecretAccountID"),
    )
    client.auth = oauth

    # --- Execute paginated SuiteQL query ---
    suiteql_url = f"{base_url}/services/rest/query/v1/suiteql"

    # Replace with your own SuiteQL query targeting any table
    query = "SELECT * FROM TransactionLine WHERE ..."

    all_records = []
    offset = 0
    has_more = True

    while has_more:
        for retry in range(MAX_RETRIES):
            try:
                response = client.post(
                    suiteql_url,
                    json={"q": query},
                    headers={"Content-Type": "application/json", "Prefer": "transient"},
                    params={"limit": PAGE_LIMIT, "offset": offset},
                    timeout=REQUEST_TIMEOUT,
                )
                break
            except Exception as e:
                logger.warning(f"Request failed (attempt {retry + 1}/{MAX_RETRIES}): {e}")
                if retry == MAX_RETRIES - 1:
                    raise
                time.sleep(RETRY_WAIT_SECONDS)

        response.raise_for_status()
        data = response.json()
        items = data.get("items", [])
        for item in items:
            item.pop("links", None)  # Remove NetSuite HATEOAS links from response
        all_records.extend(items)
        has_more = data.get("hasMore", False)
        offset += PAGE_LIMIT

    # --- Write results ---
    df = pd.DataFrame(all_records)
    if not df.empty:
        output.write_pandas(df)

    logger.info(f"Transform complete. Total records: {len(all_records)}")

Key Details

  • Works for any table or query — just change the SuiteQL statement. This is not limited to any specific table.
  • OAuth 1.0 signing is handled by requests-oauthlib, which Foundry supports as a library dependency.
  • Secrets (consumer key/secret, token key/secret, account ID) are stored securely in the External Systems connector and retrieved at runtime — never hardcoded.
  • Pagination uses NetSuite’s limit/offset parameters and the hasMore flag.
  • Retries with backoff handle transient network or API failures.
1 Like

hey once check this code

import logging
import time
from typing import Any, Dict, List

import pandas as pd
from requests.sessions import Session
from requests_oauthlib.oauth1_auth import OAuth1

from transforms.api import transform, Output
from transforms.external.systems import external_systems, Source, ResolvedSource

logger = logging.getLogger(**name**)

# Configuration

PAGE_LIMIT = 1000          # NetSuite allows up to 5000, but start conservative
REQUEST_TIMEOUT = (30, 900)  # connect, read
MAX_RETRIES = 5
RETRY_BACKOFF = 15
MAX_RECORDS = 5_000_000    # Safety limit - adjust or remove for very large tables

@external_systems(
netsuite_source=Source("")
)
@transform(
output=Output(""),
)
def compute(output, netsuite_source: ResolvedSource):
logger.info("Starting NetSuite SuiteQL ingestion...")


# --- OAuth 1.0 Setup ---
connection = netsuite_source.get_https_connection()
base_url = connection.url.rstrip("/")  # Avoid double slashes
client: Session = connection.get_client()

oauth = OAuth1(
    client_key=netsuite_source.get_secret("additionalSecretConsumerKey"),
    client_secret=netsuite_source.get_secret("additionalSecretConsumerSecret"),
    resource_owner_key=netsuite_source.get_secret("additionalSecretTokenId"),
    resource_owner_secret=netsuite_source.get_secret("additionalSecretTokenSecret"),
    signature_method="HMAC-SHA256",
    realm=netsuite_source.get_secret("additionalSecretAccountID"),
)
client.auth = oauth

suiteql_url = f"{base_url}/services/rest/query/v1/suiteql"

# === YOUR QUERY HERE ===
query = """
    SELECT 
        *
    FROM 
        TransactionLine
    WHERE 
        lastModifiedDate >= '2025-01-01'   -- Add filters for performance!
    ORDER BY 
        internalId ASC
"""

all_records: List[Dict[str, Any]] = []
offset = 0
total_fetched = 0
has_more = True

while has_more and total_fetched < MAX_RECORDS:
    for attempt in range(MAX_RETRIES):
        try:
            response = client.post(
                suiteql_url,
                json={"q": query},
                headers={
                    "Content-Type": "application/json",
                    "Prefer": "transient"  # Important for performance
                },
                params={
                    "limit": PAGE_LIMIT,
                    "offset": offset
                },
                timeout=REQUEST_TIMEOUT,
            )
            response.raise_for_status()
            break
        except Exception as e:
            if attempt == MAX_RETRIES - 1:
                logger.error(f"Failed after {MAX_RETRIES} attempts. Last error: {e}")
                raise
            logger.warning(f"Request failed (attempt {attempt+1}). Retrying in {RETRY_BACKOFF}s...")
            time.sleep(RETRY_BACKOFF * (2 ** attempt))  # Exponential backoff

    data = response.json()
    items = data.get("items", [])
    
    # Clean HATEOAS links
    for item in items:
        item.pop("links", None)

    all_records.extend(items)
    total_fetched += len(items)

    has_more = data.get("hasMore", False)
    offset += PAGE_LIMIT

    logger.info(f"Fetched {len(items)} records (total: {total_fetched}, hasMore: {has_more})")

# --- Convert to DataFrame & Write ---
if not all_records:
    logger.warning("No records returned from query.")
    output.write_pandas(pd.DataFrame())
    return

df = pd.DataFrame(all_records)

# Optional: Basic cleanup
# df = df.convert_dtypes()  # Let pandas infer better types

output.write_pandas(df)
logger.info(f"Successfully wrote {len(df)} records to Foundry.")