Choose a system of record
This page is for solution architects and integration developers who are connecting Revenue Cloud to an ERP, a PIM or a CRM. It helps you choose the system of record: for each entity, which system is the master and which one only copies it. By the end you'll have a filled-in ownership matrix: one row per entity, with its master, its join key, the route the data travels and the import mode.
Make this decision before you build the first feed. Revenue Cloud does not lock a field because another system masters it: the next ERP export silently overwrites a price someone corrected in Cockpit. Nothing stops an edit in Cockpit or through the API, so write the ownership split down and design every feed around it.
One setting helps for companies and contacts. The tenant setting self_service_master_data stops buyers from editing their own company and contact data in the shop: addresses_only leaves them their addresses, and none closes everything (as of 2026-09). A buyer's edit is then refused with 403. Set it when the ERP or CRM owns that data. It doesn't affect Cockpit, the API or imports.
This page makes no calls of its own. The endpoints it names are there so you can check a design against them. Authentication and the conventions every call follows are on API usage.
The short answer
A split that fits most B2B projects, and the one the matrix at the end fills in:
- The PIM owns product content: SKUs, attributes, categories and assets. Revenue Cloud receives it in bulk.
- The ERP owns prices, contract prices and companies. Price lists arrive from the ERP export, and companies (called organizations in the API) carry the ERP's customer number.
- The CRM owns the contacts it creates. Revenue Cloud owns the ones that register themselves in the shop, and passes them on to the CRM.
- Revenue Cloud owns the order from placement until the ERP acknowledges it, including its number and its totals. Every placed order goes to the ERP to be booked. After the acknowledgement, the ERP drives the order: it feeds back holds, shipments and cancellations. Payment status comes from whatever took the money.
- Stock is the real choice. Either Revenue Cloud keeps the stock ledger, or the ERP does and Revenue Cloud mirrors it. See Stock for what the mirror costs.
One rule holds for every row: one master per entity, or at most one master per group of fields. Two systems that both write the same field overwrite each other, and nothing on the platform flags it.
1. Name a master for each entity
List the entities your project moves between systems. For most projects these are products, prices, stock, companies, contacts and orders. For each one, name the system whose value wins when the two disagree. That system is where people edit it. Everyone else reads.
Two questions settle most rows:
- Where do people edit it today? If the purchasing team maintains prices in the ERP, the ERP is the price master. Moving that job into Cockpit is a process change, not an integration decision.
- Who needs it first? A buyer's cart needs prices and availability instantly, so Revenue Cloud serves them from its own copy even when the ERP is the master. Mastership says who writes a value, not who serves it.
A few values belong to Revenue Cloud whatever you decide. Step 5 lists them.
2. Pick the join key for each entity
Every feed matches records by a key that both systems know. Revenue Cloud's own id is a UUID your ERP has never seen, so join on the business key instead:
| Entity | Join on | Unique? | Find a record by it | Watch out for |
|---|---|---|---|---|
| Product | sku | Yes, per tenant; required on create | GET /v1/products?sku= | It is the only identifier column. An EAN or a manufacturer article number is an attribute inside attribute_values, not a key. |
| Company (organization) | customer_number | Yes, per tenant, while set; optional | GET /v1/customers/organizations?customer_number= | Free text with no format check. A second company with the same number is a 409. |
| Contact | email, or external_id (the CRM's or ERP's contact id) | Both, per tenant; external_id while set | GET /v1/customers/contacts?email= or ?external_id= | email is the login, so changing it changes how the person signs in. It is matched exactly, case included, so send it in the same case every time. Join on external_id when the CRM owns the contact. |
| Price list | code | Yes, per tenant; required | GET /v1/prices/lists?code= | The currency belongs to the list. Entries carry none. |
| Price entry | sku (or product_id) plus quantity_min | Matched on that pair by a bulk upsert | via its list | sku is matched exactly, never case-folded. An entry keyed by sku does not match the same product keyed by product_id, so pick one and use it everywhere. Validity dates are not part of the match. Two rows with the same SKU and quantity in one call, such as two valid_from windows, make the call fail (as of 2026-09), so send one row per SKU and quantity. |
| Location | code | Yes, per tenant; required | GET /v1/inventories/locations?code= | A location also carries an external_id for the ERP's own warehouse key, which you can read and filter on. The create and update routes do not list it (as of 2026-10), so join on code. See Source fields. |
| Stock | location plus sku | One row per item per location is expected, but not enforced | GET /v1/inventories/stock?location_id= | The filter takes the location's id, so look the location up by its code first. Key every stock call by sku: a row created with only product_id doesn't match a call keyed by sku, and a second row is created. You can't set a balance, only move it. See below. |
| Order | number on the Revenue Cloud side, external_ref on the ERP side | number yes | GET /v1/orders?external_ref= | Revenue Cloud draws the number from the tenant's range, and no order route lets a caller set it. external_ref is written once, by the acknowledgement. |
Three joins need an extra step:
- A contract price joins on a UUID, not on the customer number. A company-specific price list is scoped by
organization_idorcontact_id. When the ERP exports a contract price for customer10042, look up the company with?customer_number=10042first, then create or update the list scoped to that company'sid. B2B pricing covers the scoped lists themselves. Price resolution considers at most 1,000 active lists per tenant (as of 2026-09), so one list per company works for up to about that many companies. - A contact can have two masters. Let the CRM create and edit the contacts it knows, joined on
external_id. Let buyers who register in the shop be Revenue Cloud's, and pass each one on to the CRM once it is approved. With open registration that iscontact.registered. It also fires when a buyer is invited into an existing company, so filter onself_registeredif the CRM should only get sign-ups. If the tenant approves registrations first,contact.registeredalso fires for applications stillpending, so pass oncontact.registration_approvedinstead. When the CRM writes back its own id, set it as that contact'sexternal_id, and the contact is joined from then on. Decide which system wins for the fields both sides edit, such as the phone number, and write that down too. - An order has a key on each side. Revenue Cloud's
numberexists the moment the order is placed. The ERP's id arrives later, when your integration callsPOST /v1/orders/{id}/acknowledgewith{"external_ref": "…"}. A second acknowledgement is refused with422, so book in the ERP first and acknowledge once. See Orders.
Stock
Revenue Cloud keeps stock as a ledger. on_hand and reserved are running totals of movements, and an edit can't write them. PUT /v1/inventories/stock/{id} silently drops both fields and applies the rest of the body (as of 2026-09), so a feed that sends on_hand there gets a 200 and changes nothing. Only a body with nothing else in it is answered 422. POST /v1/inventories/stock drops them too: a row created with on_hand: 40 starts at 0. Every change is a signed movement: receive goods, adjust, reserve for an order, commit when the goods leave.
If Revenue Cloud is the stock master, that is what you want: the ledger stays consistent on its own. See Stock. To have the ERP raise the reorders from that ledger, see Send low-stock alerts to the ERP.
main: 40". Revenue Cloud needs "item A at main: −3". Your feed reads the current on_hand, computes the difference, and sends it through POST /v1/inventories/adjust: up to 200 items per call, all at one location named by location_code, with a reason. A call without location_code books to the tenant's default location, not the one you meant, and a call without reason is refused by default. Keep the window between your read and your write short. Goods committed in between change on_hand, and your delta is then off by that amount.Two more traps. An item is refused with 422 if it would take on_hand below zero, or below what is reserved for open orders (unless the tenant allows negative stock), so an ERP count lower than the open reservations won't go through. And an adjustment is not safe to repeat. The items of one call are applied one after the other, and a refusal part-way through does not undo the ones before it. After any error or timeout, re-read on_hand and compute the deltas again rather than resending the call, or the items that did land are applied twice. See Retries and idempotency.Whichever system masters stock, check its size first. Availability and reservations read at most 10,000 stock rows per tenant, counted across all locations, and an adjustment reads at most 10,000 per location (as of 2026-09). Past that, items that are in stock can show as untracked or fail to reserve, and an adjustment can start a second row at 0. Nothing reports an error when that happens. If a tenant needs more than 10,000 rows (items times locations), raise it before you design the feed.
The Stock page names a third option: an App that replaces the availability check and answers from the ERP live, while the rest of the stock routes keep working. No shipped App does this yet (as of 2026-09), so plan on one of the two above.
3. Pick the route for each direction
Data into Revenue Cloud and data out of it travel by different routes, so answer the question once for each direction.
Into Revenue Cloud: Reaching Revenue Cloud describes three routes:
| Route | Use it for | Typical feed |
|---|---|---|
| Generic API Call, in an Integration Studio workflow | A handful of records per run | A new company from the CRM, a shipment from the ERP |
| Import and export nodes, in a workflow | A file, on a schedule, without a server of your own | The nightly PIM product export |
/v1/io directly, from your own code | A file, when your own service already runs the job | A catalog load from your middleware |
Prices take their own route. Send an ERP price export to POST /v1/prices/lists/{list_id}/entries/bulk (at most 5,000 entries per call). It upserts by default, so re-running the same export corrects prices instead of duplicating them. It never deletes (as of 2026-09): an entry the export no longer contains, such as a withdrawn quantity tier, keeps resolving until you delete it or replace the list. That holds for a list of up to 10,000 entries (as of 2026-09): past that, a re-run no longer finds every existing entry and inserts some of them again, so split a larger book across several lists with the same scope, each SKU in exactly one of them. PUT /v1/prices/lists/{list_id}/entries replaces the whole list instead, within the same 10,000-entry limit: past it, old entries survive next to the new ones. It removes the old entries before it writes the new ones, so a call that fails part-way leaves the list incomplete until you run it again.
Out of Revenue Cloud: react to events. A placed order, an acknowledged order and a changed payment status each publish one. Which consumer to use (a webhook to your server or an Integration Studio event trigger) is its own decision, covered in Choose an event surface. The topic you subscribe to carries the app as a prefix, for example revenexx.orders.order.placed, not order.placed.
Whether a bulk import publishes an event per changed row is not documented (as of 2026-09). Don't design a reaction, such as "reindex when a product changes", that depends on an import triggering it. Trigger it from the end of the import job instead.
4. Pick the import mode for each feed
A /v1/io import takes a mode and the keys it matches on. For products, the key is ["sku"]. The mode decides what happens to rows that are already there. Import and export has the full request.
mode | Rows in the file | Rows not in the file | Use it for |
|---|---|---|---|
upsert (default) | Inserted if the key is new, updated if it exists | Left alone | Almost every master-data feed, full or partial |
full-sync | Inserted or updated | Deleted | A complete export where "missing" really means "gone" |
append | Inserted, with no matching on keys | Left alone | Data that is new by construction. Never master data. |
full-sync deletes every row of that entity on the tenant whose key is not in the file, across all of the tenant's markets. The file must be the complete set: every product, not "products changed since yesterday", and not one brand's or one market's extract on a tenant that sells more. A partial file run in full-sync removes everything it left out, and the rows are gone, not deactivated. If your master can only export changes, use upsert and delete through the API when the master says a record is gone.- Re-sending the same export does not undo an edit made in Revenue Cloud. The edit stays until the master itself changes that product.
- A product deleted through the API and later re-sent unchanged is not re-created. Create it again with
POST /v1/products.
full-sync also needs keys, and not every entity accepts it (as of 2026-09). For one that doesn't, the job fails.
A job finishes as completed, partial (some rows rejected), failed or canceled. Poll GET /v1/io/bulk-jobs/{id}, and treat partial as a failure until you have read the rejected count. On a full-sync, set max_rejects to 0, so that one bad row stops the job instead of being left out of a load that deletes whatever it doesn't contain.
5. Record what you can't write from outside
Some fields are Revenue Cloud's no matter what the matrix says: the entity's own routes don't let a caller write them. Put them in the matrix, so nobody designs a feed that writes them:
- Order totals.
subtotal,tax_totalandgrand_totalare computed from the lines when the order is placed, never taken from the caller. - The order number. It is drawn from the tenant's number range and never reused.
external_ref. It is written once, by the acknowledgement.- Order status and fulfilment status.
statuschanges only through the order actions, such as cancel and complete.fulfillment_statusis derived from what has shipped and what was cancelled. Acknowledging changes neither, and a hold is a separateon_holdflag. - An order's contents after the acknowledgement. Until the ERP acknowledges,
PUT /v1/orders/{id}can correct the buyer, the addresses, the customer's own order number, the requested delivery date,user_dataandmetadata, plus four columns that record where a source system last confirmed the order:external_id,external_refs,source_dataandsource_synced_at. After it, the same call answers422, unless the tenant turned onallow_modification_after_acknowledge. - Stock balances (
on_hand,reserved) and the computedavailable. See Stock. - Platform-written links.
external_user_idon a contact andexternal_team_idon a company are set by the platform and ignored when a route writes the record.
One field works the other way round. payment_status is always fed in from outside, by whatever took the money: at placement, or later through POST /v1/orders/{id}/payment-status. The orders app never decides it. Name that system in the matrix as well.
Everything else is writable, which means your master's fields are writable in Cockpit too. Tell the merchant's team which fields are mastered elsewhere. An edit there is overwritten by the next feed that carries that record: every run for prices sent with entries/bulk, and only once the master changes that product for a product import.
A /v1/io import doesn't go through these routes. For any entity that GET /v1/io/entities lists, an import writes the columns in the file as they are, without the rules the entity's own routes apply. Use it for master data such as products. Send stock, orders, companies and contacts through their own routes, so that the rules on this page hold.
The ownership matrix
The split from the short answer, filled in. Copy it and change the rows where your project differs:
| Entity | Master | Join key | Into Revenue Cloud | Out of Revenue Cloud | Mode | Read-only in Revenue Cloud |
|---|---|---|---|---|---|---|
| Products | PIM | sku | Import node or /v1/io, nightly | — | full-sync if the export is complete, else upsert | — |
| Price lists and entries | ERP | list code; entry sku + quantity_min | entries/bulk, after each ERP price run | — | upsert | — |
| Contract prices | ERP | customer_number → organization_id | entries/bulk on a list scoped to the company | — | upsert | — |
| Locations | ERP | code (external_id is read-only for now, as of 2026-10) | POST /v1/inventories/locations, once per warehouse | — | — | external_id, external_refs, source_synced_at, source_data (as of 2026-10: no route writes them) |
| Stock | ERP or Revenue Cloud | location + sku | POST /v1/inventories/adjust with deltas (ERP master only) | Stock events (Revenue Cloud master) | — | on_hand, reserved, available |
| Companies | ERP | customer_number | Generic API Call per change | Company events | — | external_team_id |
| Contacts | CRM for the ones it creates; Revenue Cloud for self-registered buyers | external_id (CRM), email (self-registered) | Generic API Call per change | contact.registered (open registration) or contact.registration_approved (approval first), to pass new buyers to the CRM | — | external_user_id |
| Orders, until acknowledged | Revenue Cloud | number | — | order.placed → book in the ERP → acknowledge | — | number, totals, status |
| Orders, after acknowledgement | ERP | external_ref | Hold, ship, cancel, payment status, through the order actions | Order events | — | the order's fields, unless the tenant allows changes |
| Payment status | Payment provider or ERP | order id | POST /v1/orders/{id}/payment-status | order.payment_status_changed | — | — |
Event names in the matrix are short. The topic you subscribe to carries the app as a prefix, for example revenexx.orders.order.placed. An Integration Studio trigger's subject accepts underscores since 2026-09-29, so a topic such as revenexx.inventories.stock_level.low can be written exactly. To cover several topics with one workflow, put a * in the segment that varies, such as revenexx.orders.order.*, revenexx.customers.contact.* or revenexx.inventories.*.low, and branch on the topic inside the workflow.
Where to go next
- Choose an event surface: the consumer for every "out of Revenue Cloud" cell.
- Import a BMEcat catalog: a complete PIM product feed.
- Send low-stock alerts to the ERP: when Revenue Cloud keeps the stock, a reorder signal to the ERP and the delivery booked back in.
- Hand an order to the ERP: the order row of the matrix, worked through with the acknowledgement.
- Confirm invoice payments from your ERP: the payment-status row.
- B2B pricing: price lists, quantity ladders and contract prices from an ERP export.
- Reaching Revenue Cloud: the three inbound routes in detail.
- Orders: the acknowledgement handshake, holds, shipments and payment status.