How to Build ERP Integrations Without Storing Customer Data
Learn how to architect pass-through, zero data retention ERP integrations (NetSuite, SAP) that pass enterprise InfoSec reviews and avoid compliance liabilities.
B2B SaaS teams face a brutal reality when moving upmarket. You build a financial workflow tool, an AI agent, or an analytics platform that needs to read and write data to enterprise ERPs like NetSuite, SAP, or Microsoft Dynamics 365. The engineering team ships the integration. The product looks great in demos. Then, the deal dies in procurement.
If you are trying to integrate your B2B SaaS product with enterprise ERPs, and your integration middleware writes any customer financial data to a database, you are building a compliance liability. Your InfoSec review will fail, your deal will stall, and your competitor who figured out pass-through architecture will close the contract instead. If you want to know how to build ERP integrations securely, the answer requires abandoning the traditional sync-and-cache model entirely in favor of a pass-through, zero data retention architecture.
This guide breaks down exactly why caching ERP data is an architectural mistake that kills enterprise deals, the technical realities of integrating with complex systems like NetSuite and SAP, and how to build a stateless integration layer that normalizes data on the fly and passes enterprise procurement without drama.
The Enterprise Procurement Wall: Why Caching ERP Data Kills Deals
Enterprise deals die in procurement, not in product demos. The number one thing that kills them is your data storage architecture.
When your sales team pushes a six-figure contract to the final stage, the buyer's InfoSec team sends over a security questionnaire. Enterprise procurement teams use data residency and security questionnaires as a primary filter for software vendors. One of the first questions you will encounter is: "Does the vendor store, cache, or persist any customer data? If so, describe the data types, retention periods, and storage locations."
If your integration layer syncs NetSuite general ledger entries, SAP purchase orders, or Dynamics 365 payroll data into a centralized database just to transform it before sending it to your application, you have to answer "Yes."
That single "Yes" triggers a massive compliance cascade. You are now legally responsible for securing a duplicate copy of your customer's most sensitive financial data. According to IBM's 2024 Cost of a Data Breach Report, the global average cost of a data breach reached a record $4.88 million - a 10% increase from the previous year. Enterprise buyers know this. They do not want their financial data sitting in your middleware provider's AWS account.
Traditional integration platforms force you into this liability. They pull data from the upstream ERP, store it in an intermediate database, apply transformation logic, and then push it to your application. This expands the attack surface. It complicates SOC 2 audits. It forces you to sign complex Data Processing Agreements (DPAs) that delay contracts by months.
To bypass this procurement wall, you must prove to the buyer that their data never rests on your integration infrastructure.
What is Zero Data Retention (ZDR) in API Architecture?
Zero Data Retention (ZDR) is an architectural design pattern where an integration layer processes third-party API payloads entirely in memory, never writing them to persistent storage. The middleware acts as a stateless proxy. It authenticates the request, transforms the payload, forwards it between your application and the upstream ERP, and discards the payload the moment the response is delivered.
In a strict ZDR architecture, there are:
- No database writes: Payload bodies are never inserted into a relational or NoSQL database.
- No message queue persistence: Event streams and webhooks are processed ephemerally without being dumped into long-term storage queues.
- No payload logging: Application logs record the transaction status (e.g., HTTP 200 OK) but strip the actual request and response bodies.
This approach is becoming a strict requirement for enterprise compliance. As noted by CData Software, ZDR ensures that sensitive data is processed in-memory and never written to persistent storage, which is critical for meeting GDPR data minimization principles and HIPAA compliance.
If you want to understand the foundational concepts behind this, read our guide on what zero data retention means for SaaS integrations. The core takeaway is that by eliminating the cache, you eliminate the compliance liability. You shrink your attack surface to zero at rest.
The Hidden Risks of Traditional iPaaS and ETL Workflows
For the last decade, connecting to enterprise systems meant relying on traditional Integration Platform as a Service (iPaaS) or Extract, Transform, Load (ETL) workflows. Platforms like MuleSoft popularized an API-led connectivity model that often relies on a three-tier architecture (System, Process, Experience APIs). While powerful for internal enterprise orchestration, this model frequently depends on stateful orchestration, caching, and data synchronization.
When B2B SaaS vendors use these legacy platforms to build customer-facing integrations, they inherit hidden risks.
Legacy platforms pull data into centralized systems for processing. As IntelliStack points out, creating copies of sensitive data expands the risk surface and complicates compliance with strict regulations. If a vulnerability is discovered in the iPaaS provider's centralized database, your customer's ERP data is exposed.
Beyond security, there is a performance penalty. Caching architectures introduce latency because data must be written to disk, indexed, and retrieved. Cloud-native integrations built on stateless, pass-through architectures offer lower latency and error rates. A recent study on ERP integrations published on ResearchGate showed cloud-native solutions are 33% faster in latency and have a lower error rate compared to on-premise ESB/ETL queues.
When your enterprise customer announces they are migrating legacy on-premise ERPs to cloud APIs, a sync-and-cache architecture becomes a massive bottleneck. The synchronization jobs fail during cutover, state consistency breaks, and your engineering team spends weeks manually reconciling duplicate records.
How to Architect a Pass-Through ERP Integration
Building a pass-through ERP integration requires a fundamental shift in how you handle data mapping, authentication, and request execution. You cannot rely on hardcoded integration scripts that pull data into a local database.
Instead, you need a generic execution pipeline driven by declarative configuration. Here is how you architect it.
1. The Generic Execution Pipeline
To achieve true zero data retention, your middleware must execute API calls without relying on integration-specific code. Truto achieves this by handling 100+ third-party integrations without a single line of integration-specific code in its database or runtime logic.
Instead of writing custom Python or Node.js scripts for NetSuite and SAP, you define a Unified Model. This model maps your application's standard data structure (e.g., a generic Invoice object) to the provider's specific schema using a transformation language like JSONata.
When your application requests an invoice, the middleware:
- Receives the request.
- Looks up the declarative mapping configuration for the specific ERP.
- Translates the request into the upstream provider's exact format (e.g., constructing a NetSuite SuiteQL query) entirely in memory.
- Dispatches the HTTP request to the ERP.
- Receives the response.
- Transforms the response back into your Unified Model.
- Returns the response to your application and clears the memory buffer.
sequenceDiagram
participant App as Your B2B SaaS
participant Proxy as Stateless Proxy Layer
participant ERP as Upstream ERP (SAP/NetSuite)
App->>Proxy: GET /unified/invoices
Note over Proxy: Load JSONata Mapping<br>Transform to SuiteQL (In-Memory)
Proxy->>ERP: POST /query (SuiteQL)
ERP-->>Proxy: Raw ERP JSON Response
Note over Proxy: Transform to Unified Model<br>Discard Raw Payload
Proxy-->>App: Standardized Invoice JSONThis architecture is particularly vital for modern AI workflows. If you are building AI features, refer to our deep dive on zero data retention AI agent architecture to see how this pipeline secures LLM tool calling.
2. Handling Complex API Surfaces via Proxy
Enterprise ERPs rarely offer clean RESTful endpoints. NetSuite requires complex SuiteQL queries or SuiteScript deployments. SAP relies on heavy OData protocols. Some modern tools use GraphQL.
To maintain a stateless architecture, your integration layer must act as a Proxy API. For example, Truto's Proxy API allows developers to expose complex GraphQL-backed integrations as RESTful CRUD resources on the fly. It uses placeholder-driven request building to construct dynamic queries based on the incoming request context, execute them against the upstream provider, and extract the relevant nodes from the response - all without caching the underlying data.
If you need to map custom fields, you do it declaratively. You can learn exactly how to create ERP-specific mapping guides to handle these edge cases without writing stateful code.
3. Data Pass-Through Techniques That Actually Hold
Pass-through is more than "do not call db.insert()." The runtime has to be deliberately built so that payloads cannot leak into a persistence layer, even accidentally. A few techniques that make this real when you build ERP integrations without storing customer data:
Request-scoped memory only. Payload buffers live inside the request handler's local scope. No global caches, no module-level variables, no long-lived worker state that outlives the response. When the handler returns, the payload is unreachable and eligible for garbage collection on the next cycle.
Streaming instead of buffering. For large ERP responses like multi-page SuiteQL results or bulk OData exports, stream bytes from the upstream provider directly to the caller using HTTP chunked transfer encoding. The middleware acts as a byte pump, not a buffer. You never hold the full response in memory, let alone on disk.
Zero-copy transformations where feasible. JSONata mapping can operate on the parsed object graph without cloning. For very large payloads, use streaming JSON parsers (for example stream-json in Node.js) that emit events per record so the transformation applies to one record at a time rather than materializing the full array.
No side-channel writes. Disable APM auto-instrumentation that captures HTTP bodies. Disable framework-level request logging that dumps req.body. Configure your HTTP client to not retain response bodies in retry buffers longer than the request handler lifetime. Every one of those defaults is a silent path to disk.
Deterministic idempotency instead of a dedup table. Derive idempotency keys from a hash of the request payload and pass them to the upstream ERP when the provider supports it. The ERP handles deduplication. The middleware records nothing.
Pass-Through Architecture Diagrams
The gap between a sync-and-cache platform and a pass-through platform is easier to see than to describe. Here is what each flow actually looks like when an enterprise buyer's InfoSec team asks where their data lives.
Traditional Sync-and-Cache
flowchart LR
App[Your SaaS App]
MW[Integration Middleware]
Cache[("Staging DB<br>payloads at rest")]
Queue[("Message Queue<br>payloads at rest")]
ERP[Upstream ERP]
App --> MW
MW --> Cache
Cache --> Queue
Queue --> ERP
ERP --> Queue
Queue --> Cache
Cache --> AppEvery arrow crossing into Cache or Queue represents a copy of customer financial data written to disk. Each copy is in scope for SOC 2, GDPR, and whatever DPA the buyer negotiates. Each copy needs its own encryption keys, access reviews, and retention policy.
Pass-Through, Zero Data Retention
flowchart LR
App[Your SaaS App]
Proxy["Stateless Proxy<br>in-memory transform"]
Auth[("Token Store<br>OAuth only")]
ERP[Upstream ERP]
App --> Proxy
Proxy -.reads token.-> Auth
Proxy --> ERP
ERP --> Proxy
Proxy --> AppOnly OAuth tokens persist, and they never touch the data plane. Payload bodies exist for the duration of a single request handler and are garbage-collected the moment the response is returned to the caller.
Request Lifecycle in Detail
sequenceDiagram
participant App as Your SaaS
participant Proxy as Stateless Proxy
participant TokenStore as Token Store
participant ERP as Upstream ERP
App->>Proxy: GET /unified/invoices?cursor=abc
Proxy->>TokenStore: Fetch valid OAuth token
TokenStore-->>Proxy: Access token (in-memory)
Note over Proxy: Load JSONata mapping<br>Translate cursor to OData $skip<br>Build upstream request
Proxy->>ERP: HTTPS request with bearer token
ERP-->>Proxy: Raw ERP payload
Note over Proxy: Transform to unified schema<br>Normalize rate limit headers<br>Extract next cursor
Proxy-->>App: Unified invoice JSON + cursor
Note over Proxy: Discard payload buffers<br>No writes to diskNotice what the diagram does not contain: no staging table, no worker pool holding half-processed records, no message queue durably storing the payload. The token store is the only stateful component, and it holds credentials, not customer data.
Hands-On Implementation: Building the Stateless Proxy
Diagrams show the shape. Code shows whether the shape holds under real traffic. The examples below use Node.js and TypeScript, but the pattern is language-agnostic: receive request, transform in memory, forward, transform response, return, discard. Every function fits inside a single request handler so garbage collection reclaims payload memory as soon as the response is written.
The Core Proxy Handler
Every unified endpoint routes through the same generic handler. It loads a declarative mapping from configuration, applies it to the incoming request, forwards the transformed request to the ERP, and maps the response back. Notice what is missing: any database write for the request or response body.
import jsonata from 'jsonata';
async function proxyUnifiedRequest(req: Request): Promise<Response> {
const { integration, resource, method } = parseRoute(req.url);
const accountId = req.headers.get('x-account-id');
// Load declarative mapping - config only, no customer payloads
const mapping = await loadMapping(integration, resource, method);
// Fetch a fresh OAuth token from the isolated token store.
// The platform schedules a refresh ahead of expiry, so this is always warm.
const token = await tokenStore.getValidAccessToken(accountId);
// Parse the incoming request in memory
const inputBody = req.method === 'GET' ? null : await req.json();
const inputQuery = Object.fromEntries(new URL(req.url).searchParams);
// Transform via JSONata expressions loaded from mapping config
const queryExpr = jsonata(mapping.query_mapping);
const bodyExpr = mapping.request_body_mapping
? jsonata(mapping.request_body_mapping)
: null;
const upstreamQuery = await queryExpr.evaluate({ query: inputQuery });
const upstreamBody = bodyExpr
? await bodyExpr.evaluate({ body: inputBody, query: inputQuery })
: null;
// Build the upstream URL and dispatch
const upstreamUrl = renderTemplate(mapping.url_template, {
resource,
query: upstreamQuery,
});
const upstreamResponse = await fetch(upstreamUrl, {
method: mapping.http_method,
headers: {
Authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: upstreamBody ? JSON.stringify(upstreamBody) : undefined,
});
// Pass 429s through with normalized rate limit headers
if (upstreamResponse.status === 429) {
return new Response(null, {
status: 429,
headers: normalizeRateLimitHeaders(upstreamResponse.headers, integration),
});
}
const rawResponse = await upstreamResponse.json();
// Transform to unified schema
const responseExpr = jsonata(mapping.response_mapping);
const unified = await responseExpr.evaluate({ response: rawResponse });
// rawResponse and unified go out of scope after this return.
// Garbage collection reclaims them; nothing is written to disk.
return new Response(JSON.stringify({ result: unified }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}The mapping itself is not code. It is a JSON or YAML document stored alongside your integration definitions. For a NetSuite invoice list, it might look like this:
# netsuite-invoices.list.yaml
url_template: "https://{{context.account_id}}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql"
http_method: POST
query_mapping: |
{
"limit": query.limit ? $number(query.limit) : 100,
"offset": query.cursor ? $number(query.cursor) : 0
}
request_body_mapping: |
{
"q": "SELECT id, tranid, entity, total, trandate FROM transaction WHERE type = 'CustInvc' ORDER BY trandate DESC"
}
response_mapping: |
response.items.{
"id": $string(id),
"number": tranid,
"customer_id": $string(entity),
"total": $number(total),
"issued_at": trandate
}Swap to SAP OData or Dynamics 365 and only the mapping file changes. The proxy handler stays untouched.
Streaming Large Payloads
Some ERP responses are too large to buffer safely. A SuiteQL query returning fifty thousand journal entries, or an OData bulk export of a fiscal year of transactions, will exhaust request handler memory if you call .json() on the response. Stream instead.
async function streamUpstreamResponse(req: Request): Promise<Response> {
const { integration, resource } = parseRoute(req.url);
const accountId = req.headers.get('x-account-id');
const token = await tokenStore.getValidAccessToken(accountId);
const mapping = await loadMapping(integration, resource, 'list');
const upstreamUrl = renderTemplate(mapping.url_template, { resource });
const upstreamResponse = await fetch(upstreamUrl, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
// Pipe the upstream body directly to the caller.
// Nothing is buffered on the proxy side.
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
headers: {
'content-type':
upstreamResponse.headers.get('content-type') || 'application/json',
'transfer-encoding': 'chunked',
},
});
}If you need to transform each record on the way through, use a streaming JSON parser and a TransformStream so the mapping applies record by record:
import StreamJson from 'stream-json';
import StreamArray from 'stream-json/streamers/StreamArray';
import jsonata from 'jsonata';
function buildTransformStream(mappingExpr: string): TransformStream {
const expr = jsonata(mappingExpr);
const parser = StreamJson.parser();
const streamer = StreamArray.streamArray();
parser.pipe(streamer);
return new TransformStream({
async transform(chunk, controller) {
parser.write(chunk);
let item;
while ((item = streamer.read()) !== null) {
const mapped = await expr.evaluate({ response: item.value });
controller.enqueue(JSON.stringify(mapped) + '\n');
}
},
});
}The caller receives newline-delimited JSON. The proxy never holds more than one record in memory at a time.
Ephemeral Webhook Handling
Inbound webhooks are where most integrations quietly turn stateful. A common pattern is "receive webhook, drop it on a persistent queue, ack." That queue is a compliance liability. Do this instead:
async function handleInboundWebhook(req: Request): Promise<Response> {
const rawBody = await req.text();
const signature = req.headers.get('x-erp-signature');
// Verify the signature in memory before parsing anything else
if (!verifyHmacSignature(rawBody, signature, getWebhookSecret(req))) {
return new Response('unauthorized', { status: 401 });
}
const payload = JSON.parse(rawBody);
const eventMapping = await loadWebhookMapping(req);
const expr = jsonata(eventMapping);
const unifiedEvent = await expr.evaluate(payload);
// Look up the customer endpoint and forward immediately
const subscription = await webhookSubscriptions.lookup(
unifiedEvent.account_id
);
if (!subscription) {
// No subscriber - drop the event, no persistence
return new Response(null, { status: 204 });
}
const delivery = await fetch(subscription.url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-truto-signature': signOutbound(unifiedEvent, subscription.secret),
},
body: JSON.stringify(unifiedEvent),
});
// If delivery failed, return non-2xx so the ERP retries.
// We do not queue the event ourselves.
return new Response(null, { status: delivery.ok ? 200 : 503 });
}The upstream ERP already implements retry logic. Leaning on it eliminates the durable queue entirely. When the customer endpoint recovers, the next webhook fires and the loop continues.
Payload-Free Structured Logging
Even if your handler is stateless, a careless logger.info(req.body) will write the entire payload to your log aggregator's disk. Enforce metadata-only logging at the boundary:
function logUpstreamCall(ctx: {
integration: string;
resource: string;
method: string;
status: number;
durationMs: number;
correlationId: string;
}) {
// Only metadata. No bodies, no query strings, no headers.
logger.info({
event: 'upstream_call',
integration: ctx.integration,
resource: ctx.resource,
method: ctx.method,
status: ctx.status,
duration_ms: ctx.durationMs,
correlation_id: ctx.correlationId,
});
}Every log record maps to an audit-friendly event. Nothing in the log line will ever appear in a data breach report.
Handling Rate Limits and Pagination Statelessly
The most common objection to pass-through architecture is handling API limits. Engineers often assume they need a database to queue requests, track rate limits, and manage pagination state. This is false. You can handle all of this statelessly.
Normalizing Rate Limits
When an upstream API like NetSuite or SAP returns an HTTP 429 Too Many Requests error, traditional middleware absorbs the error, places the request in a retry queue, and waits. This requires persistent state.
A zero data retention architecture takes a different approach: it normalizes the rate limit information and passes the responsibility to the caller.
Truto does not retry, throttle, or apply backoff on rate limit errors. When an upstream API returns an HTTP 429, Truto passes that error directly to the caller. However, because every ERP returns rate limit data differently (some in headers, some in the body, some not at all), Truto normalizes upstream rate limit info into standardized headers per the IETF specification:
ratelimit-limit: The total request quota.ratelimit-remaining: The remaining quota.ratelimit-reset: The time window reset point.
Your application receives the 429 and the standardized headers. Your application - which already maintains state for its own background jobs - applies exponential backoff and retries the request. The middleware remains perfectly stateless.
// Example: Client-side handling of normalized 429 errors
async function fetchInvoicesWithRetry(url, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url);
if (response.status === 429) {
const resetTime = response.headers.get('ratelimit-reset');
const waitMs = (resetTime * 1000) - Date.now();
console.warn(`Rate limited. Waiting ${waitMs}ms before retry.`);
await new Promise(resolve => setTimeout(resolve, waitMs));
continue;
}
if (!response.ok) throw new Error('API Request Failed');
return await response.json();
}
throw new Error('Max retries exceeded');
}Stateless Pagination
Pagination follows the same principle. Instead of the middleware downloading all 10,000 records, caching them, and serving them to you in chunks, the middleware normalizes the pagination cursor.
When you request page one, the middleware translates your generic cursor parameter into the ERP's specific pagination format (e.g., an OData $skip token). The ERP returns the data and a next-page token. The middleware translates that token back into a generic cursor and passes it to your application. Your application stores the cursor and passes it back on the next request. The state lives with the client, not the middleware.
Securing the Connection: OAuth, Webhooks, and Ephemeral Processing
Maintaining a zero data retention posture requires strict control over how authentication tokens and asynchronous webhooks are handled.
Ephemeral Webhook Processing
ERPs frequently send webhooks when a purchase order is approved or a journal entry is posted. Traditional platforms ingest these webhooks into a Kafka or RabbitMQ queue, writing the payload to disk before processing it. If that queue is breached, the financial data is compromised.
In a ZDR architecture, webhooks are processed ephemerally. The middleware receives the HTTP ingestion, verifies the cryptographic signature in memory, maps the vendor-specific event (e.g., netsuite.transaction.created) to a unified event format, and immediately attempts outbound delivery to your application.
If the delivery fails, the middleware does not store the payload indefinitely. It relies on the upstream provider's retry mechanics or drops the event after a short, memory-bound retry window. The payload is never written to a database schema.
Secure OAuth Token Management
Authentication state is the only data the integration layer should persist. You must store OAuth access tokens and refresh tokens to maintain the connection. However, these tokens must be strictly isolated from the data plane.
The platform schedules work ahead of token expiry. Truto refreshes OAuth tokens shortly before they expire, ensuring that when an API call is made, a valid token is already available in memory. This prevents the middleware from having to pause a request, execute a database write to update a token, and then resume the request. The token lifecycle operates completely independent of the data payload lifecycle.
Ensuring Zero Data Persistence
Claiming "pass-through" in a security questionnaire is easy. Proving it under an audit is not. If you want to build ERP integrations without storing customer data, every layer of the middleware needs explicit controls that make persistence impossible, not merely discouraged. Here are the controls that hold up under enterprise scrutiny.
Payload-Free Logging
Application logs are the most common leak. A default console.log(request) or an APM tracer capturing HTTP bodies will silently write NetSuite journal entries to your log aggregator's disk. Enforce log scrubbing at the middleware boundary:
// Structured logging that records metadata only
logger.info({
event: 'upstream_request',
integration: 'netsuite',
resource: 'invoices',
method: 'GET',
status: response.status,
duration_ms: elapsed,
correlation_id: requestId,
// No request.body, no response.body, no query values
});Every log line, trace span, and error report should carry transaction metadata (status, latency, integration name, correlation ID) but strip payload bodies and query parameters that may contain PII or financial figures.
In-Memory Only Transformations
Declarative mapping languages like JSONata are pure functions. They accept an input JSON blob, produce an output JSON blob, and never touch the file system. Keep the transformation step inside a single request handler so that garbage collection reclaims the payload as soon as the response returns. Avoid patterns that hand payloads off to a background worker, because "background" almost always means "persistent queue."
If a specific ERP call is genuinely slow (a NetSuite SuiteQL join across three million rows, for example), stream the response back to the caller rather than buffering it. Node.js streams, HTTP chunked transfer encoding, and server-sent events all let you flush data to the client without ever materializing the full payload in memory or on disk.
Ephemeral Webhook Handling with Hard TTLs
Inbound webhooks are the trickiest path. If you have to buffer them, buffer them in memory-bound structures with a hard TTL, not in a durable log. Sign the outbound delivery in the same handler that receives the inbound event, so the payload never leaves the process boundary. If outbound delivery fails, rely on the upstream provider's retry contract instead of persisting the event yourself. Providers like NetSuite and SAP will re-emit events on delivery failure; that is a stronger guarantee than a self-hosted retry queue anyway.
Auth State Isolated from the Data Plane
The one thing you must persist is OAuth credentials. Treat the token store as a separate security domain: different database, different encryption keys, different access policies. A breach of the data plane must not expose refresh tokens, and a breach of the token store must not expose customer ERP records (because there are none to expose).
Idempotency Without Storage
Engineers often reach for a database to implement idempotency keys. You do not need one. Pass idempotency keys through to the upstream ERP when the provider supports them, or generate deterministic keys from the request payload hash and let the ERP deduplicate. The middleware records nothing.
Avoiding PII Storage End-to-End
ERP payloads are dense with PII and sensitive financial data: employee SSNs on payroll runs, vendor bank account numbers, customer credit card fragments, names and addresses on invoices, tax IDs on 1099 records. Zero data retention only works if PII cannot land anywhere at rest, including places you might not think of.
Scrub before the network boundary, not after. Redaction filters that run inside your logger or APM agent are a last line of defense, not a strategy. If a code path can construct a log entry containing PII, an unfamiliar deploy or a misconfigured sampler might disable the filter and leak the data. Instead, never let PII enter a log-shaped object in the first place. Build request context objects that carry only IDs, integration names, and status codes.
Redact error stack traces. Uncaught exceptions frequently include payload fragments in variable dumps, HTTP client error objects, or ORM query strings. Wrap upstream calls in error handlers that construct a sanitized error carrying only the status code and correlation ID, then throw that instead of the raw error. Send the sanitized error to your error tracker; keep the raw error out of it.
Keep PII out of URLs. Query parameters end up in access logs, load balancer logs, and CDN logs that you may not control. Send filters, emails, tax IDs, and cursors in the request body or as opaque tokens, not as ?email=jane@acme.com query strings.
Metrics carry counts, not values. A Prometheus metric labeled with integration=netsuite is fine. A metric labeled with customer_email is a slow-motion PII breach that shows up in every dashboard export. Treat cardinality controls on metric labels as PII controls, not just cost controls.
Test with synthetic PII markers. Add automated tests that inject distinctive synthetic PII markers (a fake SSN like 999-00-1234, a fake email at a controlled domain) into upstream fixtures, then grep your log aggregator, trace backend, and error tracker for those markers after the test runs. If any marker appears anywhere except the token store, the test fails the build.
Do not encrypt PII you should not have. Encryption at rest is not a substitute for non-retention. If InfoSec asks "do you store customer data?", the honest answer to "yes but encrypted" is still "yes," and it still triggers the compliance cascade. The defensible posture is "no."
Verifiable Retention Policy
Enterprise InfoSec teams do not accept "trust us." Give them artifacts that map directly to your architecture:
- A written retention policy stating that request and response bodies are never persisted.
- A network diagram showing no arrow between the request handler and any database except the token store.
- SOC 2 controls that test log scrubbing and enforce database schema restrictions.
- Sample audit logs demonstrating that stored records contain only metadata, never customer payloads.
- A DPA that lists the token store as the only data processing location, with a clear statement that no customer transactional data is retained.
When these artifacts line up with the architecture, the InfoSec review stops being a fight over cached data and becomes a routine check.
Strategic Next Steps
Building ERP integrations does not have to mean taking on massive compliance liabilities. By adopting a pass-through, zero data retention architecture, you can give your product read and write access to NetSuite, SAP, and Dynamics 365 without ever caching a single financial record.
Stop letting procurement teams kill your enterprise deals because of your integration middleware. Architect for statelessness. Process data in memory. Pass rate limits to the caller. Use declarative mapping instead of hardcoded integration scripts.
When you eliminate the cache, you eliminate the risk. Your InfoSec review will pass, your engineering team will spend less time managing stateful queues, and your sales team will close the enterprise contracts they fought for.
FAQ
- What is zero data retention in ERP integrations?
- Zero data retention is an architectural pattern where an integration layer processes third-party API payloads entirely in-memory and discards them immediately, never writing customer data to a database or persistent queue.
- Why do caching integration architectures fail InfoSec reviews?
- Caching architectures create a duplicate copy of sensitive customer financial data on third-party infrastructure. This expands the attack surface and creates severe compliance liabilities under GDPR, HIPAA, and SOC 2.
- How do you handle rate limits without a database queue?
- A stateless integration layer passes HTTP 429 errors directly to the caller, along with normalized rate limit headers (like ratelimit-remaining). The calling application is then responsible for applying exponential backoff and retrying the request.
- Can you handle webhooks statelessly?
- Yes. Webhooks can be ingested, cryptographically verified, mapped to a unified event format, and delivered to the destination application entirely in-memory without writing the payload to persistent storage.