Hand an order to the ERP

Book every placed order in your ERP with an Integration Studio workflow, write the ERP's id back onto the order, and let the ERP report holds, shipments, stock and payments back, without booking an order twice when a run is retried or replayed.

A buyer places an order, and within one workflow run it is booked in your ERP, carries the ERP's id, and its addresses and PO number are locked against edits from the shop. From then on the ERP is the system that ships, and it reports back what happened: a hold, a shipment with its tracking code, the stock that left, the payment that arrived. This guide builds both directions: an Integration Studio workflow for the way out, and a handful of API calls from the ERP side for the way back.

This is for integrators connecting an ERP to a B2B shop. You need a tenant with the Orders core app, Integration Studio enabled on it, access to Cockpit to create API keys and the workflow, and an ERP with an order API that the workflow can reach over HTTPS from the internet, and that can call an HTTPS API itself when something changes. Integration Studio is experimental and its schemas are drafts, so expect a workflow built today to need a migration later. Statements here about behaviour the product does not yet support can change (as of 2026-09).

Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."

REVENEXX_API_KEY is the ERP side's key, created in step 1. Every call goes through the API gateway with the same two headers. The shell examples use jq to pick values out of the answers.

How the loop runs

A placed order starts a workflow run. The workflow reads the full order, stops if the order is already acknowledged, books it in the ERP, and acknowledges it with the ERP's id. From then on the ERP owns fulfilment, and reports holds, shipments, stock and payments back over the API with a key of its own.

The way out runs in a workflow because no server of yours has to be running for it, and because a failure lands on a run you can read and replay. An App handler is not an option for this: only app.installed and app.uninstalled are verified for Apps. A webhook to your own middleware works too, if you would rather run that code yourself; Choose an event surface compares the two. The way back is plain API calls, made by whatever in your ERP landscape notices the change.

1. Create two keys

Each direction gets its own key in Cockpit (see API keys and create an API key), with custom scopes instead of a preset (which scope covers which app).

The workflow's key, used only inside Integration Studio:

ScopeFor
orders.getReading the full order (step 4)
customers.organizations.getResolving the buying company to its ERP customer number (step 4)
orders.acknowledgeWriting the ERP's id onto the order (step 7)

The ERP side's key, used by whatever reports back from the ERP (step 8):

ScopeFor
orders.listFinding an order by the ERP's id
orders.getReading an order's lines before a shipment
orders.shippableAsking whether a shipment would be accepted
orders.hold, orders.unholdHolds
orders.shipShipments
orders.payment-status.updatePayment status
inventories.commitOnly if your checkout reserves stock

Reading the event catalog once, in step 2, has no documented scope yet (as of 2026-10), so make that one call with a separate development key that carries *, not with either of these two keys.

Narrow scopes limit which operations a key can call, not which orders: the ERP side's key can hold, ship or mark as paid any order on the tenant. Keep it on the ERP side, never in a storefront or a browser.

Put the workflow's key into a Revenexx API credential under Integration Studio → Credentials, with your tenant slug, and test it there. The test proves the key and the tenant, not the scopes: a missing scope fails at the step that needs it, with an authorization error. See Reaching Revenue Cloud.

2. Look up the topic to subscribe to

The event to act on is the Orders app's placement event, order.placed. It fires once the order is numbered, with its positions and totals. order.created fires earlier and for quote requests too, so it is the wrong one (which order event).

order.placed is the event's name. A workflow trigger matches the event's topic, which puts the vendor and the app in front of the name: <vendor>.<app>.<name>. Read it from your tenant's event catalog rather than typing it from memory. The catalog has no documented scope yet (as of 2026-10), so use a development key with *:

Read the topic
curl -s https://api.revenexx.com/v1/events/catalog \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq -r '.emits[] | select(.app == "orders" and .name == "order.placed") | .topic'
Response
revenexx.orders.order.placed

