Shopify Returns API: returnCreate, Then returnProcess

The Shopify Returns API is a set of Admin GraphQL mutations, and the one that matters most changed in 2025. Open a return with returnCreate (or let the customer ask with returnRequest and approve it), then confirm the items, restock them and refund the money in one returnProcess call. returnRefund, the old refund step, is deprecated. Returns exist only in GraphQL; there is no REST equivalent. The operations below were written against 2026-10 and need the write_returns scope.

Which mutations make up the Shopify returns API?

A Return moves through five statuses, and each one is set by a named mutation. returnRequest creates it as REQUESTED. returnApproveRequest moves it to OPEN, returnDeclineRequest to DECLINED. returnCreate skips the request stage and creates the return already OPEN. returnProcess confirms quantities, records dispositions and refunds; once every item is processed and has a disposition the return closes itself, or you close it by hand with returnClose. returnCancel works only on an OPEN return with no refund, no disposition and no Shopify Shipping label. returnReopen undoes a close.

Two more objects hang off the return. A ReverseFulfillmentOrder is the work of getting the items back, created automatically when a return becomes OPEN. A ReverseDelivery is a parcel on its way back to you, and reverseDeliveryCreateWithShipping attaches tracking or a label file to it.

Only fulfilled items can be returned. If the line is still unfulfilled you are not returning anything, you are editing the order, which is the order edit flow instead. Creating a return on an archived order unarchives it.

How do I create a return for an order?

You cannot pass an order line item ID. A return is built from fulfilment line items, so the first query asks the order which of them can still come back:

query ReturnableLines($orderId: ID!) {
  returnableFulfillments(orderId: $orderId, first: 10) {
    nodes {
      id
      fulfillment { id displayStatus }
      returnableFulfillmentLineItems(first: 50) {
        nodes {
          quantity
          fulfillmentLineItem {
            id
            lineItem { title sku }
          }
        }
      }
    }
  }
}

quantity here is what is still available to return. Then create the return:

mutation OpenReturn($returnInput: ReturnInput!) {
  returnCreate(returnInput: $returnInput) {
    return {
      id
      name
      status
      reverseFulfillmentOrders(first: 5) {
        nodes {
          id
          lineItems(first: 50) {
            nodes { id totalQuantity }
          }
        }
      }
    }
    userErrors { field message code }
  }
}
{
  "returnInput": {
    "orderId": "gid://shopify/Order/1",
    "notifyCustomer": true,
    "returnLineItems": [
      {
        "fulfillmentLineItemId": "gid://shopify/FulfillmentLineItem/1",
        "quantity": 1,
        "returnReasonDefinitionId": "gid://shopify/ReturnReasonDefinition/1",
        "returnReasonNote": "Customer asked for a size up"
      }
    ]
  }
}

Ask for reverseFulfillmentOrders in the payload. The ReverseFulfillmentOrderLineItem IDs it returns are what returnProcess wants later for the restock decision, and fetching them now saves a second round trip.

returnReason (the SIZE_TOO_SMALL, DEFECTIVE, UNWANTED enum) still works on the input but is marked deprecated. Merchants now define their own reasons, and you pass a returnReasonDefinitionId from the returnReasonDefinitions query instead. returnReasonNote is capped at 255 characters, and a restockingFee can go on each line.

If the order has at least one fulfilled line item that has not been refunded, this succeeds and the return is OPEN. It also writes a sales agreement with reason RETURN and reversing sales records into the merchant’s reports, which is why you should not create a return just to see what the numbers would be. For that there is returnCalculate, which previews the financials without creating anything; the return management tutorial walks through it.

Request or create?

Pick returnRequest when the merchant wants to see the request before anything happens. It sits at REQUESTED with no reverse fulfillment order, a requested return cannot be cancelled, and returnApproveRequest or returnDeclineRequest is permanent either way. Pick returnCreate when approval has already happened somewhere else: your own portal, a support ticket, a 3PL rule. Both end at OPEN, and everything after that point is the same.

How do I refund and restock with returnProcess?

Before 2025-07 you did this in two mutations: reverseFulfillmentOrderDispose for the restock decision and returnRefund for the money. The Shopify developer changelog entry of 21 May 2025 deprecated returnRefund and Return.suggestedRefund, and returnProcess shipped as stable in 2025-07. The returns apps overview says existing returns apps should migrate to it.

