The Shopify order edit API is a three-step session in Admin GraphQL: orderEditBegin opens a CalculatedOrder, a handful of orderEdit* mutations stage changes against it (add a variant, change a quantity, add a discount or shipping line), and orderEditCommit applies them to the real order in one go. Nothing touches the order until commit. It only edits unfulfilled lines, and it never changes the payment that was already taken, so the money side is a separate job.
This post is written against the 2026-10 Admin API and the edit existing orders guide. It is for anyone building the “customer rang up and wants to add a second one” feature into an app, a support tool or a warehouse integration, without round-tripping through the admin UI.
How does the Shopify order edit API session work?
Your app needs the write_order_edits scope. If the order is older than sixty days you also need read_all_orders.
Begin returns the ID you will use for every later step. The CalculatedOrder is a copy of the order with your pending changes applied, and it carries two line item connections: lineItems for everything that existed before you started, and addedLineItems for what you have staged. Keep the two straight; a quantity change on an existing line uses the CalculatedLineItem ID from lineItems, not the original LineItem ID from the order.
mutation beginEdit($orderId: ID!) {
orderEditBegin(id: $orderId) {
calculatedOrder {
id
lineItems(first: 50) {
nodes { id title quantity }
}
}
userErrors { field message }
}
}
Then stage. orderEditAddVariant takes the calculated order ID, a variantId and a quantity. orderEditSetQuantity takes a lineItemId (the calculated one), a non-negative quantity, and an optional restock boolean that only matters when the quantity goes down; it defaults to false, so a reduction leaves inventory where it was unless you say otherwise. Zero removes the line. orderEditAddCustomItem adds a free-text line with a title, quantity and price for the fee or part that has no variant. orderEditAddLineItemDiscount applies a percentValue or fixed amount discount to one line, and orderEditAddShippingLine adds a new shipping charge. Each mutation returns the updated calculatedOrder, so you can read totalOutstandingSet after every step and see what the customer would owe.
Commit last:
mutation commitEdit($id: ID!, $note: String) {
orderEditCommit(id: $id, notifyCustomer: true, staffNote: $note) {
order { id totalOutstandingSet { shopMoney { amount currencyCode } } }
userErrors { field message }
}
}
notifyCustomer: true sends the customer an email with the updated order and, where there is a balance, an invoice they can pay. staffNote is a note for staff, not the customer. Once the commit goes through, the orders/edited webhook fires with the line item, discount and shipping line changes in its payload.
How do you run it from Node?
The whole session is four or five sequential requests, each depending on the ID from the one before, so it reads naturally as a single async function. This one adds a variant to an order and commits; swap the middle call for orderEditSetQuantity to change an existing line.
const SHOP = process.env.SHOPIFY_SHOP!; // my-store.myshopify.com
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN!;
const ENDPOINT = `https://${SHOP}/admin/api/2026-10/graphql.json`;
type GqlError = { field: string[] | null; message: string };
async function gql<T>(query: string, variables: Record<string, unknown>): Promise<T> {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': TOKEN,
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Admin API ${res.status}`);
const json = (await res.json()) as { data: T; errors?: { message: string }[] };
if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join('; '));
return json.data;
}
function assertNoUserErrors(label: string, errors: GqlError[]) {
if (errors.length) {
throw new Error(`${label}: ${errors.map((e) => e.message).join('; ')}`);
}
}
export async function addVariantToOrder(orderId: string, variantId: string, quantity: number) {
const begin = await gql<{
orderEditBegin: { calculatedOrder: { id: string } | null; userErrors: GqlError[] };
}>(
`mutation ($orderId: ID!) {
orderEditBegin(id: $orderId) {
calculatedOrder { id }
userErrors { field message }
}
}`,
{ orderId },
);
assertNoUserErrors('orderEditBegin', begin.orderEditBegin.userErrors);
const calcId = begin.orderEditBegin.calculatedOrder!.id;
const add = await gql<{
orderEditAddVariant: {
calculatedOrder: { totalOutstandingSet: { shopMoney: { amount: string } } } | null;
userErrors: GqlError[];
};
}>(
`mutation ($id: ID!, $variantId: ID!, $quantity: Int!) {
orderEditAddVariant(id: $id, variantId: $variantId, quantity: $quantity) {
calculatedOrder { totalOutstandingSet { shopMoney { amount } } }
userErrors { field message }
}
}`,
{ id: calcId, variantId, quantity },
);
assertNoUserErrors('orderEditAddVariant', add.orderEditAddVariant.userErrors);
const commit = await gql<{
orderEditCommit: { order: { id: string } | null; userErrors: GqlError[] };
}>(
`mutation ($id: ID!) {
orderEditCommit(id: $id, notifyCustomer: true, staffNote: "Added by support tool") {
order { id }
userErrors { field message }
}
}`,
{ id: calcId },
);
assertNoUserErrors('orderEditCommit', commit.orderEditCommit.userErrors);
return {
orderId: commit.orderEditCommit.order!.id,
outstanding: add.orderEditAddVariant.calculatedOrder!.totalOutstandingSet.shopMoney.amount,
};
}
Two things about that code are deliberate. Every mutation’s userErrors is checked before the next call, because a staged edit that failed validation (a variant ID that does not exist, say) comes back as a user error with a 200 status, and if you ignore it you will commit a session that does not contain the change you think it does. And the function does not retry the commit. If orderEditCommit times out at the network layer you do not know whether it applied; query the order’s totalOutstandingSet and its line items before trying again rather than opening a second session.
What happens to the money?
Nothing, by itself. Committing an edit changes the order total and recalculates totalOutstandingSet; it does not charge the customer’s card and it does not refund them. If the new total is higher, the customer owes the difference, and the email notifyCustomer triggers is how Shopify asks them to pay it. If the total is lower, the difference becomes an amount to refund, and refunds are a separate set of mutations and a separate post.
Read totalOutstandingSet from the CalculatedOrder before you commit, not after. That is the number to show the support agent, or the customer, as “this will add 12.50 to the order”, and it is the number to compare against a threshold if your tool should refuse edits over a certain size. notificationPreviewHtml on the same object is the HTML of the customer notification the edit would send, which is the right thing to put on a confirmation screen so the agent sees what the customer will.
The guide’s limits are worth knowing before you promise anyone a feature. You can only edit unfulfilled line items, so anything already fulfilled is off limits. The guide also says the order must be in the store currency, so check the order currency against the shop currency before offering an edit on an international order. Prepaid subscription orders with multiple scheduled fulfilments cannot have quantities changed, and editing an order never touches the subscription contract behind it. Orders placed before 1 January 2019 and archived orders are out.
When would you reach for a draft order instead?
Order editing is for changing an order that exists and has been paid, or partly paid. A draft order is for building a new one from a quote. People confuse the two because both let you add arbitrary line items and custom prices, but they diverge at the money: editing keeps the original order number, timeline and payment, and asks for the difference; a draft order starts a fresh checkout and a fresh order. If a customer wants to replace their whole basket, cancelling and issuing a draft is often cleaner than staging five removals and five additions. Our write-up of the Draft Order API quote-to-order flow covers that side.
Two integration notes. Because a commit fires orders/edited, any downstream sync you run (an ERP, a picking system) needs to treat an edited order as a changed document rather than a new one, with the same idempotency care as any Shopify webhook that can arrive twice. And each mutation in the session costs query points separately, so a bulk “apply this discount to 300 open orders” job is three or four requests per order, 900 to 1,200 in all, and needs the same throttle budgeting as any other Admin GraphQL loop.
Recommendation
Use the order edit API when the change is small and the order is otherwise right: an added item, a corrected quantity, a goodwill discount, a shipping upgrade. Stage, read totalOutstandingSet, show it to a human, then commit with notifyCustomer: true so the customer gets the invoice without you building one. Set restock: true on reductions unless the stock really has left the building.
Do not use it to rework an order wholesale, to touch anything already fulfilled, or on orders outside the store currency; the guide rules those out, and a tool that fails on them will fail on the orders support cares about most. Cancel and re-draft instead.
Whoooop builds Shopify apps and back-office integrations for UK merchants, including the order management tooling support teams use every day. If your team is still editing orders by hand in the admin, our Shopify development work covers this kind of build.