A trigger subject of order.placed has two segments and the topic has four, so it never matches. Nothing warns you. The workflow never starts.

3. Start the workflow on the event

In Cockpit, open Integration Studio → Workflows → New, and add an Event trigger with the subject from step 2:

Subject
revenexx.orders.order.placed

Each placed order now starts one run. The trigger drops a repeat delivery of the same event, so one event never starts two runs (see Event triggers).

The event's own fields sit under data in the trigger payload:

ExpressionValue
${{ trigger.payload.data.order_id }}The order's id, for every call in the next steps
${{ trigger.payload.data.number }}The order number, such as A-10482
${{ trigger.payload.data.items }}The positions: order_item_id, sku, name, quantity, unit_price, position
${{ trigger.payload.id }}The event's id, not the order's

The order's id is order_id, not id. ${{ trigger.payload.id }} is the event, and an expression that reaches for it gives every later call an id no order has. The payload carries no addresses, no payment or shipping details and no customer number, so the next step reads the full order.

An order the Orders app holds for approval does not fire this event. An order over the tenant's approval threshold, or placed by a buyer who may only request, emits order.requested instead and waits. Once it is approved (Approve or reject a pending order) it emits order.placed like any other order, with approved_by added; a rejected one is cancelled and never does. The difference from a purchase request is explained in Set up purchase approvals. If your ERP must see those orders while they wait, add a second workflow on the order.requested topic from the catalog, and keep in mind that a pending order cannot be acknowledged yet: the call answers 422 with order_pending until the order is approved. An approval that happens before the order exists, in a purchasing workflow, is different: the order is placed once it is approved and fires order.placed as usual.

4. Read the full order

Add a Generic API Call node with the Revenexx API credential, resource Orders, and the operation that gets one order (GET /v1/orders/{id}), with the id set to ${{ trigger.payload.data.order_id }}. See the node catalog.

The answer is everything the ERP needs: the buyer, the billing and shipping addresses as they stand now (the shop can change them until step 7 acknowledges the order), customer_order_number (the buyer's PO number), the payment and shipping choices, the totals, and items sorted by position. It also carries acknowledged_at, which the next step checks.

The order names the buying company by organization_id, not by the number the ERP knows it under. If your ERP books orders against its own customer numbers, add a second Generic API Call for the organization (GET /v1/customers/organizations/{id}) and read customer_number from it. That field is free text that somebody has to fill in, so a company with no customer_number is worth routing to a person rather than booking against a guess. See Organizations.

5. Skip an order that is already booked

Add an If condition node that continues only when acknowledged_at from step 4 is empty, and a second one that sends an order whose status is cancelled to the error branch from step 9 instead of the ERP. An order can be cancelled while a failed run waits to be run again.

This guard covers a whole run started again: a firing you replay from Missed runs, or an order you run again after fixing a failure. Once step 7 has acknowledged the order, every later run stops here.

It does not cover a retry. A failed node is retried on its own (three attempts by default) without passing through this check again, and a run can book the order in the ERP and then fail before step 7 writes the acknowledgement. So when the ERP already holds an order under the same revenexx order number, it must answer with success and the ERP id it gave that order the first time, rather than booking it again or refusing it. That is what prevents a double booking, and it is also what lets a retried or re-run workflow reach step 7 and acknowledge the order. An ERP that can only refuse a duplicate leaves the order unacknowledged: a person then has to read the ERP id and acknowledge the order by hand. This step saves the ERP call when the order is already done.

6. Book the order in the ERP

Add an HTTP Request node that sends the order to your ERP's order API, built from the step 4 answer with the expression picker. Map at least:

From the orderInto the ERP
numberThe external order reference, and the key for the ERP's duplicate check
customer_number from the organizationThe sold-to customer
customer_order_numberThe buyer's PO number
billing_address, shipping_addressBill-to and ship-to
items[].sku, unit_price, positionThe order lines
items[].quantity minus quantity_shipped and quantity_cancelledEach line's open quantity. Skip a line where it is 0
currency, grand_totalFor the ERP's own check that the totals agree

quantity stays what was ordered, even after part of a line is cancelled, so booking it as it stands would book the cancelled part too. Keep each line's id from items next to the line in the ERP, or keep position and look the id up later. A shipment names lines by that id (step 8).

On this node, set onError to route; step 9 wires what happens next. The node retries three times by default, even with no retry configured, and a request that timed out may still have been booked on the ERP side. Until the ERP's duplicate check from step 5 is in place, set the node's retry to one attempt (maxAttempts: 1) with Max attempts under Retry on failure in the node's Settings tab. Once the check answers a duplicate with the existing id, retrying what fails briefly, such as a timeout or a 503, is safe.

Before you rely on either, send the node a request your ERP rejects in a test run, and check which port the answer leaves on. If a rejection comes out of response rather than error, or your ERP answers 200 with an error in the body, add an If condition after the node that checks the answer and routes a rejection to the same error branch. See Failure handling.

7. Acknowledge with the ERP's id

Add a Generic API Call for the Orders acknowledge operation, with the order's id and the ERP's order number in external_ref. The examples use an SAP-style order number, SAP-4500091234; send whatever your ERP answered in step 6. The same call as a request:

Acknowledge
curl -s -X POST "https://api.revenexx.com/v1/orders/<ORDER_ID>/acknowledge" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"external_ref":"SAP-4500091234"}'

