How to Read Orders, Payments, and Shipments from a Customer's ERP with One Connection
August 13, 2026
Your product can read a customer's orders, reconcile their payments, and track their fulfillment from one authorized ERP connection. On NetSuite, a single connection serves the Accounting, Payments, Shipping, and HR & Directory APIs: sdk.accounting.listAccountingSalesorders returns the orders, each payment's allocations array shows what it settled, and shipments arrive as NetSuite item fulfillments with tracking attached. Every request is executed directly against the customer's NetSuite, so what you read is what their ERP says right now.
Why ERP reads are cross-category work
An ERP holds several kinds of data in one product: the ledger, the orders, the payments, the fulfillment records, the employees. At Unified, each kind is served through the category API built for it, on one connection: the composition explained in What is ERP integration?. This tutorial is that composition as working code: four category APIs, one connection ID, one customer authorization.
NetSuite is the worked example because it spans the most categories of any ERP integration. Coverage differs by platform (Sage Intacct serves accounting and HR, not shipping), so the same code runs against whichever categories a customer's ERP supports, and the capability page per integration is the truth about which.
What you are building
A per-subsidiary view of a customer's order-to-cash and fulfillment state, inside your product:
- Authorize the customer's ERP and store one connection ID
- List their subsidiaries and scope every read to one
- List sales orders and invoices for that subsidiary
- Reconcile payments against them through allocations
- Follow fulfillment from shipment to tracking events
- Subscribe to changes so the view updates itself
Connect once and scope to a subsidiary
Initialize the SDK the same way as any category:
import { UnifiedTo } from '@unified-api/typescript-sdk';
import * as errors from '@unified-api/typescript-sdk/sdk/models/errors';
const sdk = new UnifiedTo({
security: {
jwt: process.env.UNIFIED_API_KEY,
},
});
If your customers require their data to stay in a region, pass serverIdx at initialization: 0 for North America, 1 for Europe, 2 for Australia.
Multi-entity structure is much of what makes a platform an ERP, and it's first-class in the data model. The organization object maps to NetSuite's subsidiary, with a parent_id for the entity hierarchy and an is_elimination flag for elimination entities:
const subsidiaries = await sdk.accounting.listAccountingOrganizations({ connectionId });
Each organization carries its type in the consolidation hierarchy (company, subsidiary, division, or location), its own currency and fiscal year end, a tax_number, and an organization_code the customer defines for their own reporting.
Subsidiaries change rarely and, on NetSuite, don't support incremental reads, so read them on demand rather than subscribing. Everything after this scopes to one subsidiary through the orgId list parameter.
List the subsidiary's orders
const orders = await sdk.accounting.listAccountingSalesorders({
connectionId,
orgId: subsidiaryId,
sort: 'updated_at',
order: 'desc',
limit: 50,
});
Each sales order carries the customer reference (contact_id), status, currency, totals, and its line items. Line items are a slow field on NetSuite, meaning they cost an additional fetch from the source. Request them through the fields parameter when a view needs them, and leave them out of list renders that don't.
One structural fact to know before you scope: an older generic Order object also exists in the accounting category, and as of August 2026 it is deprecated in favour of the dedicated Salesorder and Purchaseorder objects. Build against the dedicated objects; the generic one appears below only because shipment records still reference it.
Reconcile payments through allocations
The payment object's allocations array shows what each payment was applied to. In the docs' own words, it "replaces separate invoice/bill payment endpoints." One array covers six document types: invoices, bills, credit memos, vendor credits, sales orders, and purchase orders.
const payments = await sdk.payment.listPaymentPayments({
connectionId,
updatedGte: lastSyncedAt,
sort: 'updated_at',
order: 'asc',
limit: 100,
});
const detailed = await sdk.payment.getPaymentPayment({
connectionId,
id: paymentId,
fields: ['id', 'total_amount', 'currency', 'contact_id', 'allocations'],
});
for (const allocation of detailed.allocations ?? []) {
// allocation.object_type: INVOICE | BILL | CREDITMEMO | VENDORCREDIT | SALESORDER | PURCHASEORDER
// allocation.object_id, allocation.amount, allocation.currency
// allocation.exchange_rate: the rate at the time the payment was applied
applyToDocument(allocation);
}
Each allocation carries the amount applied, its currency, and the exchange rate at the moment of application, so multi-currency reconciliation doesn't depend on today's rate to explain last month's payment. allocations is a slow field on NetSuite; request it by name, as above, only when reconciling. The payment's type tells you the direction: INVOICE for customer payments, BILL for vendor payments, matching NetSuite's own customer-payment and vendor-payment records.
Follow fulfillment from shipment to tracking
Shipments on NetSuite are item fulfillments, and they list by the order they fulfill:
const shipments = await sdk.shipping.listShippingShipments({
connectionId,
orderId: orderId,
});
Each shipment carries everything its panel needs to render on its own: carrier, service code, packages, origin and destination addresses, ship date, and status. Its order_id references the order it fulfills; as of August 2026 that reference points at the deprecated generic Order object rather than Salesorder, so treat it as a reference key for display and matching rather than an object to build against.
When a shipment carries a tracking_id, the tracking object has the delivery story:
const tracking = await sdk.shipping.getShippingTracking({ connectionId, id: shipment.tracking_id });
Tracking returns the tracking number, an events array, estimated versus actual delivery, and the status twice: normalized in status, and the carrier's own code and description alongside it. That pairing, the normalized value with the source's original preserved next to it, is the same pattern payments use for tender, and it means normalization never costs you the native detail.
Join spend to the people who spent it
The HR & Directory category is on the same connection, and on NetSuite the join between money and people runs through expenses: each expense carries the employee who filed it and the employee who approved it.
const expenses = await sdk.accounting.listAccountingExpenses({ connectionId, orgId: subsidiaryId });
const employee = await sdk.hris.getHrisEmployee({
connectionId,
id: expense.user_id,
fields: ['id', 'name', 'title'],
});
The fields projection keeps the per-record lookup cheap: three fields, not the full employee. This is the same one-connection point as everything above: employee reads don't need a second authorization, because the HR & Directory category rides the ERP connection the customer already granted.
Handle coverage like a first-class input
Three habits, and the code survives every platform:
The capability page outranks the global docs. Per-integration list options diverge from the endpoint reference. NetSuite's payment list, for example, doesn't carry the invoice_id filter the global page documents. Scope your code to what the integration's capability page lists, because that page is per-platform truth.
Request slow fields deliberately. Fields marked slow on the capability page (line items, payments, allocations) cost additional source fetches. Name them in fields when a view needs them; omit them from list calls that don't.
Treat 501 as information. Where a platform doesn't support an object, the request returns 501 Not Implemented rather than an empty list:
try {
return await sdk.shipping.listShippingShipments({ connectionId });
} catch (error) {
if (error instanceof errors.UnifiedToError && error.statusCode === 501) {
return renderUnsupported(); // this ERP has no shipping surface, not "no shipments"
}
throw error;
}
Stay current as the ERP changes
Subscribe to changes on the objects your view renders, and Unified detects them and delivers events. The accounting events for this build, all supported on NetSuite as of August 2026: accounting_salesorder_created, accounting_salesorder_updated, accounting_invoice_created, accounting_invoice_updated, accounting_expense_created, and accounting_expense_updated.
{
"connection_id": "5de520f96e439b002043d8dc",
"hook_url": "https://yourapp.com/webhooks/erp",
"object_type": "accounting_salesorder",
"event": "updated",
"interval": 5,
"webhook_type": "virtual"
}
Deleted-event types exist in the accounting category but are not served on NetSuite, where webhooks are virtual: virtual detection covers created and updated, and delete propagation is native-only. Plan deletes accordingly rather than assuming the event will arrive.
The shipping events complete the picture: shipping_shipment_created and shipping_shipment_updated are supported on NetSuite, so the fulfillment panel subscribes the same way. Tracking events (shipping_tracking_created, shipping_tracking_updated) exist in the category but are not served on NetSuite as of August 2026; on NetSuite, retrieve the tracking when its shipment's updated event arrives, and check the capability matrix for the platforms that serve tracking events directly.
For anything without events, updatedGte with ascending updated_at reads only what changed, and the last record's timestamp is your next call's cursor.
→ Start your 30-day free trial → Book a demo
Frequently asked questions
Why do both an Order object and a Salesorder object exist in the accounting category?
The generic Order object is the older model, carrying sales and purchase orders distinguished by type, and as of August 2026 it is deprecated in favour of the dedicated Salesorder and Purchaseorder objects. Build new work against the dedicated objects. The generic object still appears in one place: shipment records reference orders through it, so its ids remain useful as reference keys while that migration completes.
How do I handle a customer whose ERP runs multiple currencies?
Payment allocations carry the exchange rate at the time each payment was applied, alongside the applied amount and its currency. Reconciliation reads the historical rate off the allocation instead of converting at today's rate, so a payment applied in March still explains itself in August.
Do I need to activate all four categories for this to work?
You call only the category APIs your build uses; the connection carries whichever categories the ERP integration supports. A finance-only version of this build uses Accounting and Payments and ignores the rest, on the same connection.
What happens on an ERP that doesn't support one of these categories?
The request returns 501 Not Implemented for objects the platform's API can't serve, which is distinguishable from an empty result. Build the view so each panel degrades independently: an ERP without a shipping surface still renders orders and payments.
Author
Written by Mallory Greene
Mallory Greene is Head of Marketing at Unified.to. She writes about integration infrastructure, unified APIs, and MCP for technical teams. Based in Toronto.