Partial order cancellation and voiding partially captured transaction

Hello community,
I’m trying to figure out what would be the best way to partially cancel an order programmatically, while correctly voiding authorised funds from a partially captured transaction. The store is set up to capture funds on fulfilment. Here is an example scenario:

  • Customer purchases 2 items
  • 1 of the items is fulfilled
  • 1 of the items can’t be fulfilled and should be cancelled

There are two different variations of the scenario:

  • The partial cancellation occurs on an item with the same line item ID
  • The partial cancellation occurs on items with different line item IDs

I’m currently using the following process as a base for both variations:

  • Calculate a refund for the cancelled item
  • Create a refund with no transaction associated

The following happens in Shopify when this is followed:

Partial cancellation occurs on an item with the same line item ID

  • Shopify always selects the already fulfilled item when the refund is created
  • The unfulfilled item that should be cancelled stays in the “Unfulfilled” state
  • The fulfilled item can no longer be returned
  • The payment section still requires capturing the remaining funds

Partial cancellation occurs on items with different line item IDs

  • The cancelled item is marked as “Removed” after the $0 refund is submitted
  • The payment section still requires capturing the remaining funds

I have tried the following without any success:

  • Void the partially captured auth transaction (transactionVoid)-> Shopify returns an error `AUTH_NOT_VOIDABLE`.
  • Create a void transaction via the REST API → Shopify refunds the captured funds and doesn’t actually create a void transaction
  • Execute the `orderEditBegin`, `orderEditSetQuantity`, `orderEditCommit` → Although this solves the issue of the 1st variation scenario and moves the “Unfulfilled” product as “Removed”, Shopify now request that a refund needs to be issued to the customer. It always tries to refund the already captured portion and doesn’t allow you to void the remaining auth funds
  • Cancel the fulfilment order → It simply creates a new one. Nothing really happens

At this point, I’m a bit clueless about what else I can try out, and I hope that someone that had faced this issue in the past can shed some light on what the best practice is for handling it.

TL;DR: there are two things that I want to achieve when a partial cancellation occurs on an order:

  • Mark any cancelled “Unfulfilled” items as “Removed” via the Shopify API
  • Void the remaining partially captured authorised funds via the Shopify API

Thanks in advance!

Hi @andres03, I had a look into this and on the funds, you’ve hit expected behavior. Once any amount has been captured against an authorization, that authorization can no longer be voided by any means, full or partial. There’s no partial void in the API, so transactionVoid only works while nothing has been captured, and the REST “void” you tried falls back to a refund for the same reason. This is called out in the post-payment guide (“After an authorization has been partially captured, it can no longer be voided by any means. It must either be captured, or left to expire after the 7 to 30 day authorization period”).

So the remaining authorized funds aren’t something you void but instead they must be released. How depends on whether your gateway supports multiple captures. On a single capture gateway the first capture takes the whole order and the uncaptured remainder is released automatically. I reproduced this on a test order: after capturing $50 of a $100 authorization the order sat at PARTIALLY_PAID with totalCapturableSet and the authorization’s unsettled amount both at 0, nothing left to void or capture. On Shopify Plus with Shopify Payments multi-capture you instead pass finalCapture: true on your orderCapture call, which captures the fulfilled amount and releases the rest in one step. The capture per fulfillment behavior covers the single capture case where payment for the whole order is taken at the first fulfillment.

For the line items, a refund acts on a line item’s refundable (fulfilled, restockable) quantity, so when the cancelled unit shares a line item ID with the fulfilled one it picks the fulfilled unit, blocks the return on it, and leaves the unfulfilled unit stuck. Order editing is the supported way to take an unfulfilled item off the order: orderEditBegin, orderEditSetQuantity to 0 on the unfulfilled item, orderEditCommit moves it to Removed. That works for both the same line item ID and different line item ID cases, and it’s why cancelling the fulfillment order didn’t help (that just returns the items to a new unfulfilled fulfillment order, it doesn’t remove anything).

On a non multi-capture gateway the full order was already captured at the first fulfillment, so the cancelled item’s amount is sitting in captured funds and a refund is the only way back. If you capture only the fulfilled amount first (single capture releases the rest, or finalCapture: true on Plus) and then edit out the unfulfilled item, the captured total already matches what’s left and there’s nothing extra to refund.

Let me know if this helps or if you have follow up questions and I’ll be happy to have another look!

