An Adobe App Builder extension that syncs customers and orders between Adobe Commerce and NetSuite using Adobe I/O Runtime actions.
Built on the Adobe Commerce Integration Starter Kit and extends it with NetSuite-specific transformers, senders, and a scheduled delta sync for customers.
| Feature | Status | Description |
|---|---|---|
| Commerce → NetSuite customers | ✅ | Upserts Commerce customers to NetSuite via SuiteTalk REST |
| Commerce → NetSuite orders | ✅ | Upserts Commerce sales orders to NetSuite via SuiteTalk REST |
| NetSuite → Commerce customers | ✅ | Delta sync of NetSuite customers back to Commerce (cron-based) |
| OAuth 1.0a (NetSuite) | ✅ | Token-Based Authentication for all NetSuite API calls |
| OAuth1 / IMS (Commerce) | ✅ | PaaS (OAuth1) and SaaS (IMS) Commerce authentication |
| Telemetry | ✅ | OpenTelemetry instrumentation (opt-in via ENABLE_TELEMETRY) |
git clone <repository-url>
cd netsuite-connector
npm installaio login
aio console org select
aio console project select
aio console workspace select
aio app use # Choose 'm' (merge)cp env.dist .env
chmod +x scripts/fetch-secrets.sh
scripts/fetch-secrets.sh <workspace> # e.g. Stage
# Edit .env with your Commerce and NetSuite credentialsCreate a new attribute with Attribute Code = netsuite_internal_id in Adobe Commerce Admin → Stores > Attributes → Customer.
If your Commerce instance Adobe I/O Events for Adobe Commerce module version is 1.6.0 or greater and the onboarding script completed successfully, the following steps are not required. The onboarding script will configure the Adobe Commerce instance automatically. Follow the steps in the next section to validate that the configuration is correct or skip to the next section.
To configure the provider in Commerce, do the following:
- In the Adobe Commerce Admin, navigate to Stores > Settings > Configuration > Adobe Services > Adobe I/O Events > General configuration. The following screen displays.

