Orders

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 --> [*]
A return from request to a terminal state.

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.

Open a return
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.

200 response
{
  "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.

Approve a return
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" }'
ActionFromToEffect
approverequestedapprovedAccept the RMA. Nothing moves money yet.
rejectrequestedrejectedDecline the RMA. Frees the reserved quantity.
receiveapprovedreceivedGoods are physically back.
completereceivedcompletedIssues the refund. Terminal.
cancelrequested or approvedcancelledAbandon 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}.

Completion still succeeds when there is nothing to refund
If the order has no captured payment — an unpaid Net-N invoice, say, or an order captured outside Throttle — the return still moves to 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.

Exchange for a different variant
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:

  1. creates a replacement order carrying metadata.exchange_for_return pointing back at the return,
  2. computes delta = replacementTotal − refundAmount,
  3. charges the customer's stored card when delta > 0, or refunds the difference when it is negative,
  4. marks the return completed and stamps the replacement order id.
Exchange response
{
  "data": {
    "id": "ret_74bd...",
    "status": "completed",
    "refundAmount": 3218,
    "exchange": {
      "replacementOrderId": "ord_51aa...",
      "replacementTotal": 3499,
      "delta": 281,
      "chargedPaymentId": "pay_88ce...",
      "refundedPaymentId": null
    }
  }
}
Replacement items are not taxed
Replacements are charged at list price — this path has no address-aware tax quote yet. Because 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

CodeHTTPMeaning
no_items400The request carried an empty `items` array.
invalid_line_item400A `lineItemId` does not belong to that order.
invalid_quantity400Quantity is not a positive integer.
quantity_exceeds_returnable400More than the remaining returnable quantity for that line.
invalid_action400Action is not one of the five listed above.
invalid_return_state409The action is not legal from the return’s current status.
refund_failed402The refund was attempted on completion and the processor declined.
no_customer409An exchange needs to charge more, but the order has no customer to bill.
payment_failed402The exchange difference charge was declined.

Full list and envelope shape on Error codes.