Three things happen:

  • external_ref is stored on the order, so either system can find the other's record. The ERP side finds an order with GET /v1/orders?external_ref=SAP-4500091234.
  • acknowledged_at is stamped, which is what step 5 checks on the next run.
  • The correction window closes. From now on the shop can no longer change the order's addresses or PO number (PUT /v1/orders/{id} answers 422), because the ERP holds the copy that ships. A tenant that set allow_modification_after_acknowledge keeps the window open. It does not stop a cancellation: step 10 passes those on.

If the ERP's answer carries no order id, stop before this call and route the run to the error branch: an acknowledgement with an empty external_ref cannot be repeated or corrected, and the ERP side's lookup by its own id then finds nothing.

It does not change status, payment_status or fulfillment_status, and it ships nothing. It publishes an order.acknowledged event, for anything else that waits on the hand-off.

Acknowledging is once only. A second call answers 422 with order was already acknowledged at …, rather than overwriting the first reference. It also answers 422 for an order that was cancelled or completed in the meantime.

This node retries three times by default too. If the first attempt succeeds but its answer is lost, the next attempt gets that 422, although the hand-off worked. Set this node to one attempt, or treat already acknowledged on its error port as done.

8. Report back from the ERP

From here the ERP drives. Several calls below behave according to tenant settings, such as on_hold_blocks and auto_complete_on. They are listed, with where to change them, on Settings. Whatever notices the change, such as an ERP job, your middleware, or a scheduled workflow that polls the ERP, calls the API with the ERP side's key. First find the order by the ERP's own id:

Find the order
ORDER_ID=$(curl -s "https://api.revenexx.com/v1/orders?external_ref=SAP-4500091234" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq -er 'if (.items | length) == 1 then .items[0].id else error("expected one order") end') || exit 1
Check that exactly one order came back. A filter the Orders app does not know is ignored, not refused, so a typo such as ?external_id= answers 200 with every order on the tenant, and .items[0] is then an arbitrary order. In a script, the lookup above stops it when .items has no entry or more than one.

Every call below uses $ORDER_ID from this lookup.

Hold and release

A credit check fails, or somebody has a question:

Hold the order
curl -s -X POST "https://api.revenexx.com/v1/orders/$ORDER_ID/hold" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason":"Credit check pending"}'

