Returns

A return on revenexx is a sub-resource of an order, not a top-level entity — register, receive, then complete or reject, with only the complete step booking anything.
There is no /v1/returns. A return exists only under the order it belongs to. Every step on this page is under /v1/orders/{id}/…. The one list across orders is GET /v1/orders/returns, described in Listing returns across orders.

The four steps

registeredreceivedcompletedrejectedPOST /v1/orders/{id}/returnPOST …/receivePOST …/completePOST …/completePOST …/rejectan orderskippablebooks quantity_returnedbooks nothingyour backend: each call, in order

Only the complete step moves any quantity. Registering and receiving are announcements.

Before you register, ask what may still come back: GET /v1/orders/{id}/returnable. The {id} is the order uuid, not the order number (look a number up with GET /v1/orders?number=…). It answers per position what shipped, what already came back, what open returns claim and what is left, plus whether a return would be accepted at all. It changes nothing and books nothing. It runs the same code as POST /v1/orders/{id}/return, so the answer and what that route accepts cannot drift. A completed order still answers, because goods can come back after the sale. A discount placed as a position of its own is money, not goods, and is not listed.

FieldMeaning
returnableWhether a return would be accepted right now.
blocked_reasonNull when returnable is true; otherwise the words the return route would refuse with.
positions[]Every goods position in position order, including those with nothing returnable.
quantity_shipped, quantity_returned, quantity_claimedWhat went out, what came back, what open returns already claim.
quantity_returnableShipped − returned − claimed: the most a return may take on that position.
order_item_idCopy it into positions[] when you register; do not construct it.

returnable_positions counts the lines a return dialog would offer. returnable_quantity is a headline figure, not a total to act on.

1. Register the case

Request
curl -X POST "https://api.revenexx.com/v1/orders/{id}/return" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{
        "reason": "Wrong variant delivered",
        "reason_code": "wrong_item",
        "positions": [ { "order_item_id": "5b0c…", "quantity": 2, "restock": true } ]
      }'

Answers 201 with the stored return — the row is created, not an order updated. It draws a return number from the tenant's return range.

Detail
GuardsPositions are guarded against what actually shipped and has not already come back, so a return cannot exceed the goods that left.
restockPer position: whether the item is expected to be sellable again. Recorded now, acted on only when the return completes.
reason_codeOptional. One of the codes this tenant keeps, so a returns report can group by cause. A code the tenant does not keep is a 400 that names the codes still open to a return. A code that is switched off (active: false) is a 409 return_reason_inactive.
Omitting positionsRegisters everything still returnable, in full — the "the customer sent the whole delivery back" case.
BooksNothing. quantity_returned stays where it is and the order does not move.
Allowed onA completed order. Refused on a cancelled one.

Return reasons

GET /v1/orders/return-reasons lists the codes this tenant keeps. To find one by its code, use GET /v1/orders/return-reasons?code=damaged. GET /v1/orders/return-reasons/{id} reads one by its row id, not its code. A tenant starts with nine standard reasons: damaged, defective, wrong_item, wrong_quantity, not_as_described, late_delivery, ordered_in_error, no_longer_needed and other. The list belongs to the tenant, so read the codes from the route instead of hardcoding them.

active filters the list too (exact match). A switched-off reason (active: false) stays in the list and in every read, so returns already filed under it keep their code and title. To see the codes a new return may use, filter with ?active=true. No route adds, renames or switches off a reason yet, and seeding never switches one back on.

2. Receive the goods

Request
curl -X POST "https://api.revenexx.com/v1/orders/{id}/returns/{rid}/receive" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{}'

The goods-in scan: the parcel is physically back and nobody has decided yet whether the customer gets their money. It moves the return from registered to received and stamps received_at, which is what separates announced from here on a returns worklist.

It books nothing, so a return that arrives damaged can still be rejected afterwards.

Only a registered return can be received; a second call, or one against a settled return, is a 422. This step is skippable — a return may be completed straight from registered where a merchant does not scan goods in.

3a. Complete — accept it

Request
curl -X POST "https://api.revenexx.com/v1/orders/{id}/returns/{rid}/complete" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"resolution":"refund"}'

The only step that moves quantity_returned. The goods are taken back on the order's books, completed_at is stamped by the server, and the settlement is recorded.

resolution is validated against the settlement words this app publishes:

text
refund    partial_refund    replacement    repair    store_credit

Read the live list from GET /v1/orders/vocabularies/return-resolutions. Anything else is refused rather than stored as a word no reader knows, and it is checked before the positions are booked, so a rejected value leaves nothing behind.

It does not refund money and does not put stock back. The response's restock array names what to hand to POST /v1/inventories/restock; payment travels through POST /v1/orders/{id}/payment-status.

Once completed the return is final — receive, complete and reject all refuse afterwards.

3b. Reject — close it against the customer

Request
curl -X POST "https://api.revenexx.com/v1/orders/{id}/returns/{rid}/reject" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"resolution":"wear_and_tear"}'