Hi @Donal-Shopify,
thank you for the extensive answer! The shop on which I’m trying this is using `capture per fulfillment` behaviour with Shopify Payments. The capture is executed by Shopify when an item is marked as fulfilled, and I’m currently not calling the orderCapture mutation.

So if I understand correctly, in my case, the remaining auth funds can’t be released on the cancellation (manually or programmatically) and just have to wait for them to expire?

Hey @andres03! Capture per fulfillment is a Shopify Plus feature, and on Plus with Shopify Payments it runs on a multi-capturable authorization, so you should be able to release the remainder yourself.

The automatic capture fires with finalCapture defaulting to false, which is why the auth stays open. Instead of letting it fire, call orderCapture yourself on the kept item’s amount with finalCapture: true. That captures what you’re keeping and voids the cancelled item’s remaining hold in one call. It only overrides the automation for that one order, so no store-wide setting changes. transactionVoid stays blocked once anything is captured (the AUTH_NOT_VOIDABLE you hit), so this is the supported way forward.

The one case where you’re stuck waiting for expiry is if the kept item was already auto-captured and the only thing left uncaptured is the cancelled item’s amount. There’s no positive amount to attach finalCapture to (it can’t be zero) and nothing over-captured to refund, so that leftover hold just releases at expiry. Owning the capture going forward avoids that.

I haven’t been able to test this end to end on a test store just yet, so its certainly worth a dry run or two to confirm it works how you need it to!

Hi @Donal-Shopify,
thank you for the answer! I’m not sure if I fully understand the following:

The automatic capture fires with finalCapture defaulting to false, which is why the auth stays open. Instead of letting it fire, call orderCapture yourself on the kept item’s amount with finalCapture: true.

When we specify “Capture per fulfillment” as strategy in Shopify, then Shopify always automatically captures the funds when a fulfillment is created on an order. Are you saying it is possible to prevent this somehow and capture the funds outside Shopify?

I also have a separate question for the partial cancellation (edit order step behavior) in cases where the payment strategy is set to pre-capture (automatically captures all funds on order creation). The `orderEdit` multi-step flow causes Shopify to display “Refund owned” message.

This is confusing as the refund for the removed (cancelled) items is already created. Is there any way to prevent this? Or is there another recommended way for partial cancellations for fully captured orders? Thanks in advance!

Hi @andres03, thanks for following up!

On the capture-per-fulfillment question, not quite “outside Shopify”. You still call orderCapture, you just take ownership of the timing instead of letting the automation do it. On an order where one item is getting cancelled, call orderCapture for the kept item’s amount with finalCapture: true before you create that item’s fulfillment. Manually capturing takes that single order off the per-fulfillment automation (other orders keep auto-capturing as normal), and finalCapture: true releases the remaining authorization on the cancelled item in the same call. The catch is timing. If the kept item’s fulfillment has already fired and auto-captured, you can’t pre-empt it for that order anymore, and the leftover hold just falls off when the authorization expires (roughly 7 to 30 days).

For the “Refund owed” message on a fully-captured order, that’s expected behavior. orderEdit only recalculates the order total, it never moves money on its own (orderEditCommit doesn’t return or create any refund transaction). Since the funds were already captured, lowering the total leaves captured greater than the new total, which Shopify surfaces as a refund you still owe. The flow of funds when editing orders table spells this out. The refund is always a separate step you run after committing the edit, via refundCreate against the captured transaction.

I reproduced your scenario on a test store to be sure. Fully captured order, removed a line item through orderEditBegin to orderEditSetQuantity (quantity 0) to orderEditCommit. After the commit the total dropped, money hadn’t moved, and the order showed “Refund owed” equal to the removed amount. A single refundCreate for exactly that difference, with the captured transaction as the parent, cleared the owed amount to zero and netted the payment down to the new total. One thing worth flagging from that test: refundCreate now requires the @idempotent(key: "...") directive on the current API version (I hit this on 2026-04), so it’ll error without it.

In a correct sequence the owed amount clears to zero, so a residual $350 still owed after a $350 refund usually means a sequencing or amount mismatch, for example the refund being issued before or independently of the edit, or against a different amount.

Can you share the exact orderEdit and refundCreate calls (or ideally their x-request-id response headers)? With that I can pin down where the extra $350 is coming from.

Hi @Donal-Shopify,
thank you for the reply!

