Handle returns, exchanges and claims
Take the returns and exchanges shoppers ask for on store orders, and open claims, then approve, receive and refund each one with the Commerce API.
A return brings units of a store order back for a refund. An exchange brings them back for other variants, and refunds the difference when the new ones cost less. A claim settles units that were damaged or lost after they shipped, with a refund or a replacement, and nothing comes back.
Shoppers ask for returns and exchanges on your storefront, through the Store API. You open, approve, receive and refund them with the Commerce API. The routes are under https://api.coritan.com/api/v1/orgs/{org_slug}/commerce/returns and /commerce/return-reasons, and the API reference lists them under Commerce.
Before you begin
Section titled Before you begin- Commerce must be on for your organization, as Sell with the Commerce API describes.
- Reading returns and return reasons takes an organization API key with the
commerce.orders:readscope, or a member's token with the Tier 1 support role or above. - Opening, approving, declining, receiving and cancelling returns, and changing the reasons, take
commerce.returns:write, or the Tier 2 support role or above. A key with it also needscommerce.orders:readto read returns. - Refunding a return takes
commerce.refunds:write, or the Billing role or above. Scopes and roles lists every scope.
How a return moves
Section titled How a return moveskind |
What comes back | How it is settled |
|---|---|---|
return |
The units | A refund of what they are worth, less any restocking fee and shipping deduction |
exchange |
The units | Other variants on an exchange order, and a refund of any difference |
claim |
Nothing | A refund, or the same variants sent again, as its claim_type says |
A return the shopper asks for starts requested. One you open, and every claim, starts approved.
| Status | What it means |
|---|---|
requested |
The shopper asked. It waits for you to approve or decline it. |
approved |
You agreed, and the goods can come back. |
rejected |
You declined it, and rejection_reason says why. |
received |
The goods are back. |
refunded |
We refunded it, and refund_id names the refund. |
completed |
It was settled with nothing to refund: an exchange with no difference to give back, fees that took the whole value, or a replaced claim. |
canceled |
You cancelled it before the goods were back, or a claim before it was settled. |
Units are worth their share of what is left to refund on their order item, so discounts and earlier refunds count. Amounts are in minor units of the return's currency_code. The shopper sees each step on their order's timeline, and you see it on yours.
What can come back
Section titled What can come back- Only units that shipped, and only as many as are not already returned, refunded, or named by another open return or a claim. A gift card never comes back: refund it on the order while it is unspent.
- A shopper asks within the store's returns window:
returns_window_daysdays from when the item was delivered, or shipped when no delivery is recorded. When it arrived in more than one shipment, the latest one counts. You can open a return yourself at any time. - An order takes up to 50 returns, whatever their status. A cancelled order takes none, and neither does an exchange order.
- A return names up to 100 order items, each once, and an exchange up to 50 new variants.
Set the returns window and the reasons
Section titled Set the returns window and the reasonsThe returns window is 30 days unless you set it. Set it with PATCH /commerce/store, which takes commerce.store:write or the Admin role:
curl -X PATCH "https://api.coritan.com/api/v1/orgs/acme/commerce/store" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"settings": {"returns_window_days": 14}}'
The window is a whole number of days. 0 stops shoppers from asking, and null brings back the 30 days. Change the store's settings lists the other settings.
Return reasons are what a shopper picks from when they say why. Add one with POST /commerce/return-reasons:
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/return-reasons" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"code": "too_small", "label": "It is too small", "description": "For clothes and shoes that do not fit.", "rank": 10}'
| Field | What it holds |
|---|---|
code |
1 to 64 lowercase letters, digits, hyphens or underscores, starting with a letter or digit. It never changes. |
label |
What the shopper reads, up to 255 characters. |
description |
More about it, up to 500 characters. |
rank |
Its place in the list, from 0 to 100,000, lowest first. It is 0 unless you set it. |
is_disabled |
true hides it from shoppers. |
The answer is 201 with the return_reason.
GET /commerce/return-reasonslists every reason in the order shoppers see them, disabled ones included. Shoppers see only the enabled ones.PATCH /commerce/return-reasons/{reason_id}changes thelabel,description,rankoris_disabled. To use another code, add a new reason and disable the old one.DELETE /commerce/return-reasons/{reason_id}deletes a reason that no return names. We keep a reason that a return names and disable it instead, and the answer saysdeleted: false.
A shopper's item may name the code of an enabled reason in its reason_code, and an item of a return you open may name any of the store's reasons. Another code answers 422.
Open a return yourself
Section titled Open a return yourselfOpen a return for a shopper who asks you directly, or after the returns window has closed. POST /commerce/returns names the order by its id or its order_ ID, and the units in items:
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/returns" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Idempotency-Key: return-1043-1" \
-H "Content-Type: application/json" \
-d '{
"order_id": "order_01j8z3k4m5n6p7q8r9s0t1v2w3",
"items": [{"order_item_id": 5521, "quantity": 1, "reason_code": "too_small"}],
"instructions": "Pack the shoes in their box and take the parcel to any DHL point.",
"label_url": "https://shop.example.com/labels/1043.pdf"
}'
| Field | What it takes |
|---|---|
items |
Each order_item_id from the order's items and a quantity, with an optional reason_code and a note of up to 500 characters. |
kind |
return, exchange or claim. It is exchange when you send exchange_items, and return otherwise. |
exchange_items |
For an exchange, each variant_id and quantity to send instead. |
claim_type |
For a claim, refund or replace. |
restocking_fee, shipping_deduction |
Minor units to keep back from the refund. |
waive_difference |
true sends an exchange's new items when they cost more than what comes back. |
location_id |
The stock location that sellable units go back to. |
instructions, label_url |
How to send the goods back: text of up to 2,000 characters, and an https link to a return label of up to 1,000 characters. |
note |
A note of up to 2,000 characters. The shopper sees it on the return. |
The answer is 201 with the return, already approved. The shopper reads your instructions and label_url on the return in the Store API.
Approve or decline a request
Section titled Approve or decline a requestFind the requests that wait for you with GET /commerce/returns?status=requested. The list is newest first, and count is every return the filters match.
| Filter | Takes |
|---|---|
status |
A status from How a return moves |
kind |
return, exchange or claim |
order |
An order's id or its order_ ID |
page, per_page |
Up to 100 a page; 20 by default |
GET /commerce/returns/{return_id} answers one return, with each item's quantity, reason_code and note, and the order it belongs to in order_id and order_display_id.
To approve a request, send the fees and how to send the goods back:
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/returns/214/approve" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Idempotency-Key: return-214-approve" \
-H "Content-Type: application/json" \
-d '{"restocking_fee": 300, "instructions": "Pack the items in their box and take the parcel to any DHL point.", "label_url": "https://shop.example.com/labels/214.pdf"}'
The body is optional, and takes restocking_fee, shipping_deduction, waive_difference, location_id, instructions and label_url as Open a return yourself describes. The answer is 200 with the return, now approved, and refund_amount says what it refunds if every unit comes back. The fee and the deduction together cannot be more than the return refunds.
To decline a request, say why. The shopper reads the reason, which is up to 500 characters:
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/returns/214/reject" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Idempotency-Key: return-214-reject" \
-H "Content-Type: application/json" \
-d '{"reason": "The shoes have been worn outside, so we cannot take them back."}'
The return becomes rejected, and the shopper can ask for its units again while the window is open. You can approve or decline only a requested return.
Receive the goods
Section titled Receive the goodsWhen the parcel arrives, record what came back and in what condition: sellable, which goes back in stock, or damaged, which does not.
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/returns/214/receive" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Idempotency-Key: return-214-receive" \
-H "Content-Type: application/json" \
-d '{"items": [{"order_item_id": 5521, "quantity": 1, "condition": "sellable"}, {"order_item_id": 5522, "quantity": 1, "condition": "damaged"}], "location_id": 4}'
- Without
items, every unit comes back sellable. Name an item twice to split its units between the two conditions. - An item can come back with fewer units than the return names, even none, as long as one unit comes back in all. An item you leave out of
itemsdid not come back. - Sellable units go back in stock at the
location_idyou send, else the one the return names, else where they shipped from. - You can receive only an
approvedreturn, and never a claim.
The answer is 200 with the return, now received. Each item shows its received_quantity, its conditions, and a condition of sellable, damaged or mixed. refund_amount is what the units that came back are worth, less the fee, the deduction and an exchange's new items. When that leaves nothing on a return, it is completed at once.
On the order, each item's returned_quantity counts the units that came back. The order's fulfillment_status becomes partially_returned, or returned once every unit it ships has come back.
Refund a return
Section titled Refund a returnPOST /commerce/returns/{return_id}/refund settles a received return or an approved claim:
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/returns/214/refund" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Idempotency-Key: return-214-refund"
We refund the refund_amount as any order refund: the card or PayPal payment first, then any gift cards the shopper paid with, with a credit note, as Refund or cancel an order describes. We email the shopper, and send commerce.payment.refunded as well as the return's own webhook.
The answer is 200 with the return, now refunded with its refund_id, and the refund. When nothing is left to refund, the return becomes completed and refund is null. The body is optional: a reason of up to 64 characters for the refund, which is the return's kind unless you send one, and a note of up to 500 characters.
Warning
A refund sends money back to the shopper at once, and cannot be undone.
Refunding an open return's units on the order, by items, answers 409 with units_in_return: refund them through the return. Once the goods are in, the order keeps their value for the return, and its items' refundable_quantity leaves them out.
Exchange items
Section titled Exchange itemsName the new variants in exchange_items, each with a variant_id and a quantity. We price them as the order was priced: in its currency, for its region and the customer's groups, with tax for its address. Each must be on sale in the order's sales channel and in stock, and cannot be a gift card. A variant with no price in the order's currency answers 409 with price_unavailable, and one whose tax we cannot work out for the address answers 409 with tax_unavailable.
difference_amount is what the new items cost, less what the units coming back are worth:
- At zero or below, the exchange refunds what is left over, less any fee and deduction.
- Above zero, the shopper would owe you the difference, and we cannot collect it. Asking for the exchange, opening it and approving it answer
409withexchange_payment_required. Sendwaive_difference: truewhen you open, approve or refund the exchange to send the new items anyway, anddifference_waivedshows it.
When you receive an exchange, we work the difference out again from the units that came back. If it is covered or waived, we create an exchange order that sends the new items. It has the order's addresses, costs nothing, and ships like any order. The return's exchange_order_id names it, and the exchange order's exchange_for names the return.
When you also hold commerce.refunds:write or the Billing role, receiving refunds the difference at once, and the answer's refund holds the refund. When that refund cannot be made, refund_error holds the error with its status, and the return stays received until you refund it.
An exchange can wait after you receive it, with no exchange order, in two cases. The order's timeline notes each one for you, and the shopper does not see it:
- The units that came back are worth less than the new items, and you did not waive the difference. Refund it with
waive_difference: trueto send the new items, or withas_return: trueto refund the units that came back instead. - The new items are out of stock. Add stock, then refund the return, which sends them first. Or refund it with
as_return: true.
as_return: true settles the exchange as a return: it drops the new items and refunds the units that came back, less the fee and the deduction. It works only before the exchange order exists.
An exchange order takes no refund and no return. Cancel it before it ships, and what its new items took is refundable again on the order they came from. In the Store API, the shopper's view of the return has the exchange order's order_ ID in exchange_order_id, and an exchange_order_access_token that opens it.
Settle a claim
Section titled Settle a claimOpen a claim for units that were damaged or lost after they shipped, with kind set to claim and a claim_type:
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/returns" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Idempotency-Key: claim-1043-1" \
-H "Content-Type: application/json" \
-d '{"order_id": 1742, "kind": "claim", "claim_type": "replace", "items": [{"order_item_id": 5521, "quantity": 1}], "note": "The parcel arrived crushed."}'
A claim starts approved and brings nothing back, so it takes no fee, no deduction and no exchange_items. Its units are held for it from the start: they cannot be refunded on the order or named in a return. Settle it with POST /commerce/returns/{return_id}/refund, which takes commerce.refunds:write or the Billing role for a replacement too:
- A
refundclaim refunds what the units are worth, and becomesrefunded. - A
replaceclaim sends the same variants again on an exchange order at no charge, and becomescompleted.
Cancel a return
Section titled Cancel a returnPOST /commerce/returns/{return_id}/cancel ends a return that is requested or approved, before its goods are back, or a claim before it is settled. You can send a reason of up to 500 characters, and the shopper reads it on their order's timeline.
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/returns/214/cancel" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Idempotency-Key: return-214-cancel" \
-H "Content-Type: application/json" \
-d '{"reason": "The shopper decided to keep the shoes."}'
The return becomes canceled, and its units can be named in a new return.
Caution
A cancelled return cannot be opened again. To take the units back later, the shopper asks again while the window is open, or you open a new return.
Have a 3PL receive the goods
Section titled Have a 3PL receive the goodsA webhook provider can receive returned goods for you. Turn it on in the provider's config, which takes commerce.store:write or the Admin role:
curl -X PATCH "https://api.coritan.com/api/v1/orgs/acme/commerce/fulfillment-providers/3" \
-H "X-API-Key: $ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"config": {"returns": true}}'
When you approve or open a return of units that such a provider shipped, we send the provider a return.requested. We sign and retry it as Check what we send to a provider describes. It holds:
- the
order, with itsid,display_id,emailandcurrency_code; - the
return, with itsid,kind,note,instructionsandlabel_url, thelocationthe goods go to, theshopper_address, andrequested_atandapproved_at; - the
items, each with itsreason_code.
A 2xx answer's reference becomes the return's provider_reference. When you cancel a return that the provider has received, we send it a return.canceled with the return's reference and your reason. A claim sends the provider nothing.
The provider reports the goods with a return.received event, which it sends as Send events from the provider describes:
{
"id": "rma-2291",
"type": "return.received",
"data": {
"return_id": 214,
"items": [{"sku": "SHOE-42", "quantity": 1, "condition": "sellable"}]
}
}
items names units by sku or order_item_id, each with a quantity and a condition. Without items, every unit comes back sellable. Sellable units go back in stock at the location_id in data, which must be a location the provider runs, else where the return.requested sent them. The provider's receipt refunds nothing, so refund the return yourself. An exchange's new items still go out.
GET /commerce/fulfillment-providers/{provider_id}/deliveries lists these messages too, and its kind filter takes return_create and return_cancel.
Return webhooks
Section titled Return webhooksThese reach your organization webhooks as Receive commerce webhooks describes. The data of each commerce.return event holds the return_id, order_id, public_id, kind, claim_type, status, currency_code and livemode, and the fields below.
| Event | When | Also in data |
|---|---|---|
commerce.return.requested |
A shopper asks, or you open a return. | requested_by, items, exchange_items |
commerce.return.approved |
You approve a request, or open a return. | restocking_fee, shipping_deduction, refund_amount, provider_id |
commerce.return.rejected |
You decline a request. | reason |
commerce.return.received |
The goods are back. | received_by, items, refund_amount, difference_amount |
commerce.return.refunded |
Its refund went through. | refund_id, amount |
commerce.return.completed |
It was settled with nothing to refund. | refund_amount, exchange_order_id |
commerce.return.canceled |
You cancel it. | reason |
commerce.exchange.created follows when an exchange order is made. Its data holds the return_id, kind, the original order_id and public_id, the exchange_order_id, exchange_public_id and display_id of the new order, its items and livemode.
Limits and retries
Section titled Limits and retriesEvery write on a return takes an optional Idempotency-Key header of up to 128 characters. A retry with the same key within 24 hours gets the first answer back and changes nothing. The same key with another body, or for another return, answers 409 with idempotency_key_reused, and a retry while the first request runs answers 409 with idempotency_in_progress. A request that fails keeps no key. The reason routes take no key.
Refunding a return counts towards the organization's limit of 30 refunds and cancellations a minute, retries included, and so does the refund made when you receive an exchange. Beyond it, the refund route answers 429 with rate_limited and a Retry-After of 60 seconds, and receiving an exchange puts that error in refund_error.
Result
Section titled ResultA return you refunded shows refunded, with the refund_id of a refund on its order and a credit note. One settled without a refund shows completed. The order lists each of its returns under returns, with its status, and its items' returned_quantity counts what came back.
Troubleshooting
Section titled Troubleshooting409withitem_not_returnable- The units have not shipped, or are already returned, refunded, or named by another open return or a claim.
returnable_quantitysays how many of the item can come back now. A gift card never comes back: refund it on the order while it is unspent. 409withreturn_window_closed- The shopper asked after the returns window closed, at the time in
closed_at. You can still open the return yourself. 409withinvalid_return_status- The return's
statusdoes not allow that step, andallowedlists the statuses that do. A claim is never received: refund it to settle it. 409withexchange_payment_required- The new items cost
difference_amountmore than what comes back. Sendwaive_difference: trueto send them anyway, or, once the goods are in, refund withas_return: true. 422withfees_too_large- The restocking fee and the shipping deduction come to more than the return refunds.
refundableis the most they can be together. 409withinsufficient_inventory- The new items of an exchange are out of stock. Add stock and refund the return again, or refund it with
as_return: true. 409withexchange_sent- The exchange's new items have gone out on an exchange order, so it cannot be settled as a return.
409withunits_in_return- The order refund named units that an open return holds, and
return_idsays which. Refund them through the return, or cancel the return first. 409withexchange_order- The order sends an exchange's new items, so it takes no return and no refund. Cancel it before it ships, and its value is refundable again on the original order.
409withrefund_in_progress- A refund of this return is waiting for the payment provider's answer. Wait for it before you refund or cancel the return.
409withtoo_many_returns- The order has 50 returns already.
409withreason_code_taken- The store has a reason with that
code. Pick another code, or change the reason that has it. 502withrefund_failed- The payment provider refused the refund, and its reason is in the
message. The return staysreceived, orapprovedfor a claim, so you can refund it again.