A hold is a flag with a reason, next to the order's status rather than a status of its own. With the tenant's default on_hold_blocks of shipping, a held order refuses shipments; with shipping_and_cancel it refuses cancellation as well. POST /v1/orders/$ORDER_ID/unhold releases it. See Orders.

Ship

When goods leave, report the shipment with the ERP's delivery-note number:

Report a shipment
curl -s -X POST "https://api.revenexx.com/v1/orders/$ORDER_ID/ship" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "number": "LS-80012345",
        "carrier": "DHL",
        "carrier_code": "dhl",
        "tracking_code": "00340434161094042557",
        "positions": [ { "order_item_id": "5b0c…", "quantity": 4 } ]
      }'

Name the lines by order_item_id, the id of each entry of items in GET /v1/orders/$ORDER_ID, or leave positions out to ship everything still open. Send your own number, so a repeat books nothing (the table below says what it answers). What a shipment does to the order, and how part shipments, refusals and completion work, is in Ship, cancel and return an order.

Commit the stock, if your checkout reserved it

A shipment does not touch stock. If your checkout reserved stock, commit it when the goods leave, under the same order_ref the reservation used:

Commit the stock
curl -s -X POST https://api.revenexx.com/v1/inventories/commit \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"order_ref":"<ORDER_REF>"}'

Most tenants reserve nothing, so there is nothing to commit, and if your ERP keeps the stock and the shop does not track it, skip this. A commit takes the whole reference, so there is no partial commit. The traps (which reference a cart conversion used, shipping in parts) are in Ship, cancel and return an order, and the reservation model is in Stock.

Payment status

When the ERP sees the money, it sets the order's payment status:

Set the payment status
curl -s -X POST "https://api.revenexx.com/v1/orders/$ORDER_ID/payment-status" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status":"paid"}'

This records a fact and takes no money. For invoices and prepayments, the payment record has to be settled too, or the dunning scan keeps sending reminders: Confirm invoice payments from your ERP does both calls. A refund goes the other way, through Ship, cancel and return an order.

When the ERP side sends a call twice

An ERP job retries, and a job that crashed halfway runs again. Each report-back call behaves differently on a repeat:

CallSent againSafe?
holdSets the same flag, replaces the reason (with none, if the repeat sends none), and writes another trail entry and order.held eventThe order ends the same, but subscribers see the hold twice
unhold422 order is not on holdYes. Treat it as done
ship with your own number409 while the repeated quantity is still open, 422 when nothing it names is open any more. Nothing bookedYes
ship without numberA second shipment, as long as quantity is openNo. Send number
inventories/commit422 no active reservations for order_ref '…'Yes. Treat it as done
payment-statusSets the same status, and writes another trail entry and order.payment_status_changed eventThe order ends the same, but subscribers see the change twice

9. Make a failure visible

An event-driven workflow fails with nobody watching. Wire the error ports of the HTTP Request node from step 6 and the acknowledge node from step 7 to something your team reads, such as a Send Email node or a call into your ticketing system, and end that branch with a Stop and Error node that names the order:

Stop and Error message
ERP rejected order ${{ trigger.payload.data.number }}, see the run's steps

Put an If condition first on the acknowledge node's branch that ends the run quietly on already acknowledged (see step 7), so that case does not raise an alert.

Without the Stop and Error node the run finishes green after the email, and the run list shows a success. With it, the run is marked failed with a reason someone can act on. After the cause is fixed, run the order again. If the run failed before the ERP call, that is all. If it failed at or after it, the ERP may already hold the order, and its duplicate check from step 5 answers with the existing id, so the second run acknowledges the order instead of booking it twice. A failure at step 7 matters most: the order is in the ERP, but it is not acknowledged and the shop can still change it.

Check Integration Studio → Runs → Missed runs after every save. A workflow only runs while its build is ready, so an order placed right after an edit, while the new build is still running, can land there instead of in a run. Replay it from there. See Missed runs.

10. Pass cancellations on to the ERP