- Select
OAuth (Recommended)from theAdobe I/O Authorization Typemenu. - Copy the contents of the
<workspace-name>.json(Workspace configuration JSON you downloaded in the previous stepCreate app builder project) into theAdobe I/O Workspace Configurationfield. - Copy the commerce provider instance ID you saved in the previous step
Execute the onboardinginto theAdobe Commerce Instance IDfield. - Copy the commerce provider ID you saved in the previous step
Execute the onboardinginto theAdobe I/O Event Provider IDfield. - Click
Save Config. - Enable Commerce Eventing by setting the
Enabledmenu to Yes. (Note: You must enable cron so that Commerce can send events to the endpoint.) - Enter the merchant's company name in the
Merchant IDfield. You must use alphanumeric and underscores only. - In the
Environment IDfield, enter a temporary name for your workspaces while in development mode. When you are ready for production, change this value to a permanent value, such asProduction. - (Optional) By default, if an error occurs when Adobe Commerce attempts to send an event to Adobe I/O, Commerce retries a maximum of seven times. To change this value, uncheck the Use system value checkbox and set a new value in the Maximum retries to send events field.
- (Optional) By default, Adobe Commerce runs a cron job (clean_event_data) every 24 hours that delete event data three days old. To change the number of days to retain event data, uncheck the Use system value checkbox and set a new value in the Event retention time (in days) field.
- Click
Save Config.
aio app deploynpm run onboard
npm run commerce-event-subscribeFor more detailed general information about the setup, see the README.md.
flowchart TB
subgraph Commerce["Adobe Commerce"]
CommerceAPI["Commerce REST API"]
CommerceEvents["Commerce Events"]
end
subgraph AppBuilder["Adobe App Builder"]
subgraph C2N_Customer["Commerce → NetSuite: Customer"]
CustConsumer["customer/commerce/consumer"]
CustSync["customer/commerce/sync\n(pre → transform → send → post)"]
end
subgraph C2N_Order["Commerce → NetSuite: Order"]
OrdConsumer["order/commerce/consumer"]
OrdSync["order/commerce/sync\n(validate → pre → transform → send → post)"]
end
subgraph N2C_Customer["NetSuite → Commerce: Customer (cron)"]
ExtSync["customer/external/sync\n(fetch → transform → send → state update)"]
end
end
subgraph NetSuite["NetSuite"]
SuiteTalk["SuiteTalk REST API\n(record/v1/customer, record/v1/salesOrder)"]
SuiteQL["SuiteQL API\n(customer delta query)"]
end
CommerceEvents --> CustConsumer --> CustSync
CommerceEvents --> OrdConsumer --> OrdSync
CustSync -->|"PUT eid:<id>"| SuiteTalk
OrdSync -->|"PUT eid:<increment_id>"| SuiteTalk
SuiteTalk -->|"netsuite_internal_id"| CommerceAPI
SuiteQL -->|"lastmodifieddate delta"| ExtSync
ExtSync -->|"upsert customers"| CommerceAPI
netsuite-connector/
├── app.config.yaml # App Builder configuration (actions, triggers, hooks)
├── env.dist # Environment variable template
├── package.json
│
├── actions/ # Runtime Actions
│ ├── customer/
│ │ ├── commerce/
│ │ │ ├── consumer/ # Routes Commerce customer events
│ │ │ └── sync/ # Commerce → NetSuite customer upsert
│ │ │ ├── index.js
│ │ │ ├── pre.js # Fetches full customer from Commerce
│ │ │ ├── transformer.js # Maps Commerce customer → NetSuite customer
│ │ │ ├── sender.js # PUT record/v1/customer/eid:<id>
│ │ │ └── post.js # Writes netsuite_internal_id back to Commerce
│ │ └── external/
│ │ └── sync/ # NetSuite → Commerce customer delta sync
│ │ ├── index.js
│ │ ├── pre.js # SuiteQL paginated fetch
│ │ ├── transformer.js # Maps NetSuite customer → Commerce customer
│ │ └── sender.js # Search / create / update in Commerce
│ ├── order/
│ │ └── commerce/
│ │ ├── consumer/ # Routes Commerce order events
│ │ └── sync/ # Commerce → NetSuite order upsert
│ │ ├── index.js
│ │ ├── validator.js # Validates order payload
│ │ ├── pre.js # Reads guest customer / subsidiary config
│ │ ├── transformer.js # Maps Commerce order → NetSuite salesOrder
│ │ ├── sender.js # PUT record/v1/salesOrder/eid:<increment_id>
│ │ └── post.js # Writes netsuite_internal_id back to Commerce
│ └── oauth1a.js # NetSuite OAuth 1.0a helper
│
└── scripts/
└── onboarding/ # Event provider & registration setup
Pipeline: preProcess → transformData → sendData → postProcess
- Trigger:
com.adobe.commerce.<EVENT_PREFIX>.observer.customer_save_commit_after(viaactions/customer/commerce/consumer). - Pre-process (
pre.js):- Fetches the full Commerce customer by
params.data.idusinggetCustomerAPI. - Skips fetch if
params.data.idequalsNETSUITE_GUEST_CUSTOMER.
- Fetches the full Commerce customer by
- Transform (
transformer.js):isPersonistruewhencompanyfield is empty or missing.- Maps
email,firstname,lastnamefrom Commerce customer. subsidiarydefaults to{ id: 1 }(can be overridden via options).phonefield: taken fromdata.phone, or from the first address with a non-emptytelephone; included only when non-empty.addressBookbuilt from Commerce addresses:- Each address includes
externalId,addressee,addr1,addr2,city,state(mapped fromregion_code),zip,country,addrPhone. defaultBillinganddefaultShippingflags are preserved.- Label assigned: "Default" (both flags), "Billing", "Shipping", or "Additional".
- Each address includes
- Country code mapping:
UK→GB. - Street lines are deduplicated and filtered.
- Send (
sender.js):- NetSuite SuiteTalk REST upsert via
PUT record/v1/customer/eid:<commerce_customer_id>. - Uses OAuth 1.0a Token-Based Authentication.
- Extracts NetSuite Internal ID from
locationresponse header.
- NetSuite SuiteTalk REST upsert via
- Post-process (
post.js):- Updates Commerce customer
custom_attributes.netsuite_internal_idwith NetSuite Internal ID. - Uses
updateCustomerAPI. - Validates the update by checking the returned attribute value.
- Updates Commerce customer
Pipeline: validateData → preProcess → transformData → sendData → postProcess
- Trigger:
com.adobe.commerce.<EVENT_PREFIX>.observer.sales_order_save_commit_after(viaactions/order/commerce/consumer). - Validation (
validator.js):- Requires
data.value.entity_idfor correlation. - Requires
data.value.increment_id. - Requires
data.value.items(non-empty array). - Each item must have
skuandqty_ordered. - Requires
data.value.addresses. - For guest orders: requires
data.value.customer_email. - For registered orders: requires
data.value.customer_id.
- Requires
- Pre-process (
pre.js):- Reads
NETSUITE_GUEST_CUSTOMERandNETSUITE_DEFAULT_SUBSIDIARY_ID(defaults to"1"). - Returns
{ guestCustomerInternalId, subsidiaryId }.
- Reads
- Transform (
transformer.js):entity.externalId:- Registered customers: uses
customer_id. - Guest orders: uses
NETSUITE_GUEST_CUSTOMERorguest_<email>.
- Registered customers: uses
otherrefnum=increment_id.billingAddressandshippingAddress:- Shipping falls back to billing if not present.
- Checks
data.addressesarray andextension_attributes.shipping_assignments. - Each address includes
addressee,addr1,addr2,city,state,zip,country.
item.items:- Excludes configurable parent items (
product_type !== "configurable"). - Only includes items with
qty_ordered > 0. - Maps
sku→item.externalId,qty_ordered→quantity,price→rate,name→description.
- Excludes configurable parent items (
- Country code mapping:
UK→GB. - Street lines are deduplicated and filtered.
- Send (
sender.js):- NetSuite SuiteTalk REST upsert via
PUT record/v1/salesOrder/eid:<increment_id>. - Uses OAuth 1.0a Token-Based Authentication.
- Extracts NetSuite Internal ID from
locationresponse header. - Parses error details from
o:errorDetailsif available.
- NetSuite SuiteTalk REST upsert via
- Post-process (
post.js):- Updates Commerce order
custom_attributes.netsuite_internal_idwith NetSuite Internal ID. - Uses
updateOrderAPI. - Logs success or failure; does not fail the action if update fails.
- Updates Commerce order
Pipeline: getNetSuiteCustomers → transformCustomers → sendData → state update
- Trigger: scheduled cron (not event-driven).
- Cron trigger configured in
app.config.yamlunderapplication.runtimeManifest.triggers.customer-sync-trigger. - The cron expression is provided by
NETSUITE_CUSTOMER_CRON_SYNC_EXPRESSION(see.env/env.dist).
- Cron trigger configured in
- Delta sync:
- State key
customer_sync_last_datestores last sync cursor (TTL 365 days = 31,536,000 seconds). - If missing, uses
NETSUITE_CUSTOMER_LAST_SYNC_DATEor performs full sync. - Current sync timestamp is captured before fetching data.
lastmodifieddatein NetSuite is a timestamp field; SuiteQL comparisons useTO_TIMESTAMPwithYYYY-MM-DD HH24:MI:SS.- The connector normalizes sync dates to
YYYY-MM-DD 00:00:00before querying.
- State key
- Fetch (
pre.js):- SuiteQL query:
SELECT * FROM customer WHERE externalid != '<NETSUITE_GUEST_CUSTOMER>'. - Delta sync adds:
AND lastmodifieddate > TO_TIMESTAMP('<lastSyncDate>', 'YYYY-MM-DD HH24:MI:SS'). - Ordered by
lastmodifieddate ASC, id ASC. - Paginated in pages of 1000 (
OFFSET ... ROWS FETCH NEXT 1000 ROWS ONLY). - Retries on HTTP 429 with linear backoff (max 3 retries, delay = 1000ms × attempt).
- Uses
Prefer: transientheader. - Fetches customer addresses via NetSuite Record API in 3 steps:
GET record/v1/customer/{id}/addressbook— retrieves the list of address entry IDs (vialinks[0].href).GET record/v1/customer/{id}/addressBook/{entryId}— retrieves each entry withdefaultBilling,defaultShipping, and a link toaddressBookAddress.GET record/v1/customer/{id}/addressBook/{entryId}/addressBookAddress— retrieves the full address fields (addr1,city,state,zip,country,addrPhone).
- Address fetch errors for individual entries are logged and skipped without failing the sync.
- SuiteQL query:
- Transform (
transformer.js):- Maps to Commerce customer payload:
email,firstname(defaults to "Unknown"),lastname(defaults to "Unknown").website_id,store_id,group_idfromCOMMERCE_DEFAULT_*or1.custom_attributes.netsuite_internal_id = <netsuite id>.
- Filters out customers without
email. - Transforms NetSuite address entries into Commerce
addresses:streetfromaddr1,city,postcodefromzip,country_idfromcountry.id.regionfromstate(full name),region_iddefaults to0.telephonefromaddrPhone, falls back to"00000000".firstname/lastnamefrom the parent Commerce customer.default_billinganddefault_shippingflags preserved.
- Maps to Commerce customer payload:
- Sync (
sender.js):- Searches Commerce customers by email using
customers/searchAPI. - Fetches NetSuite addresses via
getNetSuiteCustomerAddresses(see pre.js) and includes them in the payload. - Updates existing customers with
updateCustomerAPI. - Creates new customers with
createCustomerAPI. - Returns
{ success, created, updated, failed, errors }. - Marks overall sync as failed if all customers failed.
- Searches Commerce customers by email using
- State update (
index.js):- Updates
customer_sync_last_datewithcurrentSyncDateon success. - Also updates when there are no customers to sync (checkpoint).
- TTL: 365 days (31,536,000 seconds).
- Updates
Go to the Adobe Developer Console:
- Click
Create project from template→ selectApp Builder. - Choose a name and title; select or create a Stage workspace.
- Add the following API services (select default OAuth Server-to-Server):
- I/O Events
- I/O Management
- Adobe I/O Events for Adobe Commerce
- Adobe Commerce as a Cloud Service (SaaS only)
- Download the workspace configuration JSON and save it as
workspace.jsonin./scripts/onboarding/config/.
Required only for Adobe Commerce 2.4.4 or 2.4.5. Follow the installation documentation.
Upgrading to Adobe I/O Events for Adobe Commerce module version 1.6.0 or greater enables additional automated onboarding steps.
cp env.dist .env
# Fill in all required values (see Environment Variables below)
npm installaio login
aio console org select
aio console project select
aio console workspace select
aio app use # Choose 'm' (merge)aio app deployConfirm the deployment in the Adobe Developer Console under the Runtime section of your workspace.
Configure event providers and registrations:
npm run onboardThe script outputs provider IDs — save the commerce instance ID, provider ID, and backoffice provider ID for the next steps.
If your Adobe I/O Events for Adobe Commerce module is version 1.6.0 or greater:
npm run commerce-event-subscribeOtherwise, subscribe manually following the documentation.
Required event subscriptions:
| Entity | Event | Required fields |
|---|---|---|
| Customer | observer.customer_save_commit_after |
created_at, updated_at |
| Order | observer.sales_order_save_commit_after |
created_at, updated_at |
In Commerce Admin → Stores > Settings > Configuration > Adobe Services > Adobe I/O Events:
- Set
Adobe I/O Authorization TypetoOAuth (Recommended). - Paste the workspace configuration JSON into
Adobe I/O Workspace Configuration. - Enter the commerce provider instance ID and provider ID from the onboarding output.
- Enable Commerce Eventing and ensure cron is running.
Copy env.dist to .env and fill in the values:
# NetSuite credentials
NETSUITE_ACCOUNT_ID=
NETSUITE_CONSUMER_KEY=
NETSUITE_CONSUMER_SECRET=
NETSUITE_TOKEN_ID=
NETSUITE_TOKEN_SECRET=
# Commerce API (PaaS - OAuth1)
COMMERCE_BASE_URL=https://[environment].magentosite.cloud/rest/
COMMERCE_CONSUMER_KEY=
COMMERCE_CONSUMER_SECRET=
COMMERCE_ACCESS_TOKEN=
COMMERCE_ACCESS_TOKEN_SECRET=
# Commerce API (SaaS - IMS) — use instead of OAuth1 for SaaS
# COMMERCE_BASE_URL=https://na1-sandbox.api.commerce.adobe.com/[tenant-id]/
# OAUTH_CLIENT_ID=
# OAUTH_CLIENT_SECRET=
# OAUTH_SCOPES=AdobeID, openid, read_organizations, ...
# Sync settings
NETSUITE_GUEST_CUSTOMER=
NETSUITE_DEFAULT_SUBSIDIARY_ID=1
NETSUITE_CUSTOMER_LAST_SYNC_DATE= # Optional: YYYY-MM-DD
NETSUITE_CUSTOMER_CRON_SYNC_EXPRESSION= # e.g. 0 * * * *
# Commerce defaults for NetSuite → Commerce customer creation
COMMERCE_DEFAULT_WEBSITE_ID=1
COMMERCE_DEFAULT_STORE_ID=1
COMMERCE_DEFAULT_GROUP_ID=1
# Observability
LOG_LEVEL=info
ENABLE_TELEMETRY=falseThe connector supports both Commerce deployment types:
| Mode | Auth | When to use |
|---|---|---|
| PaaS | OAuth 1.0a | Traditional Adobe Commerce (on-premise / cloud) |
| SaaS | IMS OAuth Server-to-Server | Adobe Commerce as a Cloud Service |
OAuth1 is tried first. To use SaaS/IMS, leave COMMERCE_CONSUMER_KEY and related variables blank or unset.
In Commerce Admin → System > Extensions > Integrations:
- Click
Add New Integration, give it a name. - Under API, grant access to all resources.
- Activate the integration and copy the consumer key, consumer secret, access token, and access token secret to
.env.
Follow the OAuth Server-to-Server guide and add credentials to .env:
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
OAUTH_SCOPES=AdobeID, openid, read_organizations, ...All NetSuite requests use the npm package netsuite-api-client. Use this client for any new NetSuite integrations rather than issuing raw HTTP requests.
| Variable | Location |
|---|---|
NETSUITE_ACCOUNT_ID |
NetSuite Admin > Setup > Company > Company Information |
NETSUITE_CONSUMER_KEY/SECRET |
NetSuite Admin > Setup > Integration > Manage Integrations |
NETSUITE_TOKEN_ID/SECRET |
NetSuite Admin > Setup > Users/Roles > Access Tokens |
COMMERCE_BASE_URL |
Commerce Admin URL + /rest/ (PaaS) or tenant API endpoint (SaaS) |
OAUTH_CLIENT_ID/SECRET |
Adobe Developer Console > Project > OAuth Server-to-Server |
The connector uses Custom Attributes to store NetSuite Internal IDs in Adobe Commerce entities.
Ensure a custom attribute named netsuite_internal_id exists for:
- Customer entity (required): Used by customer sync in both directions.
- Order entity (optional): Used by order post-process. If the attribute doesn't exist or update fails, order sync still succeeds.
These fields are written after a successful NetSuite upsert to support correlation and idempotency.
params.data.id: Commerce customer ID (required).email: Customer email (required).firstname,lastname: Customer name (required).addresses: Array of customer addresses (optional, used foraddressBook).- Each address:
id,street,city,region,region_code,postcode,country_id,default_billing,default_shipping.
- Each address:
data.value.entity_id: Commerce order ID (required).data.value.increment_id: Commerce order number (required).data.value.items: Array of order items (required, non-empty).- Each item:
sku,qty_ordered,price,name,product_type.
- Each item:
data.value.addresses: Array of order addresses (required).- Each address:
address_type,street,city,region,region_code,postcode,country_id,firstname,lastname.
- Each address:
data.value.customer_is_guest: Boolean flag for guest orders.data.value.customer_email: Customer email (required for guest orders).data.value.customer_id: Customer ID (required for registered orders).data.value.billing_address: Billing address (optional, can be inaddressesarray).data.value.extension_attributes.shipping_assignments: Shipping assignments (optional, fallback for shipping address).
- NetSuite customer record:
id,email,firstname,lastname,externalid,lastmodifieddate. - NetSuite address entry (per
addressBookitem):defaultBilling,defaultShipping. - NetSuite address sub-resource (
addressBookAddress):addr1,city,state,zip,country.id,addrPhone.
npm run app-dev# Run all tests
npm test
# Run tests with coverage
npm test -- --coverage
# Run a specific test file
npm test -- test/actions/customer/commerce/sync/transformer.test.js# View runtime logs
aio app logs
# View specific action logs
aio runtime activation logs <activation-id>
# List recent activations
aio runtime activation listThe connector uses @adobe/aio-lib-telemetry for OpenTelemetry instrumentation (traces, metrics, logs).
To enable telemetry:
- Set
ENABLE_TELEMETRY=truein the action'sinputsinapp.config.yaml. - Configure exporters in
actions/telemetry.js.
For local development, spin up the included telemetry stack:
docker compose up- Add the event to
./scripts/onboarding/config/events.jsonunder the relevant entity and flow. - Run
npm run onboard. - Create the action handler in
actions/{entity}/{flow}/{operation}/index.js. - Register it in
actions/{entity}/{flow}/actions.config.yaml. - Add a
caseto the consumer'sswitchstatement inactions/{entity}/{flow}/consumer/index.js. - Deploy:
aio app deploy.
| Issue | Solution |
|---|---|
| NetSuite 401 Unauthorized | Verify OAuth 1.0a credentials (NETSUITE_* vars) and token permissions |
| NetSuite 429 Too Many Requests | Built-in retry with linear backoff (max 3 retries); reduce sync frequency if persistent |
| Customer not synced to NetSuite | Check that netsuite_internal_id custom attribute exists on Customer entity |
| Order sync succeeds but no internal ID | netsuite_internal_id attribute on Order entity is optional; check Commerce logs |
| Delta sync not running | Verify NETSUITE_CUSTOMER_CRON_SYNC_EXPRESSION and cron trigger in app.config.yaml |
| Guest customer not excluded | Ensure NETSUITE_GUEST_CUSTOMER matches the NetSuite external ID used for guests |
| Commerce auth errors | For PaaS: check COMMERCE_* OAuth1 vars; for SaaS: check OAUTH_* IMS vars |
- NetSuite customer address telephone fallback: If
addrPhoneis absent in NetSuite,telephonein Commerce is set to"00000000"(Commerce requires a non-empty value). - Customer and order upserts rely on NetSuite
externalId: Ensure NetSuite allowsexternalIdvalues forcustomerandsalesOrderrecords. - Order line items exclude configurable parents: Only simple/virtual items with
qty_ordered > 0are synced. - Shipping address fallback: If a shipping address is not present, a billing address is used.
- Delta sync state: The
customer_sync_last_datestate is updated even when no customers are synced to maintain the checkpoint. - Error handling: Order post-process does not fail the action if Commerce update fails; the order is already created in NetSuite.
- Rate limiting: NetSuite API calls retry on HTTP 429 with linear backoff (max 3 retries).
- Telemetry: Actions use OpenTelemetry instrumentation for observability (controlled by
ENABLE_TELEMETRY).