The capture flow you explained is now clearer to me, thanks! The only problem is that in the OMS system (which is first point of updates) tries to refund/revoke funds for cancellations after all items are already fulfilled. That makes timing a bit challenging and I would prefer to leave this job to Shopify. But nonetheless good to know this for the future!

On the second point - the test I conducted I did the refund before the order edit. Do you think that this might have caused the warning?

Thanks for your assistance!

That’s a fair call on the timing! If your OMS only reverses funds after every item is already fulfilled (and therefore auto-captured), there’s no positive amount left to attach a final capture to, so the cancelled item’s remaining hold just falls off when the authorization expires (roughly 7 to 30 days). Leaving that to Shopify is reasonable. The only way to release it earlier is to own the orderCapture call with finalCapture true before the per-fulfillment automation fires, which your OMS flow doesn’t give you room for.

On the “Refund owed” message, the order in which you ran the refund and the edit isn’t really what’s driving it. orderEdit only recalculates the order total, it never moves money, and the banner is just the order total minus what’s already been received. On a fully captured order, removing a line item lowers the total while the captured amount stays put, so a balance shows as owed until a refund of the matching amount brings the received total back in line.

To confirm, can you tell me what the refund covered versus what the edit removed, including tax and any shipping or discount allocation, and whether the refund was associated to a transaction? That’ll show whether there’s a remaining balance or just the two operations not lining up.

@Donal-Shopify here are some details for this specific case.

There were initially 3 items on the order

  • Item A with quantity of 1
  • Item B with quantity of 2

Item A was fulfilled and the 2 quantities of item B were cancelled (removed). Item B costs $175 each. Here is the items section on the order:

First the refund was created with the following body (note that the REST API was used here; not sure if this is relevant)

{
    "refund": {
        "note": "Refund reason",
        "notify": true,
        "shipping": {
            "full_refund": true
        },
        "refund_line_items": [],
        "transactions": [
            {
                "parent_id": "9542343819308",
                "amount": 350,
                "kind": "refund",
                "gateway": "bogus",
                "currency": "USD"
            }
        ]
    }
}

No `refund_line_items` are specified to avoid the issue where a fulfilled item might be refunded instead of the one that should be cancelled and is still in “unfulfilled” state.

Response:

{
    "refund": {
        "id": 994033500204,
        "order_id": 6789732827180,
        "created_at": "2026-06-08T13:55:51-04:00",
        "note": "Refund reason",
        "user_id": null,
        "processed_at": "2026-06-08T13:55:51-04:00",
        "duties": [],
        "total_duties_set": {
            "shop_money": {
                "amount": "0.00",
                "currency_code": "USD"
            },
            "presentment_money": {
                "amount": "0.00",
                "currency_code": "USD"
            }
        },
        "return": null,
        "refund_shipping_lines": [],
        "restock": false,
        "admin_graphql_api_id": "gid://shopify/Refund/994033500204",
        "order_adjustments": [
            {
                "id": 381010149420,
                "order_id": 6789732827180,
                "refund_id": 994033500204,
                "amount": "-350.00",
                "tax_amount": "0.00",
                "kind": "refund_discrepancy",
                "reason": "Refund discrepancy",
                "amount_set": {
                    "shop_money": {
                        "amount": "-350.00",
                        "currency_code": "USD"
                    },
                    "presentment_money": {
                        "amount": "-350.00",
                        "currency_code": "USD"
                    }
                },
                "tax_amount_set": {
                    "shop_money": {
                        "amount": "0.00",
                        "currency_code": "USD"
                    },
                    "presentment_money": {
                        "amount": "0.00",
                        "currency_code": "USD"
                    }
                }
            }
        ],
        "refund_line_items": [],
        "transactions": [
            {
                "id": 9542373179436,
                "order_id": 6789732827180,
                "kind": "refund",
                "gateway": "bogus",
                "status": "success",
                "message": "Bogus Gateway: Forced success",
                "created_at": "2026-06-08T13:55:51-04:00",
                "test": true,
                "authorization": null,
                "location_id": null,
                "user_id": null,
                "parent_id": 9542343819308,
                "processed_at": "2026-06-08T13:55:51-04:00",
                "device_id": null,
                "error_code": null,
                "source_name": "26768343041",
                "payment_details": {
                    "credit_card_bin": "1",
                    "avs_result_code": null,
                    "cvv_result_code": null,
                    "credit_card_number": "•••• •••• •••• 1",
                    "credit_card_company": "Bogus",
                    "buyer_action_info": null,
                    "credit_card_name": "test",
                    "credit_card_wallet": null,
                    "credit_card_expiration_month": 11,
                    "credit_card_expiration_year": 2033,
                    "payment_method_name": "bogus"
                },
                "receipt": {
                    "paid_amount": "350.00"
                },
                "amount": "350.00",
                "currency": "USD",
                "payment_id": "#1111.1",
                "total_unsettled_set": {
                    "presentment_money": {
                        "amount": "0.0",
                        "currency": "USD"
                    },
                    "shop_money": {
                        "amount": "0.0",
                        "currency": "USD"
                    }
                },
                "manual_payment_gateway": false,
                "amount_rounding": null,
                "admin_graphql_api_id": "gid://shopify/OrderTransaction/9542373179436"
            }
        ]
    }
}