First ask Shopify what it would refund. Return.suggestedFinancialOutcome takes the ReturnLineItem IDs and quantities you intend to process (and any ExchangeLineItem IDs, or an empty list) and returns maximumRefundable, discountedSubtotal, totalTax and the shipping and duties it would include:

query SuggestOutcome($id: ID!, $lines: [SuggestedOutcomeReturnLineItemInput!]!) {
  return(id: $id) {
    suggestedFinancialOutcome(
      returnLineItems: $lines
      exchangeLineItems: []
      refundMethodAllocation: ORIGINAL_PAYMENT_METHODS
    ) {
      maximumRefundable { presentmentMoney { amount currencyCode } }
      totalTax { presentmentMoney { amount currencyCode } }
    }
  }
}

Then process. One call confirms the quantity, says what happened to the goods, and moves the money:

mutation ProcessReturn($input: ReturnProcessInput!) {
  returnProcess(input: $input) {
    return {
      id
      status
      refunds(first: 5) {
        nodes {
          id
          totalRefundedSet { presentmentMoney { amount currencyCode } }
        }
      }
    }
    userErrors { field message code }
  }
}
{
  "input": {
    "returnId": "gid://shopify/Return/1",
    "notifyCustomer": true,
    "returnLineItems": [
      {
        "id": "gid://shopify/ReturnLineItem/1",
        "quantity": 1,
        "dispositions": [
          {
            "reverseFulfillmentOrderLineItemId": "gid://shopify/ReverseFulfillmentOrderLineItem/1",
            "quantity": 1,
            "dispositionType": "RESTOCKED",
            "locationId": "gid://shopify/Location/1"
          }
        ]
      }
    ],
    "financialTransfer": {
      "issueRefund": {
        "orderTransactions": [
          {
            "parentId": "gid://shopify/OrderTransaction/1",
            "transactionAmount": { "amount": "25.99", "currencyCode": "GBP" }
          }
        ]
      }
    }
  }
}

Three details decide whether this call succeeds.

dispositionType has four values: RESTOCKED, NOT_RESTOCKED, MISSING and PROCESSING_REQUIRED. locationId is required for RESTOCKED, and that is the location whose inventory goes up. If a parcel arrives short, process the units that arrived as RESTOCKED and the rest as MISSING; the quantities are per disposition, not per line.

parentId must be a CAPTURE or SALE transaction on the original order, so pull order.transactions with kind and status and pick the successful capture rather than the authorisation that preceded it. transactionAmount is in the order’s presentment currency, which on a store selling through Markets is not always the shop currency.

financialTransfer is optional. Leave it out and you have confirmed the items and restocked them without paying anything back, which is the right shape for a 3PL receiving goods while finance refunds in a second returnProcess call later. Refunds made this way are tied to the specific return line, unlike a bare refundCreate. Exchanges follow the same pattern: exchangeLineItems on returnCreate reserve the replacement, and processing them is what creates the exchange fulfillment orders. Since 2025-07 only exchanges where the buyer owes a balance go on hold, with AWAITING_PAYMENT as the reason.

Everything you process emits webhooks (returns/process, refunds/create, reverse_fulfillment_orders/dispose), and like the rest of Shopify’s webhooks they arrive at least once, so the handler needs the dedupe-and-reconcile shape before it touches your warehouse system.

What I would build

Default to returnRequest plus returnApproveRequest for anything customer-facing, so a merchant can still say no, and reserve returnCreate for automated approvals you have already decided in your own rules engine. Call suggestedFinancialOutcome and refund what it says rather than recomputing tax yourself; the numbers include discount allocation and duties you will get wrong. Process in two steps when the warehouse and the refund are separate decisions, and in one when they are not.

Do not put returnRefund or reverseFulfillmentOrderDispose in new code. Both still exist in 2026-10 and no removal version has been named, but the exchanges guide says the legacy flow will be removed in a future API version, and a returns integration is the kind of thing nobody wants to reopen with a deadline attached.

If you are not writing an app at all, and the merchant just needs returns handled, Shopify’s own self-serve returns in customer accounts will do, and you can stop here. The API earns its place when returns feed a warehouse, a 3PL or a finance system that is not Shopify.

Whoooop builds Shopify apps and integrations like this, including returns and fulfilment flows that have to agree with a warehouse system and the merchant’s accounts. If you have a returns process spread across spreadsheets and a 3PL portal, our Shopify development work usually starts by mapping it onto exactly these mutations.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch