Ship, cancel and return an order
Orders books what happened to an order: a shipment, a cancelled line, a returned quantity. It moves no stock and no money. Stock belongs to Inventories, money to Payments, and nothing in those two apps listens to an order event (as of 2026-09). So after an order is placed, your integration is the glue: it calls Orders to book the fact, then calls Inventories, Promotions and Payments to act on it, and then tells Orders what the payment did. This guide walks the three situations that need that glue, shipping in parts, cancelling single lines and taking a return, and names the trap in each.
This is for developers and integrators building an ERP sync, a warehouse tool or a customer-service back office. You need a tenant with the Orders, Inventories and Payments apps (and Promotions, if the order used a promotion), an API key with the orders.*, inventories.* and payments.* scopes (and promotions.* for a promoted order; looking up topics has no documented scope yet, so a development key with * covers it; see which scope covers which app), a placed order with positions, and a payment created with an order_ref. The order_ref is a reference you choose and pass when you create the payment and when you reserve stock: Orders has no such field. Confirm invoice payments shows how a payment gets one. New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app and a test tenant. Every call goes through the API gateway with the same two headers. Make them from your backend:
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
The examples use curl, and a value in angle brackets is an id from your own data.
What is automatic and what is not
| You do this in Orders | Orders books | Nothing happens to |
|---|---|---|
| Ship | A shipment, a DEL- number, the open quantities | Stock (no commit), the carrier (no label) |
| Cancel | The cancellation, the order status | Stock (no release), the payment (no refund), a promotion (no release) |
| Complete a return | quantity_returned, and the refund amount it works out | Stock (no restock), the money (no refund), a promotion (no credit) |
| Refund in Payments | The payment becomes refunded | The order's payment_status (nobody tells Orders, as of 2026-09) |
Each step is separate because each can fail on its own, and because a merchant's policy differs: some return goods to stock, some do not. Drive the steps from a workflow that reacts to the order's event rather than from one request. This page owns what each booking does. Hand an order to the ERP owns the ERP side: its keys, external_ref, and which ERP event triggers which call.
1. Ship in parts
What the shipping costs is a different job (see Set up shipping rates). Here you book that goods left. Ask what is left to ship first:
curl -s "https://api.revenexx.com/v1/orders/<ORDER_ID>/shippable" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq '{shippable, blocked_reason, positions}'
Each position carries quantity, quantity_shipped, quantity_cancelled and quantity_open, where open is ordered minus shipped minus cancelled. The call answers 200 for every known order (an unknown order is a 404). A block is reported in shippable: false and blocked_reason (on hold, cancelled, completed, or nothing left), not thrown.
Book a part shipment by naming the positions and quantities:
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 '{
"positions": [ { "order_item_id": "<ORDER_ITEM_ID>", "quantity": 6 } ],
"carrier": "DHL",
"carrier_code": "dhl",
"tracking_code": "JJD0001234567"
}'
carrier_codeis the carrier'scodefromGET /v1/shipping/carriers, and it groups shipments by carrier.carrieris the name people and other systems read. Send both. The code is stored as given, not checked against the shipping App.- Omitting
positionsships every open position in full. Apositionslist that is empty is a400, and a quantity above what is open is a422("ship quantity … exceeds the remaining …"). - The answer is
201with theshipmentand the updatedorder. The first partial shipment moves aplacedorder toin_fulfillment, withfulfillment_status: partial.fulfillment_statusis derived from the positions and is never set by hand. Each position'sorder_item_idis in thepositionsof theshippableanswer above. - Shipping everything that is left makes
fulfillment_statusfulfilled. Whether the order then becomescompletedis the tenant'sauto_complete_onsetting:shipment(the default) completes it,paymentwaits until the order is paid,manualwaits for your call toPOST /v1/orders/{id}/complete. - A merchant can refuse part shipments. With the
partial_shipmentsetting atnever, a shipment that leaves anything open is a422with the codepartial_shipment_refused, andshippablereportscomplete_only: true. Cancelling the position you will not ship first still works. - Each shipment draws a delivery number (
DEL-000001). Send your ownnumberand a repeat books nothing: it is a409while the repeated quantity is still open, or a422when nothing it names is open any more. That is the simplest way to make a retry safe. - It prints no label and notifies nobody. The
order.shipment_createdevent is what your other systems react to.
order_ref (and is a 422 when nothing is reserved under it): there is no partial commit and no per-line id. An order converted from a cart can already hold one reservation for the whole order, with a time limit, when the tenant's convert_reserves_stock setting is request or require (it is never by default). The cart conversion holds it under the order_ref you passed to it, or under the cart id if you passed none, so check GET /v1/inventories/reservations?order_ref=<THAT_REFERENCE> first, and pass an order_ref when you convert. Committing that reference consumes the stock of every unshipped line, and once the expired hold has been swept the commit is a 422. Either commit it only on the final shipment, or release it and reserve per part at once (release and re-reserve are two calls, not one, so the stock is unheld in between). To ship in parts, reserve each part under its own reference from the start, for example ORD-1/1 and ORD-1/2, and commit the reference that shipped. A second reserve under the same reference is committed together with the first. See Stock.2. Cancel single lines
Cancelling the whole order only works while nothing has shipped:
curl -s -X POST "https://api.revenexx.com/v1/orders/<ORDER_ID>/cancel" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer withdrew", "cancelled_by": "service" }'
Once anything has shipped, this is a 422 ("order has shipped positions — cancel the open quantities via /items/cancel instead"). Cancel the open quantities by position instead:
curl -s -X POST "https://api.revenexx.com/v1/orders/<ORDER_ID>/items/cancel" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "positions": [ { "order_item_id": "<ORDER_ITEM_ID>", "quantity": 4 } ], "reason": "Out of stock" }'
positionsis required, and a quantity above what is still open is a422. The order keeps its status while anything is open.- Shipped plus cancelled is how an order ends. If the rest is shipped after a part is cancelled,
fulfillment_statusbecomesfulfilledand the order completes asauto_complete_onsays. If every position ends up fully cancelled, the order moves tocancelled. - That last case fires no
order.cancelledevent. Onlyorder.item_cancelledfires, once per position, withquantity_remainingafter the booking. A consumer that waits fororder.cancelledalone misses an order cancelled line by line. - A tenant can require a reason (
cancel_requires_reason), and can block cancelling while an order is on hold (on_hold_blocks).
Cancelling refunds nothing and puts nothing back. Refund a captured payment in full only when the whole payment should go back: on a part-shipped order that also returns the money for what shipped, and there is no partial refund (step 5). For a cancelled order, release the stock you reserved (POST /v1/inventories/release with the order_ref, which is idempotent and covers the whole reference, so to cancel one line release it and reserve the rest again). If the payment was only authorized, cancel it with POST /v1/payments/{id}/cancel. If it was captured, refund it (step 5) and, since nothing shipped on a cancelled order, record refunded (step 6). Orders has no payment status for a cancelled authorization (as of 2026-10), so do not record refunded for one: leave payment_status as it is, or set the one of the seven statuses your process needs.
A promotion the order used is not released either. When the whole order is cancelled, call POST /v1/promotions/release with the order_id: that reverses everything the order spent, so a single-use code is usable again and the budget is credited. Do not call it after cancelling only some lines, because it would give back the whole order's codes and budget. Releasing is your job, and the guide Run promotions and voucher codes shows the call in context.
3. Take a return
A return is a sub-resource of the order. There is no /v1/returns and no list across orders: keep the return id from the register answer, or read the order's returns from GET /v1/orders/{id}. The steps, and what each books, are on Returns. The short version:
RETURN=$(curl -s -X POST "https://api.revenexx.com/v1/orders/<ORDER_ID>/return" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Wrong variant delivered",
"positions": [ { "order_item_id": "<ORDER_ITEM_ID>", "quantity": 2, "restock": true } ]
}')
RETURN_ID=$(printf '%s' "$RETURN" | jq -r .id)
curl -s -X POST "https://api.revenexx.com/v1/orders/<ORDER_ID>/returns/$RETURN_ID/complete" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "resolution": "refund" }' > "complete-$RETURN_ID.json"
jq '{status, restock, refund_total}' "complete-$RETURN_ID.json"
- Returns are quantity-based and partial. Omitting
positionsreturns everything still returnable. A quantity above what shipped and has not come back is a422. - Two open returns can claim the same quantity. What is returned is booked only when a return completes, so the quantity of a return that is still open is not held back. Check your own records before you register a second return on the same position (as of 2026-09).
resolutionis one ofrefund,partial_refund,replacement,repair,store_credit. It is a label: it moves no money. Reject withwear_and_tearornot_returnable, or a reason of your own, and nothing is booked.- Completing books
quantity_returnedand states the refund, inrefund_totalanddiscount_returnedon the answer and onorder.return_completed, worked out from what the buyer paid for the returned goods, net of tax and shipping. It moves no money. It puts nothing back in stock. Therestockarray names the goods flagged for restocking. A position is flagged only when you setrestock: trueon it, or at the top level of the return: otherwiserestockis empty. Flag the physical positions one by one: a top-level flag with nopositionsalso flags a discount line, which has no goods to put back. - A promotion the order used is not credited back. Call
POST /v1/promotions/returnswith the order'sorder_idand the returnedlines(orall: true). Pass thetermsat commit time if you expect partial returns, as the promotions guide explains.
4. Put the stock back
The restock array of the completed return is what to hand to Inventories. If it is empty, nothing was flagged: skip this step. Send it yourself:
jq '{items: [.restock[] | select(.sku or .product_id) | {sku, product_id, quantity}], order_ref: "<ORDER_REF>", reason: "return <RETURN_NUMBER>"}' "complete-$RETURN_ID.json" \
| curl -s -X POST https://api.revenexx.com/v1/inventories/restock \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" -d @-
The call adds each quantity to its stock row (created if missing) and writes one movement per item, and answers 200 with { stock, restocked }. An empty items list is a 400.
- Check
restockedin the answer. It isfalse, with nothing booked, when the tenant'srestock_on_return_defaultsetting is off or the call says"restock": false. The setting is on by default (as of 2026-10), so a merchant who does not resell returns has to turn it off, or passrestock: false. - It is not idempotent.
order_refis a label, not a key. Calling it twice books the quantity twice. Record, on your side, that a return has been restocked, and do it before you retry anything. - It checks nothing against the order. It does not compare the quantity with what shipped or came back, and it touches no reservation.
5. Refund the payment
After a return, do this step and step 6 only when everything that shipped has come back and no return is open. The checklist is at the end of this page. A refund returns the whole payment.
curl -s -X POST "https://api.revenexx.com/v1/payments/<PAYMENT_ID>/refund" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Return RET-000001" }'
Find the payment with GET /v1/payments?order_ref=<ORDER_REF>. Payments only knows an order_ref if whoever created the payment passed one, so set it when you create payments.
amount is ignored without an error. The call reads only reason. A smaller amount in the body does not produce a partial refund: the payment is refunded in full. A partial_refund return resolution does not change that, it only labels the return. There is no partial refund in Payments (as of 2026-09).- Only a
capturedpayment can be refunded. Any other status is a400, including a payment that is alreadyrefunded, so a repeat is refused. An authorized payment is cancelled instead. The refund can also have happened already at the provider, which reports back and moves the payment torefunded. - Check the answer's
statusbefore you go on. A refund the provider refuses comes back as200with the payment'sstatusset tofailed, and afailedpayment can no longer be refunded through the API. Only whenstatusisrefundedhas the money been sent. Do not recordrefundedon the order for anything else. - The refund window is the tenant's
refund_window_days(180 by default, as of 2026-10). A payment captured longer ago is a422. - A card or wallet payment is refunded at the provider. An invoice or prepayment is only recorded as
refunded, and the merchant pays the money back by their own means. If the ERP is the one that pays an invoice back, it reports that with arefundedevent instead: see Confirm invoice payments from your ERP. - For a partial money-back there is nothing to call. What to do is a business decision the platform does not make for you: refund the whole payment and take a new payment for what the buyer keeps, or settle the difference outside the platform. Neither is documented as a pattern (as of 2026-09).
payment.refundedfires once per payment and carriespayment_id,order_ref,amountandrefunded_at. A failure to publish it does not fail the refund.
6. Tell Orders what the payment did
Payments does not update the order. Record the new state yourself, and only after a refund whose answer says status: refunded and that returned the whole payment (see the whole return, in order). There is no partially_refunded 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": "refunded", "payment_id": "<PAYMENT_ID>" }'
status is one of open, pending, authorized, paid, partially_paid, refunded, failed. The call takes no money and refunds none. It sets the order's payment_status, and publishes order.payment_status_changed. partially_paid exists for an order you record as only partly settled, but Payments has no partial payment to back it (as of 2026-09).
7. Events to drive it
Subscribe with the exact topic strings your tenant's catalog lists (GET /v1/events/catalog; see Event types). The order topics are order.shipment_created, order.item_cancelled, order.cancelled, order.completed, order.return_registered, order.return_received, order.return_completed, order.return_rejected and order.payment_status_changed, and payment.refunded from Payments. order.return_completed is the one that starts the restock and the refund. It carries return_id, resolution, restock (product_id, sku, quantity), refund_total and discount_returned. Subscribe to these topics or to the order history feed (order_event.created), never both. A subject with an underscore is valid since 2026-09-29, and step 10 of Hand an order to the ERP shows one workflow covering several order topics with a wildcard.
The whole return, in order
complete the return POST /v1/orders/{id}/returns/{rid}/complete books the quantity, states the refund
restock the goods POST /v1/inventories/restock only if restocked, once per return
refund the payment POST /v1/payments/{id}/refund only when the whole payment goes back, once
record it POST /v1/orders/{id}/payment-status only after a refund: status refunded
Do the last two after a return only when everything that shipped has come back and no return is open (the same condition as at the top of step 5). Read the order with GET /v1/orders/<ORDER_ID>. For every position, quantity_returned must equal quantity_shipped, nothing may still be open (quantity minus quantity_shipped minus quantity_cancelled is 0), and no entry in returns may be registered or received. A discount line, if the order has one, is a position like any other: include it when you ship or return with explicit positions, or omit positions. Do not test refund_total against the payment: it is the net goods share, it leaves out tax and shipping, and a caller can override it. The refund is the whole payment, so it also gives back shipping and tax. After a partial return it would give back the money for goods the buyer kept, and recording refunded would tell every consumer of the order that it was refunded in full. After a partial return, stop after the restock, leave the order's payment_status as it is, and settle the difference as described in step 5.
Make each step safe to repeat on your side, because three of the four are not:
| Step | A repeat |
|---|---|
| Complete the return | 422, a completed return cannot be completed again |
| Restock | Books the quantity again. Keep your own record |
| Refund | 400, the payment is already refunded |
| Payment status | Sets the same status again |
Troubleshooting
- A shipment answers
422"order is on hold". Unhold it (POST /v1/orders/{id}/unhold), or wait for the merchant to. 422with the codepartial_shipment_refused. The tenant ships orders complete only. Ship everything open, or cancel the positions you will not ship first.fulfillment_statusisfulfilledbut the order is notcompleted.auto_complete_onispaymentand the order is not paid yet, or it ismanualand nobody calledcomplete.- Whole-order cancel answers
422about shipped positions. Something has shipped. Cancel the open quantities withitems/cancel. - No
order.cancelledevent came. The order was cancelled line by line, which firesorder.item_cancelledonly. 422registering a return: "nothing on this order is returnable". Nothing has shipped, or everything has come back. A cancelled order cannot take a return.restocked: falseand the stock did not move. The tenant'srestock_on_return_defaultis off, or you sentrestock: false.- The stock is too high after a retry. A repeated restock booked twice. Correct it with a stock adjustment (
POST /v1/inventories/adjust, a signed delta, with areasonunless the tenant has switched that requirement off) and keep a record of restocked returns from now on. - The refund was for the whole payment though you sent an
amount. Payments ignores anamount. See step 5. 400on refund: "cannot refund a payment in status …". It is notcaptured: already refunded (possibly by the provider), still authorized, or failed.422on refund about the window. The payment was captured longer ago thanrefund_window_days.502on refund. The call to the payment provider failed. The payment stayscaptured, so a retry asks the provider again. Read the payment first: if a provider callback has already moved it torefunded, a retry is a400.- A payment lookup by order finds nothing. The payment was created without an
order_ref. 401or403. The tenant slug or key is wrong, or the key has no access to Orders, Inventories or Payments. See API keys and Errors.
Where to go next
- Orders — shipping, cancelling and the statuses, route by route.
- Returns — the four return steps and what each books.
- Stock — reservations, commit, release and restock.
- Payment lifecycle — capture, cancel and refund.
- Hand an order to the ERP — acknowledging, holding and shipping from an ERP.
- Run promotions and voucher codes — release or credit back a promotion when an order is cancelled or returned.
- Set up shipping rates — what a shipment costs the buyer, quoted before the order.
- Confirm invoice payments from your ERP — recording a payment that arrived outside the platform.