After that, the order edit multi-step was initiated (using the GraphQL):

orderEditBegin response

{
    "orderEditBegin": {
        "calculatedOrder": {
            "id": "gid://shopify/CalculatedOrder/426110386220",
            "lineItems": {
                "nodes": [
                    {
                        "id": "gid://shopify/CalculatedLineItem/16261486542892",
                        "editableQuantity": 2,
                        "quantity": 2
                    }
                ]
            }
        },
        "orderEditSession": {
            "id": "gid://shopify/OrderEditSession/426110386220"
        },
        "userErrors": []
    }
}

orderEditSetQuantity response

{
    "orderEditSetQuantity": {
        "calculatedLineItem": {
            "id": "gid://shopify/CalculatedLineItem/16261486542892",
            "quantity": 0,
            "editableQuantity": 0,
            "editableQuantityBeforeChanges": 2
        },
        "calculatedOrder": {
            "id": "gid://shopify/CalculatedOrder/426110386220",
            "lineItems": {
                "nodes": [
                    {
                        "id": "gid://shopify/CalculatedLineItem/16261486542892",
                        "editableQuantity": 0,
                        "editableQuantityBeforeChanges": 2,
                        "quantity": 0
                    }
                ]
            }
        },
        "orderEditSession": {
            "id": "gid://shopify/OrderEditSession/426110386220"
        },
        "userErrors": []
    }
}

orderEditCommit response

{
    "orderEditCommit": {
        "successMessages": [
            "Order updated"
        ],
        "userErrors": []
    }
}

Here is also the full totals section on the order

The message is really odd as there are not even enough captured funds on the order which Shopify requests to be refunded. I hope that this info helps and I’m really looking forward solving this riddle! Thanks again for your support!

Thanks for your patience in waiting for me to get back to you @andres03! You were right to avoid refunding the fulfilled item, but the tricky part is that the refund happened before Shopify knew which order lines were being removed.

The REST refund you shared is an amount-only refund. Since refund_line_items is empty, Shopify records the money movement separately from the Item B cancellation, which is why the response includes refund_discrepancy. When you then run orderEditCommit, Shopify updates the order totals, but it does not apply that earlier refund to the removed line items or create a new refund transaction. So the order total is reduced again, and the admin can still show a refund owed balance even though the customer already received the money.

For future orders, the cleaner sequence is to edit first, then refund the resulting balance:

  1. Run orderEditBegin.
  2. Use orderEditSetQuantity to set the unfulfilled Item B quantity to 0.
  3. Run orderEditCommit.
  4. Read the order’s totalOutstandingSet. If it is negative, create one refund for that exact amount against the successful parent sale or capture transaction with refundCreate. On API version 2026-04, remember to put @idempotent(key: "...") on the refundCreate field.

I tested that sequence on a dev store with a paid order. After removing the unfulfilled item, orderEditCommit left totalOutstandingSet negative and did not create a refund. A transaction refund for that exact outstanding amount then brought totalOutstandingSet back to 0.00 without creating a refund_discrepancy.

For the order that is already in this state, I would not issue another refund just to clear the banner. First compare the current total, net payment, total refunded, and total outstanding. If the customer was already refunded correctly, this is likely a sequencing/accounting mismatch from refunding before the edit rather than a real second amount owed.

Hi @Donal-Shopify,
it looks like the order of operations was indeed the issue! When the items are removed first and then refunded, the warning doesn’t appear!

Thanks a lot for your assistance!