Skip to content
Coritan Docs

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.

View as Markdown

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.

  • 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:read scope, 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 needs commerce.orders:read to read returns.
  • Refunding a return takes commerce.refunds:write, or the Billing role or above. Scopes and roles lists every scope.
kind 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.

  • 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_days days 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 reasons

The returns window is 30 days unless you set it. Set it with PATCH /commerce/store, which takes commerce.store:write or the Admin role:

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

Shell
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-reasons lists every reason in the order shoppers see them, disabled ones included. Shoppers see only the enabled ones.
  • PATCH /commerce/return-reasons/{reason_id} changes the label, description, rank or is_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 says deleted: 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 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:

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

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

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

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

When the parcel arrives, record what came back and in what condition: sellable, which goes back in stock, or damaged, which does not.

Shell
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 items did not come back.
  • Sellable units go back in stock at the location_id you send, else the one the return names, else where they shipped from.
  • You can receive only an approved return, 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.

POST /commerce/returns/{return_id}/refund settles a received return or an approved claim:

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

Name 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 409 with exchange_payment_required. Send waive_difference: true when you open, approve or refund the exchange to send the new items anyway, and difference_waived shows 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: true to send the new items, or with as_return: true to 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.

Open a claim for units that were damaged or lost after they shipped, with kind set to claim and a claim_type:

Shell
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 refund claim refunds what the units are worth, and becomes refunded.
  • A replace claim sends the same variants again on an exchange order at no charge, and becomes completed.

POST /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.

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

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

Shell
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 its id, display_id, email and currency_code;
  • the return, with its id, kind, note, instructions and label_url, the location the goods go to, the shopper_address, and requested_at and approved_at;
  • the items, each with its reason_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:

JSON
{
  "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.

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

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

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

409 with item_not_returnable
The units have not shipped, or are already returned, refunded, or named by another open return or a claim. returnable_quantity says how many of the item can come back now. A gift card never comes back: refund it on the order while it is unspent.
409 with return_window_closed
The shopper asked after the returns window closed, at the time in closed_at. You can still open the return yourself.
409 with invalid_return_status
The return's status does not allow that step, and allowed lists the statuses that do. A claim is never received: refund it to settle it.
409 with exchange_payment_required
The new items cost difference_amount more than what comes back. Send waive_difference: true to send them anyway, or, once the goods are in, refund with as_return: true.
422 with fees_too_large
The restocking fee and the shipping deduction come to more than the return refunds. refundable is the most they can be together.
409 with insufficient_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.
409 with exchange_sent
The exchange's new items have gone out on an exchange order, so it cannot be settled as a return.
409 with units_in_return
The order refund named units that an open return holds, and return_id says which. Refund them through the return, or cancel the return first.
409 with exchange_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.
409 with refund_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.
409 with too_many_returns
The order has 50 returns already.
409 with reason_code_taken
The store has a reason with that code. Pick another code, or change the reason that has it.
502 with refund_failed
The payment provider refused the refund, and its reason is in the message. The return stays received, or approved for a claim, so you can refund it again.

API operations on this page

MethodPathWhat it does
GET/api/v1/orgs/{org_slug}/commerce/returnsNewest first; count is every return the filters match
POST/api/v1/orgs/{org_slug}/commerce/returnsCreate return
GET/api/v1/orgs/{org_slug}/commerce/returns/{return_id}Get return
POST/api/v1/orgs/{org_slug}/commerce/returns/{return_id}/approveApprove return
POST/api/v1/orgs/{org_slug}/commerce/returns/{return_id}/rejectDecline a requested return; the shopper reads the reason
POST/api/v1/orgs/{org_slug}/commerce/returns/{return_id}/receiveReceive return
POST/api/v1/orgs/{org_slug}/commerce/returns/{return_id}/refundRefund return
POST/api/v1/orgs/{org_slug}/commerce/returns/{return_id}/cancelEnd a return before its goods are in, or a claim before it is settled
GET/api/v1/orgs/{org_slug}/commerce/return-reasonsEvery reason, disabled ones included, in the order shoppers see them
POST/api/v1/orgs/{org_slug}/commerce/return-reasonsA reason shoppers may pick for a return
PATCH/api/v1/orgs/{org_slug}/commerce/return-reasons/{reason_id}Label, description, rank or whether shoppers may pick it; the code never changes
DELETE/api/v1/orgs/{org_slug}/commerce/return-reasons/{reason_id}Delete return reason