SAP Commerce Cloud Loader
The SAP Commerce Cloud loader pulls your product catalog and category hierarchy out of SAP Commerce Cloud through the OCC v2 API (/occ/v2, the “Omni Commerce Connect” web services) and lands them in your data warehouse.
Read this first, because it sets expectations that no amount of configuration will change:
OCC v2 is a storefront API, not an extract API. It exists to serve a shopping front end — render a product page, run a search, show one signed-in shopper their own orders. That shape decides what this loader can and cannot bring you:
- What you get: products, the category tree, and (on B2B deployments) organization customers.
- What you do not get: orders and customers. There is no site-wide order list and no user list in OCC v2 — see Why there are no orders or customers.
- Full refresh only. No stream has an incremental cursor, because OCC v2 exposes nothing to filter one on — see Sync Modes.
If you need site-wide orders, customers, or genuine change-data capture out of SAP Commerce Cloud, OCC v2 is the wrong surface. The right one is the OData-based Integration API, which requires an Integration Object you define inside Commerce Cloud — a different integration, not a setting on this one.
Prerequisites
- An SAP Commerce Cloud deployment (Commerce Cloud in the Public Cloud, or an on-premise/private Commerce installation) with the
commercewebservicesextension deployed. - Network access from Zeotap to your Commerce Cloud host. Commerce Cloud is customer-hosted, so there is no fixed vendor domain — you supply the host, and it must be reachable and TLS-terminated.
- An OAuth client configured for the
client_credentialsgrant, bound to a user with read access to your catalog. See Authentication. - The base site id you want to read (for example
electronics-spa). Zeotap can list these for you once the host and OAuth client are set. - For the Organization Customers stream only: a B2B deployment (the
b2bcommerceextensions) and a user inb2badmingroup. - A connected Warehouse with write permissions on the target schema.
Point the loader at an API node, not your storefront
Commerce Cloud publishes no rate limits for OCC, because you host it — the only limits are your own CPU, your Solr cluster and your database. A full catalog pull is a long sequence of FULL-mode reads, which is real load.
In Commerce Cloud in the Public Cloud, give Zeotap the api aspect endpoint rather than the storefront aspect. On a self-managed installation, point it at a node that is not serving shopper traffic. If you cannot separate them, raise Delay Between Pages and schedule the loader off-peak. Doing this well is the single biggest thing you control about this integration.
Authentication
The loader uses OAuth 2.0 client credentials. Zeotap exchanges your client id and secret for an access token at:
POST https://<your-host>/authorizationserver/oauth/tokenNote that /authorizationserver is a sibling of /occ — it is not under /occ/v2, and it takes no base site in its path.
Creating the OAuth client
- Log in to Backoffice as an administrator.
- Go to System > OAuth > OAuth Clients.
- Create a new client and set:
- Client ID — any identifier you like, e.g.
zeotap_client. This goes in the OAuth Client ID field. - Client Secret — a strong secret. This goes in the OAuth Client Secret field.
- Authorities —
ROLE_CLIENT. - Authorized grant types — must include
client_credentials. - Scope —
extended(the scope OCC declares for the client-credentials flow).
- Client ID — any identifier you like, e.g.
- Bind the client to a service user that can read the catalog.
client_credentialsauthenticates the client, not a shopper, so whatever that user can read is what the loader can read. - Paste the id and secret into Zeotap and click Test connection.
Token handling
client_credentials returns no refresh token, so there is nothing to refresh. Zeotap mints a token on demand and caches it until five minutes before it expires, then mints a new one. Token lifetime is read from the token response (expires_in) rather than assumed, because it is configurable per OAuth client in Backoffice — and Commerce Cloud has returned that field as both a bare number and a quoted string across releases, so both are accepted. If a token is revoked mid-run, the loader re-mints once and retries the page it was on.
Configuration
| Setting | Type | Required | Description |
|---|---|---|---|
| OAuth Client ID | Text | Yes | The client_id of your Backoffice OAuth client |
| OAuth Client Secret | Password | Yes | The client secret. Stored encrypted and masked in the UI |
| Commerce Cloud Host | Text | Yes | Base URL of your API node, with no path — e.g. https://api.mycommerce.example.com. Zeotap appends /occ/v2 and /authorizationserver/oauth/token itself |
| Base Site | Dropdown | Yes | The base site to read. Pick one by name — the list is loaded live from /occ/v2/basesites — or paste a base site uid directly |
| Page Size | Number | No | Results per page, 1–100. Default 100. Commerce Cloud silently clamps anything higher, so a larger value is ignored rather than rejected |
| Product Fields Selector | Text | No | OCC response-configuration selector. Default FULL. If you narrow it, it must still include pagination(DEFAULT) — see The fields parameter |
| Product Sort Code | Text | No | Solr sort code used to keep page boundaries stable. Default name-asc. Sort codes are configured per deployment, so enter one your storefront exposes — or none to send no sort parameter at all |
| Delay Between Pages (ms) | Number | No | Pause between page requests, 0–60000. Default 0. Raise it when the loader shares a node with live shopper traffic |
| Enable B2B Organization streams | Toggle | No | Off by default. Turn on only on a b2bcommerce deployment; it unlocks the Organization Customers stream |
| B2B Admin User ID | Text | Only if B2B is on | The user id used to read organization customers. Must be in b2badmingroup — an ordinary customer gets 403 |
The fields parameter
Every OCC endpoint takes a fields selector, and its default is DEFAULT — a deliberately sparse projection. Ask for a product list without fields and Commerce Cloud returns objects with a handful of keys, no price, no stock, no classifications, and no error. The result is a warehouse table full of NULL columns and nothing to explain it.
So Zeotap sends fields on every single request, defaulting to FULL.
It applies only to GET /products/search. /catalogs, /basesites and /orgCustomers always use FULL, so narrowing the selector can never empty the categories or org_customers streams. You can narrow it if FULL is too heavy for your node — but a selector that omits pagination leaves the loader with no way to know where the result set ends, and the run fails with an explicit error rather than looping. Any custom selector must include pagination(DEFAULT):
products(code,name,price(FULL),stock(FULL)),pagination(DEFAULT)Available Streams
| Stream | API | Fetched from | Notable columns | Incremental cursor |
|---|---|---|---|---|
Products (products) | GET /occ/v2/{baseSiteId}/products/search | The Solr search index, with an empty query | code, name, description, manufacturer, purchasable (bool), stock_level (int), stock_level_status, number_of_reviews (int), average_rating (number), price_value (number), price_currency_iso, price (json), price_range_*, categories (json), classifications (json), images (json), raw (json) | — (none available) |
Categories (categories) | GET /occ/v2/{baseSiteId}/catalogs | The catalog → catalog-version → category tree, flattened | id, name, url, last_modified, catalog_id, catalog_version_id, parent_id, depth (int), path, subcategory_count (int), subcategory_ids (json) | — (none available) |
Organization Customers (org_customers) | GET /occ/v2/{baseSiteId}/users/{userId}/orgCustomers | B2B organization users | uid, customer_id, name, first_name, last_name, active (bool), currency_iso, language_iso, org_unit_uid, org_unit_name, roles (json), default_address (json), raw (json) | — (none available) |
Products and Categories are selected by default. Organization Customers is not — it is only offered once you turn on the B2B toggle, because on a B2C storefront the endpoint does not exist and every run would fail with a 404.
Products come from the search index, not the database
/products/search is the only endpoint in OCC v2 that lists products, and it is served by Solr, not by a direct catalog query. A product is therefore visible to this loader only if it is in the search index. In practice that means a product is invisible — with no error and no warning — when it is:
- not approved (only
approvedproducts are indexed by the standard indexer), - not in the base site’s catalog version (typically
Online, notStaged), - excluded by your indexer’s configuration (a restricted
SolrIndexedType, avisible=falseflag, a custom index query), - newly created and not yet indexed — the full/incremental Solr index jobs run on a cron, so there is always a lag,
- suppressed by a search restriction applied to the user your OAuth client is bound to.
If your row count is lower than your catalog count, this is almost always why. Compare it against the product count in Backoffice for the same catalog version, and check when your Solr index job last completed.
Prices are objects, not numbers
OCC returns money as a structured object, not a scalar:
{ "value": 115.87, "currencyIso": "EUR", "formattedValue": "€115.87", "priceType": "BUY" }value is a genuine JSON number. Each price is therefore split into two typed scalar columns you can aggregate on directly, with the whole object kept alongside so nothing is lost:
| Column | Type | From |
|---|---|---|
price_value | number | price.value |
price_currency_iso | string | price.currencyIso |
price | json | the whole price object |
price_range_min_value / price_range_min_currency_iso | number / string | priceRange.minPrice |
price_range_max_value / price_range_max_currency_iso | number / string | priceRange.maxPrice |
price_range | json | the whole priceRange object |
The price you get is the price that user, in that currency, on that base site would see — OCC prices are resolved through the price rows and any customer-group discounts applicable to the user your OAuth client is bound to. If you need list prices independent of a user context, do not read them here.
Nested collections (categories, classifications, images, variant_options, base_options, future_stocks, volume_prices, potential_promotions) are stored as JSON text, and a raw column preserves the complete product as SAP returned it. On BigQuery and Databricks these are STRING columns and on Snowflake VARIANT; BigQuery’s JSON_VALUE, JSON_QUERY and PARSE_JSON all read a STRING column, so querying them is unaffected.
Categories are flattened, not nested
/catalogs returns every catalog with its versions, and each version carries a tree of categories with nested subcategories. Zeotap walks that tree depth-first and emits one row per node, carrying where it came from and where it sits:
catalog_id/catalog_name/catalog_version_id/catalog_version_name— provenance. The same category id can legitimately appear in two catalog versions, so always filter on the version you care about.parent_id,depth,path— position.pathis the slash-joined chain of ids from the root, so1/1.1/1.1.1is queryable with a simpleLIKE.subcategory_countandsubcategory_ids— the immediate children.
The endpoint is not paginated: one response can carry a whole catalog. Zeotap streams the flattened rows out in 500-row batches. The source tree itself is necessarily held whole while it is walked, because /catalogs is not paginated — there is no page boundary to stream on. A response beyond 256 MiB fails with an explicit error rather than exhausting the worker.
Why there are no orders or customers
This is the most common question about this loader, so here is the exact reason, verified against SAP’s published OCC API references for both the 1905 and 2105 releases.
Orders. OCC v2 has exactly three order-shaped read endpoints:
| Endpoint | Why it cannot back a stream |
|---|---|
GET /{baseSiteId}/orders/{code} | One order, by code. There is no endpoint that lists order codes, so there is nothing to iterate |
GET /{baseSiteId}/users/{userId}/orders | One user’s orders. Needs a user id — see below |
GET /{baseSiteId}/feeds/orders/statusfeed | Only orders whose status changed since a timestamp, and the queue is drained by a cron job. It is a notification feed for a middleware integration, not a complete order history |
Customers. There is no GET /users at all. Every user endpoint is GET /{baseSiteId}/users/{userId}/… and requires a user id you already have.
So the chain breaks at enumeration, not at authorization: without a list endpoint there is no set of ids to walk, and no OAuth scope, role, or credential changes that. Shipping orders and customers streams here would mean shipping streams that can never return a row.
What to use instead. SAP’s answer for bulk order and customer extraction is the Integration API (/odata2webservices/…), where you author an Integration Object in Backoffice describing exactly the entities and fields you want, and read it over OData with real filtering. Because the shape of that API is defined by your Integration Object rather than by SAP, it needs a connector that discovers the schema at runtime — which is a separate integration, not an option on this page.
Sync Modes
| Stream | Full refresh | Incremental |
|---|---|---|
products | Yes | — |
categories | Yes | — |
org_customers | Yes | — |
Every stream is full refresh. This is a property of OCC v2 and not a limitation Zeotap could lift:
- Products carry no timestamp at all. The OCC
Productmodel has nomodifiedtime, nolastModified, nocreatedAt— nothing. There is no field to store as a high-water mark even if there were something to filter on. - No product or category endpoint accepts a date parameter.
/products/searchtakesquery,sort,currentPage,pageSizeandfields, and nothing else. sortcannot substitute for a cursor. OCCsortvalues are Solr sort codes (name-asc,price-desc, …) configured per deployment. There is no separate direction parameter and no timestamp sort code, so “sort newest-first and stop early” is not expressible.CategoryHierarchy.lastModifiedexists but is useless as a cursor. It is returned, and Zeotap lands it as thelast_modifiedcolumn so you can inspect it — but/catalogsaccepts no filter and no sort, so it cannot narrow what is fetched.
Practical consequence: schedule these streams at a cadence that matches how often your catalog actually changes. A nightly full refresh of a catalog is normal and cheap relative to the storefront traffic the same node serves. Deletions are handled naturally — a full refresh replaces the table, so a product that has left the index simply is not in the new table.
How It Works
- Paging. OCC pages with
currentPage(0-indexed) andpageSize. Zeotap drives the loop entirely off the response, never off a local counter — Commerce Cloud silently clampspageSizeto 100, so a loader that counted its own rows would stop early on any larger page size. - Termination is over-determined on purpose. OCC returns two different pagination envelope shapes that serialize under the same
paginationkey with different field names (currentPage/totalResultsin one,page/totalCountin the other). OnlytotalPagesis common to both, so that is the primary bound — reinforced by stopping on any empty result array and by an absolute page ceiling. A missing pagination object is treated as a configuration error and reported, never as a reason to keep going. - Streaming, not buffering. Each page is mapped to rows and handed off immediately; the category tree is flattened and emitted in sub-batches. The flattened rows are never all held at once; the single unpaginated
/catalogsresponse is, by necessity. fieldson every request. See Thefieldsparameter.- Deterministic ordering. A sort code is passed so page boundaries stay stable for the duration of a run. It is not a cursor and nothing is inferred from it.
- Retries. A
401mid-run means the token expired: Zeotap mints a new one and retries the same page once. A429,502,503or504is retried in place on a short backoff (honouringRetry-Afterwhen present) so a momentarily busy node does not cost you a re-read of the whole catalog. - Base site validation up front. Test connection checks your base site id against the live
/occ/v2/basesiteslist. This exists because a wrong base site otherwise surfaces as a bare mid-run404with an empty body.
Rate Limits
SAP publishes no rate limits for OCC, and there is no vendor-side throttle to respect — Commerce Cloud runs on infrastructure you own or lease, so the ceiling is your own capacity: application CPU, Solr query throughput, and database load.
That makes throughput your decision, and the loader gives you the controls:
| Control | Effect |
|---|---|
| Commerce Cloud Host | Point at a dedicated api aspect / non-storefront node. The most effective single change |
| Delay Between Pages (ms) | Directly throttles request rate |
| Page Size | Fewer, larger requests (max 100) instead of many small ones |
| Product Fields Selector | Narrow the projection so each response is cheaper to build |
| Schedule | Run off-peak |
If your infrastructure or a CDN in front of it does throttle, Zeotap handles 429 and 503/504 by backing off and retrying the page rather than failing the run.
Troubleshooting
| Issue | Cause and fix |
|---|---|
not found (HTTP 404) … usually a wrong base_site_id | The base site in the URL does not exist on this host. OCC returns a bare 404 with an empty body for this. Re-pick Base Site from the dropdown, which is populated from the live /occ/v2/basesites list |
OAuth token request rejected (HTTP 400/401) | Wrong client id/secret, or the OAuth client is not configured for the client_credentials grant. Check System > OAuth > OAuth Clients in Backoffice: client_credentials must be in the authorized grant types |
forbidden (HTTP 403) | The token is valid but the user behind the OAuth client cannot read the resource. For org_customers specifically, that user must be in b2badmingroup — an ordinary customer always gets 403 here |
authentication failed (HTTP 401) after a successful test | The token was revoked or the OAuth client was changed mid-run. Zeotap re-mints once automatically; a second 401 means the credential is genuinely no longer valid |
Most product columns are NULL | The Product Fields Selector was narrowed and dropped those fields (it affects product search only — categories and org_customers always request FULL). OCC’s own default (DEFAULT) is sparse and returns no error for a missing field. Clear the selector to fall back to FULL |
reported N page(s) but no rows could be decoded | The selector kept the pagination envelope but dropped the products(...) wrapper, so every page decodes to zero rows. The run fails instead of replacing your table with an empty one. Append the item wrapper, e.g. products(code,name),pagination(DEFAULT) |
returned no catalogs | The OAuth client’s user cannot read the catalog for this base site. Grant catalog read access, or check base_site_id |
returned N catalog(s) but no category nodes | Every catalog version came back with an empty category tree — usually a permissions or catalog-version issue. The run fails rather than emptying the categories table |
category walk could not complete | The tree exceeded the depth cap (60) or the node budget (500,000). The run fails rather than landing a partial tree; read a narrower catalog |
response … exceeded the 256 MiB ceiling | /catalogs is unpaginated, so one catalog arrived larger than the loader will decode. Read a narrower base site or catalog |
returned no pagination object … must include pagination(DEFAULT) | A custom Product Fields Selector omitted the pagination envelope, leaving nothing to terminate the page loop on. Append ,pagination(DEFAULT) to the selector |
| Fewer products than expected | /products/search reads the Solr index. Unapproved products, products outside the base site’s catalog version, and products indexed after your last index job are invisible. See Products come from the search index, then check when your Solr index job last ran |
| A whole product category is missing | Products are missing, not categories — check the catalog version. categories rows carry catalog_version_id; make sure you are querying the same version (usually Online, not Staged) that your base site serves |
page_size above 100 seems to be ignored | It is. Commerce Cloud clamps pageSize to 100 without saying so, which is why the field is bounded at 100 |
| Duplicate category ids in the output | Expected, and not duplication — the same category id exists in multiple catalog versions. Filter on catalog_id + catalog_version_id, which together with id form the real key |
rate limited (HTTP 429) / HTTP 503 after retries | Your node (or a CDN in front of it) is shedding load. Raise Delay Between Pages, lower Page Size, move the loader to a dedicated API node, or reschedule off-peak |
| The Organization Customers stream is not offered | Turn on Enable B2B Organization streams and set B2B Admin User ID. It is hidden by default because the endpoint does not exist on a B2C deployment |
the org_customers stream requires b2b_enabled | A saved sync selected the stream but the B2B toggle was later turned off. Turn it back on, or deselect the stream |
| I need orders / customers | Not available on OCC v2 — see Why there are no orders or customers for the endpoint-by-endpoint reason and what to use instead |
Next Steps
- Create a model to shape the raw catalog — flatten the
categoriesJSON into a product-to-category bridge, or unpackclassificationsinto product attributes - Join
productstocategoriesoncategories→codeto build a browsable catalog dimension, filtering oncatalog_version_id - Use
price_valueandstock_levelin audiences and traits — both are typed numeric columns, so no JSON parsing is needed