Returns and Exchanges
A return is an RMA against a subset of an order's line items. It moves
through an explicit state machine, and the money only moves at the end —
either as a refund on complete, or as the
price difference on an exchange.
Lifecycle
stateDiagram-v2
[*] --> requested: POST /orders/{id}/returns
requested --> approved: approve
requested --> rejected: reject
requested --> cancelled: cancel
approved --> received: receive
approved --> cancelled: cancel
approved --> completed: exchange
received --> completed: complete
received --> completed: exchange
completed --> [*]
rejected --> [*]
cancelled --> [*]
Only requested, approved, received, and completed hold a returned quantity
against the order. Rejecting or cancelling releases it, so the buyer can
open a fresh return for the same line later.
Open a return
POST /api/v1/orders/{id}/returns
— scope order_returns:write. Pass the
line items and quantities coming back. reason
(max 1000 chars) and restock are optional.
curl -X POST https://api.usethrottle.dev/api/v1/orders/ord_9f2a.../returns \
-H "X-API-Key: $THROTTLE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "lineItemId": "li_1c4e...", "quantity": 1 }
],
"reason": "Arrived damaged",
"restock": false
}'
Throttle computes refundAmount at
creation time and returns it on the object, so you can show the buyer the
exact figure before anyone approves anything.
{
"data": {
"id": "ret_74bd...",
"orderId": "ord_9f2a...",
"status": "requested",
"reason": "Arrived damaged",
"restock": false,
"refundAmount": 3218,
"refundPaymentId": null,
"notes": null,
"createdAt": "2026-08-09T14:02:11.000Z",
"updatedAt": "2026-08-09T14:02:11.000Z",
"items": [
{ "lineItemId": "li_1c4e...", "quantity": 1, "amount": 3218 }
]
}
}
List an order's returns with GET /api/v1/orders/{id}/returns
and fetch one with GET /api/v1/returns/{id} — both
scope order_returns:read.
How refundAmount is computed
Throttle refunds what the buyer actually paid for those units, not the list price. Per line that is:
- the line subtotal,
- plus the tax charged on it,
- minus any discount applied directly to that line,
- minus that line's proportional share of any order-level discount.
The result is prorated across the returned quantity and telescopes exactly across repeated partial returns — returning 1 unit three times refunds the same total as returning 3 units at once, with no rounding drift.
Transitions
POST /api/v1/returns/{id}/transition
with an action. Anything not listed here
is a 409 invalid_return_state.
curl -X POST https://api.usethrottle.dev/api/v1/returns/ret_74bd.../transition \
-H "X-API-Key: $THROTTLE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "approve" }' | Action | From | To | Effect |
|---|---|---|---|
approve | requested | approved | Accept the RMA. Nothing moves money yet. |
reject | requested | rejected | Decline the RMA. Frees the reserved quantity. |
receive | approved | received | Goods are physically back. |
complete | received | completed | Issues the refund. Terminal. |
cancel | requested or approved | cancelled | Abandon the RMA. Frees the reserved quantity. |
The refund on completion
Completing a return resolves the order's payment in captured or partially_refunded status and refunds refundAmount against it, stamping refundPaymentId on the return. The refund
carries the reason return:{returnId}.
completed and no refund is issued. The
server logs a warning, but the API returns 200. If you rely on a return
implying money moved, assert on refundPaymentId being non-null rather
than on the status.
If the processor declines the refund, the transition fails with 402 refund_failed and the return stays in received, so you can retry.
Exchanges
POST /api/v1/returns/{id}/exchange
swaps the returned goods for replacements instead of refunding. The return
must be in approved or received; it is an alternative route to completed that settles a difference
rather than issuing the standard refund.
curl -X POST https://api.usethrottle.dev/api/v1/returns/ret_74bd.../exchange \
-H "X-API-Key: $THROTTLE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"referenceId": "SKU-WIDGET-LG",
"name": "Premium Widget (Large)",
"unitPrice": 3499,
"quantity": 1
}
]
}' Throttle then:
-
creates a replacement order carrying
metadata.exchange_for_returnpointing back at the return, -
computes
delta = replacementTotal − refundAmount, -
charges the customer's stored card when
delta > 0, or refunds the difference when it is negative, -
marks the return
completedand stamps the replacement order id.
{
"data": {
"id": "ret_74bd...",
"status": "completed",
"refundAmount": 3218,
"exchange": {
"replacementOrderId": "ord_51aa...",
"replacementTotal": 3499,
"delta": 281,
"chargedPaymentId": "pay_88ce...",
"refundedPaymentId": null
}
}
} delta compares
against the return's paid value (which does include tax), an
even-swap exchange produces a small refund roughly equal to the returned
line's tax. The difference favours the buyer, never the merchant, but
budget for it if you run high exchange volume.
A delta > 0 needs someone to bill: if
the original order has no customer attached you get 409 no_customer, and a declined card is 402 payment_failed. In both cases no
replacement order is left half-settled.
Webhooks
Opening a return emits return.created.
Every transition and every exchange emits return.updated, carrying status and previousStatus so you can react to a
specific edge rather than polling. Subscribing requires order_returns:read. See Webhooks.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
no_items | 400 | The request carried an empty `items` array. |
invalid_line_item | 400 | A `lineItemId` does not belong to that order. |
invalid_quantity | 400 | Quantity is not a positive integer. |
quantity_exceeds_returnable | 400 | More than the remaining returnable quantity for that line. |
invalid_action | 400 | Action is not one of the five listed above. |
invalid_return_state | 409 | The action is not legal from the return’s current status. |
refund_failed | 402 | The refund was attempted on completion and the processor declined. |
no_customer | 409 | An exchange needs to charge more, but the order has no customer to bill. |
payment_failed | 402 | The exchange difference charge was declined. |
Full list and envelope shape on Error codes.