The goods came back used, outside the window, or were never covered. The return moves to rejected and rejected_at is stamped.

The order is untouched — the quantities still count as shipped and not returned, which is the point: a rejected return must leave the books exactly as they were. Nothing is booked onto the positions.

resolution is validated against the refusal words wear_and_tear and not_returnable. reason stays free text — a sentence about this one return rather than a value out of a set — and is what is stored when no resolution is named.

Rejection is final, and it says nothing about where the physical goods go.

The return row

FieldMeaning
numberDrawn from the return range.
order_idThe order it belongs to.
statusregistered, received, completed, rejected.
positionsWhat is coming back, with quantities and the restock flag.
reasonFree text.
reason_codeOne of the tenant's return-reason codes, or null.
resolutionOne of the published settlement or refusal words.
registered_at, received_at, completed_at, rejected_atServer-stamped as each step runs.

Listing returns across orders

GET /v1/orders/returns lists every return of the tenant, whichever order it belongs to, in every state (settled and rejected ones included). It is a merchant and operator route: a buyer's own call gets 403 buyer_not_permitted (as of 2026-11). Without order, rows come oldest first by created_at (the default is created_at.asc); a sweep sends its own order, as below.

Settling a return writes the return and the order's positions, but never the order row. An order's updated_at therefore does not move when a return settles, and a sweep over orders cannot see it. Sweep the returns instead:

Request
curl "https://api.revenexx.com/v1/orders/returns?status=completed&completed_at=gte.<LAST_RUN>&order=completed_at.asc" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..."

<LAST_RUN> is the moment your previous sweep started, as an ISO 8601 moment with its offset (Z, or %2B for a + offset).

The answer is one page. The rows are in items. page holds limit, offset, total, returned (the rows in this page) and hasMore. filter echoes the filters as they arrived, as strings. Each row names its order_id (the order the goods came from), its positions (the goods that came back), its resolution, its refund_mode (proportional or recomputed) and its refund_total (what is owed back, in the order currency).

To read a whole window, send offset=0, then add returned to offset for each next page. Stop when page.hasMore is false.

Filters are query parameters:

  • Exact match on the column it names: id, order_id, number, status, reason_code, reason, resolution, refund_mode, refund_total, discount_returned, external_id, organization_id.
  • Timestamps: one bound or one exact moment per column. The columns are registered_at, received_at, completed_at, rejected_at, created_at, updated_at and source_synced_at. A bound is gt., gte., lt. or lte. with an ISO 8601 moment that carries its offset (Z or ±hh:mm), or a calendar day read as UTC midnight. An exact moment is the bare value with no operator (completed_at=2026-10-01T09:12:44.123Z); it matches only a row stamped at exactly that moment, so a sweep uses bounds. Sending the same column twice is 400 invalid_value.
  • Paging and order: limit (default 50, maximum 200; a larger value is clamped to 200), offset, and order (column, column.asc or column.desc; a bare column sorts ascending).

The JSON columns (positions, external_refs, source_data, metadata) match only a whole JSON document, serialized exactly as stored; anything that is not JSON is 400 invalid_value. They are not useful for a sweep.

The route refuses a filter it cannot apply with 400:

  • unknown_filter: the key names no column.
  • unsupported_filter_operator: an operator on a column that is not a timestamp (a value starting eq., in., is., like. and the like is read as one, even on a text column), or an operator other than gt., gte., lt. or lte. on a timestamp column (for example eq.).
  • invalid_value: a bound that is not an ISO 8601 moment, or a key sent twice.

The sweep. Start each window before the last one ended, so the windows overlap. completed_at is the app's clock at the completion, taken just before the write that stores it, so a completion can land after a read that already covered its time. No upper bound on that gap is promised: it is however long that one write takes. Choose an overlap far longer than a write (minutes, not milliseconds), and skip rows whose id you have already handled: a row that appears in two windows is the same return.

The overlapping sweep is best effort: with no bound on that gap, an overlap makes a missed return unlikely, not impossible, and nothing tells you that one was missed. Back it with a reconciliation run on a slower clock, for example once a night: read the completed returns of a much longer window (completed_at=gte.<A_WEEK_AGO>), and handle every id you have not handled yet. A return the fast sweep missed turns up there, late but not lost. To check without reading every row, send limit=1 for the same window and compare page.total with the number of returns you handled in it.

This route only reads. It books nothing and moves nothing.

The orchestration you own

A complete return, end to end, is four calls in three apps:

text
POST /v1/orders/{id}/returns/{rid}/complete   → books quantity_returned, answers `restock`
POST /v1/inventories/restock                  → puts the sellable goods back
POST /v1/payments/{id}/refund                 → moves the money
POST /v1/orders/{id}/payment-status           → records that it moved

Each is separate because each fails independently, and a merchant's policy on which of them happens differs. Drive them from a workflow reacting to the return's event, not from a single request.

Where to go next

Was this page helpful?