Acknowledging does not stop the shop from cancelling. An operator in Cockpit, or any caller with the orders.cancel or orders.items.cancel scope, can still cancel the whole order before anything has shipped, or cancel open quantities on single lines (what each cancellation books is in Ship, cancel and return an order). Nothing in steps 1 to 9 tells the ERP, which then picks and ships goods for a cancelled order.

Add a second workflow for the two cancellation events, order.cancelled and order.item_cancelled. Their topics, from the catalog as in step 2, are revenexx.orders.order.cancelled and revenexx.orders.order.item_cancelled. A trigger subject accepts underscores since 2026-09-29, so each topic could be written exactly. One workflow covers both cancellations if you subscribe to every order topic with a one-segment wildcard instead:

Subject
revenexx.orders.order.*

Then add an If condition on ${{ trigger.payload.topic }} for each of the two topics, and let every other topic end the run without doing anything. The wildcard starts a run for every order event, placements, holds and shipments included, so keep the check at the very start of the workflow. Both events carry order_id and number, so the workflow finds the ERP order by the revenexx order number, as the duplicate check in step 5 does. When the ERP has no order under that number yet, because the hand-off run failed and is waiting to be run again, route the cancellation to an error branch that ends in Stop and Error, and run it again once the order is booked. Steps 5 and 6 already keep a re-run of the hand-off from booking what was cancelled in between:

EventCarriesThe ERP should
order.cancelledreason, cancelled_atCancel the whole ERP order
order.item_cancelleditem_id, position, sku, quantity_cancelled, quantity_remaining, reasonReduce that line by quantity_cancelled. quantity_remaining is what is still open to ship on the line, not its new ordered quantity

A cancellation books no refund, stock release or promotion release: see Ship, cancel and return an order for those. If your process is that cancellations start in the ERP once it holds an order, tell the merchant so, and have the ERP report a cancellation with POST /v1/orders/{id}/cancel or /items/cancel, which adds orders.cancel or orders.items.cancel to the ERP side's key. An order cancelled line by line fires only order.item_cancelled, never order.cancelled, which is why the workflow handles both. See Orders.

Troubleshooting

  • The workflow never starts. The trigger subject is order.placed or has a typo. It has to be the full topic from the catalog, revenexx.orders.order.placed (step 2). Then look in Missed runs: a firing during a build lands there, not in the run list.
  • The run fails at step 4 with an id that no order has. The expression reads ${{ trigger.payload.id }}, which is the event's id. Use ${{ trigger.payload.data.order_id }}.
  • An authorization error on one node only. The workflow's key lacks that node's scope. The credential test does not check scopes.
  • 422 order was already acknowledged at …. On a retry of the step 7 node, the first attempt went through: the order is done. On a run that got past step 5, the If condition reads the wrong field. Check the ERP for a duplicate from that run.
  • 422 order is cancelled — cannot acknowledge at step 7. The order was cancelled while the run was booking it. The ERP holds a booking to reverse.
  • 422 on a change to the shipping address after the hand-off. Expected: acknowledging closed the correction window. Change the address in the ERP.
  • 422 order is on hold … unhold before shipping. Release the hold first. The message names the reason when the hold has one.
  • 422 nothing left to ship on this order, or 422 order is completed — cannot ship. Every line is shipped or cancelled already, often a repeat of a full shipment; the second message appears once that shipment completed the order. Nothing was booked twice.
  • 422 this merchant ships complete orders only — would leave open: …. The tenant's partial_shipment setting refuses a shipment that leaves anything open. Ship the full order, or change the setting.
  • 400 positions[0]: unknown order_item_id. The shipment names a line by its position number or by the SKU. Send the line's id: items[].id in GET /v1/orders/{id}.
  • 422 no active reservations for order_ref '…' on commit. There is no hold under that reference: the checkout reserved nothing, reserved under another reference such as the cart's id, or the hold was already released, committed or expired.

Where to go next

Was this page helpful?