Ship, cancel and return an order

Ship a placed order in parts, cancel single lines and take a return, and do the stock, promotion and money steps that Orders never does for you, with the trap in each step.

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:

Environment
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 OrdersOrders booksNothing happens to
ShipA shipment, a DEL- number, the open quantitiesStock (no commit), the carrier (no label)
CancelThe cancellation, the order statusStock (no release), the payment (no refund), a promotion (no release)
Complete a returnquantity_returned, and the refund amount it works outStock (no restock), the money (no refund), a promotion (no credit)
Refund in PaymentsThe payment becomes refundedThe 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:

What is open
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:

Ship part of an order
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_code is the carrier's code from GET /v1/shipping/carriers, and it groups shipments by carrier. carrier is the name people and other systems read. Send both. The code is stored as given, not checked against the shipping App.
  • Omitting positions ships every open position in full. A positions list that is empty is a 400, and a quantity above what is open is a 422 ("ship quantity … exceeds the remaining …").
  • The answer is 201 with the shipment and the updated order. The first partial shipment moves a placed order to in_fulfillment, with fulfillment_status: partial. fulfillment_status is derived from the positions and is never set by hand. Each position's order_item_id is in the positions of the shippable answer above.
  • Shipping everything that is left makes fulfillment_status fulfilled. Whether the order then becomes completed is the tenant's auto_complete_on setting: shipment (the default) completes it, payment waits until the order is paid, manual waits for your call to POST /v1/orders/{id}/complete.
  • A merchant can refuse part shipments. With the partial_shipment setting at never, a shipment that leaves anything open is a 422 with the code partial_shipment_refused, and shippable reports complete_only: true. Cancelling the position you will not ship first still works.
  • Each shipment draws a delivery number (DEL-000001). Send your own number and a repeat books nothing: it is a 409 while the repeated quantity is still open, or a 422 when 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_created event is what your other systems react to.
Shipping does not commit stock. If you reserve stock in Inventories, committing it is your call, and a commit covers the wholeorder_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:

Cancel the order
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:

Cancel part of an order
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" }'
  • positions is required, and a quantity above what is still open is a 422. 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_status becomes fulfilled and the order completes as auto_complete_on says. If every position ends up fully cancelled, the order moves to cancelled.
  • That last case fires no order.cancelled event. Only order.item_cancelled fires, once per position, with quantity_remaining after the booking. A consumer that waits for order.cancelled alone 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:

Register and complete a return
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 positions returns everything still returnable. A quantity above what shipped and has not come back is a 422.
  • 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).
  • resolution is one of refund, partial_refund, replacement, repair, store_credit. It is a label: it moves no money. Reject with wear_and_tear or not_returnable, or a reason of your own, and nothing is booked.
  • Completing books quantity_returned and states the refund, in refund_total and discount_returned on the answer and on order.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. The restock array names the goods flagged for restocking. A position is flagged only when you set restock: true on it, or at the top level of the return: otherwise restock is empty. Flag the physical positions one by one: a top-level flag with no positions also flags a discount line, which has no goods to put back.
  • A promotion the order used is not credited back. Call POST /v1/promotions/returns with the order's order_id and the returned lines (or all: true). Pass the terms at 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:

Restock a return
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 restocked in the answer. It is false, with nothing booked, when the tenant's restock_on_return_default setting 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 pass restock: false.
  • It is not idempotent. order_ref is 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.

Refund
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.

A refund is all or nothing, and an 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 captured payment can be refunded. Any other status is a 400, including a payment that is already refunded, 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 to refunded.
  • Check the answer's status before you go on. A refund the provider refuses comes back as 200 with the payment's status set to failed, and a failed payment can no longer be refunded through the API. Only when status is refunded has the money been sent. Do not record refunded on 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 a 422.
  • 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 a refunded event 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.refunded fires once per payment and carries payment_id, order_ref, amount and refunded_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:

Record the refund on the order
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

text
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:

StepA repeat
Complete the return422, a completed return cannot be completed again
RestockBooks the quantity again. Keep your own record
Refund400, the payment is already refunded
Payment statusSets 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.
  • 422 with the code partial_shipment_refused. The tenant ships orders complete only. Ship everything open, or cancel the positions you will not ship first.
  • fulfillment_status is fulfilled but the order is not completed. auto_complete_on is payment and the order is not paid yet, or it is manual and nobody called complete.
  • Whole-order cancel answers 422 about shipped positions. Something has shipped. Cancel the open quantities with items/cancel.
  • No order.cancelled event came. The order was cancelled line by line, which fires order.item_cancelled only.
  • 422 registering a return: "nothing on this order is returnable". Nothing has shipped, or everything has come back. A cancelled order cannot take a return.
  • restocked: false and the stock did not move. The tenant's restock_on_return_default is off, or you sent restock: 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 a reason unless 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 an amount. See step 5.
  • 400 on refund: "cannot refund a payment in status …". It is not captured: already refunded (possibly by the provider), still authorized, or failed.
  • 422 on refund about the window. The payment was captured longer ago than refund_window_days.
  • 502 on refund. The call to the payment provider failed. The payment stays captured, so a retry asks the provider again. Read the payment first: if a provider callback has already moved it to refunded, a retry is a 400.
  • A payment lookup by order finds nothing. The payment was created without an order_ref.
  • 401 or 403. 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

Was this page helpful?