Common integration tasks

Common API tasks, by job

Orders out, stock in, prices in, customer master in — the four jobs almost every ERP integration is made of, each with the resource, the shape, the webhook alternative and the trap.

Ninety percent of what an ERP integrator builds here is four jobs. This article is the recipe for each: which interface, which resource, roughly what comes back, and the thing that goes wrong.

The examples are illustrative. Base URL, headers, paging parameters and the response envelope are real and stable. Field names inside items and data depend on your installed apps — read the exact shape from your tenant's https://api.revenexx.com/v1/openapi.json, and take topic names from Events › Event Catalog. Do not code against the field names below.

The four jobs at a glance

JobDirectionVolumeLatency neededInterface
Orders outPlatform → ERPOne at a timeImmediateWebhook, plus a reconciliation poll
Stock inERP/WMS → PlatformThousands of rowsMinutes to hoursBulk import, or a live read for the few that matter
Prices inERP → PlatformThousands of rowsNightlyBulk import
Customer master inERP → PlatformHundreds of rowsNightly, plus live for creditBulk import, plus a live read at checkout

Read the pattern: exactly one of the four wants the REST API in a loop, and it is the one that handles single records. The other three are files. That is not a limitation of the platform — it is what the shape of the data is telling you. See Integration patterns.

Job 1: Orders out

Every order placed here has to reach your ERP, once, with everything the ERP needs to book it.

The webhook route — the right one

Subscribe to the order-created topic in Events › Event Catalog and have the ERP receive it. The delivery carries the event id, the topic, a timestamp and a signature — see Set up webhooks.

The event body tells you that an order exists and which one. Most integrations then fetch the full order, because an event payload is deliberately small:

GET /v1/orders/{id} HTTP/1.1
Host: api.revenexx.com
X-Revenexx-Tenant: acme-eu
X-Revenexx-Api-Key: rvxk_…

The pull route — as the safety net, not the mechanism

List orders that have appeared since your last successful run:

GET /v1/orders?limit=100&order=created_at.desc HTTP/1.1

Paging is limit and offset, sorting is order=<field>.<direction>, and the answer is always the same envelope:

{ "items": [ ... ], "total": 248, "limit": 100, "offset": 0 }

Run this hourly as reconciliation, not every thirty seconds as a substitute for the webhook. A webhook is a delivery attempt, not a guarantee; without the reconciliation run, a receiver that was down through its whole retry window loses orders silently.

Acknowledging back

Once the ERP has booked the order, say so. The order resource exposes actions for exactly this:

POST /v1/orders/{id}/acknowledge
POST /v1/orders/{id}/cancel
POST /v1/orders/{id}/complete

Acknowledgement is what closes the loop: it is how you distinguish "the platform sent it" from "the ERP has it", and it is the join your reconciliation report runs on.

What the ERP actually needs

Do not let the integrator take only the header. B2B orders carry per-position data that the ERP needs to book the line at all:

Carried onWhatWhy it matters
Ordercustomer_order_numberThe buyer's own PO number. Without it, their accounting cannot match the invoice
OrderThe three statusesstatus, payment_status and fulfillment_status are independent — see The three order statuses
PositionCost centreOne order can span three departments' budgets
PositionThe buyer's own article numberTheir part number, not yours
PositionPosition textThe machine, floor or Kostenstelle note

The trap

An order transmitted twice is two deliveries. Deduplicate on the event id, which is stable across retries, and never on a generated one of your own. And remember that ownership of the order moves at transmission: after the ERP has it, the ERP owns its status — see Deciding your system of record.

Job 2: Stock in

Your warehouse or ERP knows what is on the shelf. The shop needs to show it without lying.

The model, first

FieldMeaning
on_handWhat is physically there, per location and article
reservedWhat is already promised to somebody
availableon_hand − reserved. Computed, never stored, never written
reorder_pointYour own purchasing trigger (Meldebestand), not the customer's

available is the number a buyer should see. Pushing available as if it were on_hand double-counts your reservations and produces phantom stock-outs.

The bulk route — the right one for the whole warehouse

Push a full-state stock file on a schedule. Full state, not deltas: a delta stock file applied twice leaves stock wrong by exactly one run's movements, silently, with no error anywhere.

The bulk plane takes the file as a job rather than a request — you register it, it processes, you poll its state:

POST /v1/io/uploads      → a short-lived signed upload URL
POST /v1/io/imports      → register the uploaded file against a profile
GET  /v1/io/bulk-jobs/{job_id}   → rows read, written, rejected

Or drop the file on SFTP and let a scheduled profile pick it up, which is what most ERPs actually do.

The REST route — for the few articles that matter

Reading or adjusting individual stock levels:

GET /v1/inventories/stock?limit=100
GET /v1/inventories/availability?sku=4711-A
POST /v1/inventories/movements

Use this for corrections and for the handful of high-value articles where a nightly figure is not good enough. Do not use it in a loop over your catalog — see Rate limits.

The event alternative, in the other direction

The platform can tell you when stock crosses a threshold, so your purchasing system does not have to poll. Subscribe to the low-stock topic and you get a push when an article falls under its reorder point.

The trap

Never let the shop write stock. Stock is a running total of a movement ledger; a figure somebody typed over the top is a figure nobody can explain a week later. Correct with a booked adjustment, and see When stock numbers drift.

Job 3: Prices in

Prices are the field where being wrong costs a credit note, so this one gets the most care.

The model, first

A price is not a field on a product. It is a row in a price list, and the list carries the scope: currency, customer, channel, market, validity window, priority. That indirection is what lets one article have a catalogue price, a contract price, a scale price and a Swiss price simultaneously — see How pricing works.

So "push prices" always means: push rows into the right list.

The bulk route — the only sensible one at volume

POST /v1/io/uploads
POST /v1/io/imports    → against a price import profile
GET  /v1/io/bulk-jobs/{job_id}

A price import file is typically list code, SKU, quantity-from, amount, currency and validity dates. The mapping — which column becomes which field, and which fields the file may not touch — lives in the profile, see Field mapping and Import prices.

Reading list structure back is an ordinary REST call:

GET /v1/prices/lists?limit=50

Resolving one price

When something needs the price for one buyer, one article and one quantity — an order-list conversion, a quotation tool, a check — that is a resolve call rather than a lookup, because it has to walk the whole priority chain:

POST /v1/prices/resolve

Use this rather than reimplementing the priority rules in the ERP. Two implementations of price resolution will disagree, and the disagreement will surface on an invoice.

The trap

Expiring validity windows fail open, not closed. A contract price whose valid_until passes does not raise an error; the next-best list applies and the buyer pays more. Put the validity dates of contract prices on a review calendar — see Price list hygiene.

Job 4: Customer master in

The ERP owns who your customers are. The platform owns how they log in.

Split the entity before you write any code

This is the field-ownership conversation, and it is per field, not per entity:

FieldOwnerDirection
Customer numberERPERP → Platform
Company name, addressesERPERP → Platform
Payment termsERPERP → Platform
Credit limit, blocked statusERPRead live at checkout
Shop logins, contacts, rolesPlatformPlatform only
Approval rules, cost centres, budgetsPlatformPlatform only

The ERP has no concept of a shop login, and the platform has no business inventing a credit limit. See Deciding your system of record.

The bulk route

Organizations and their addresses arrive nightly as a file, matched on the customer number — a stable business key, never an internal id. Every import should mean "make the record look like this", so re-running converges instead of duplicating.

The REST route

GET  /v1/customers/organizations?limit=100
GET  /v1/customers/contacts?limit=100
GET  /v1/customers/organization_metrics

The metrics resource is worth knowing about for BI: it carries per-account revenue and order counts over 30, 90 and 365 days plus last_order_at, maintained for you rather than computed on the fly. See The metrics that matter in B2B.

The live read that is not optional

Credit limit and blocked status must be current at checkout, not from last night. Delivering to a customer your Buchhaltung blocked this morning is the one failure in this article that costs real money. Make it a live call, and decide what happens when the ERP does not answer — refuse, or let it through and flag. Both are defensible; not deciding is not.

The trap

Do not sync contacts from the ERP. The ERP's contact table is invoice recipients and a phone number from 2014. Shop contacts have roles, permissions, approval limits and cost-centre assignments that no ERP models. Let the platform own them — see Contacts and roles.

The two rules that apply to all four

Match on a stable business key. SKU, customer number, order number. An internal database id the other system has never seen is not a key you can match on.

Make every write idempotent. Things fail halfway. A timeout tells you nothing about whether the other side processed the request. If running the operation twice has the same effect as running it once, the recovery from any failure is "run it again" — and there is no Idempotency-Key header here, so idempotency has to be a property of your data, not of a header. See Test an integration end to end.

Next