Shopify Draft Order API: Quote to Paid Order

Shopify’s draft order API turns a quote into a paid order in four mutations. draftOrderCreate builds the order, draftOrderCalculate prices it without saving anything, draftOrderInvoiceSend emails a secure checkout link, and draftOrderComplete converts it into a real order. Everything here is written against Admin GraphQL 2026-07.

The mutations need the write_draft_orders access scope, or write_quick_sale, and the staff account behind the token needs permission to manage draft orders. The scope on its own is not enough.

What is the Shopify draft order API for?

Orders that did not come through checkout. A phone order, a wholesale quote agreed over email, a pre-order taken before stock lands, a replacement order rebuilt by hand after a support ticket. Shopify’s own list also covers selling at discount or wholesale rates, and using custom items to represent costs that are not products.

Custom items are the part people miss. A draft order line item does not have to point at a variant. Pallet surcharge, artwork setup fee, a carriage charge your rate table cannot express: they can all be typed straight onto the order with a price and a title, and they will never show up in your catalogue.

How do you set a price the storefront would refuse?

Two different fields, and picking the wrong one silently does nothing.

For a real variant sold below catalogue price, use priceOverride. The docs are explicit that this “will be used in place of the product variant’s catalog price in this draft order”. For a custom line item with no variant behind it, use originalUnitPriceWithCurrency, which the reference describes as the price “for a custom line item” and says is ignored when variantId is provided. The older originalUnitPrice is deprecated.

One more trap in DraftOrderInput: customerId is deprecated. Attach the buyer through purchasingEntity instead, which takes either a customerId or a purchasingCompany for B2B. Same idea, one level deeper.

mutation CreateWholesaleDraft($input: DraftOrderInput!) {
  draftOrderCreate(input: $input) {
    draftOrder {
      id
      status
      ready
      invoiceUrl
      totalPriceSet { shopMoney { amount currencyCode } }
    }
    userErrors { field message }
  }
}
{
  "input": {
    "purchasingEntity": { "customerId": "gid://shopify/Customer/1234567890" },
    "poNumber": "PO-4471",
    "reserveInventoryUntil": "2026-10-03T17:00:00Z",
    "tags": ["wholesale"],
    "lineItems": [
      {
        "variantId": "gid://shopify/ProductVariant/44444444",
        "quantity": 24,
        "priceOverride": { "amount": "18.50", "currencyCode": "GBP" }
      },
      {
        "title": "Pallet surcharge",
        "quantity": 1,
        "originalUnitPriceWithCurrency": { "amount": "35.00", "currencyCode": "GBP" },
        "requiresShipping": false,
        "taxable": true
      }
    ],
    "shippingLine": { "title": "Freight", "price": 120.0 }
  }
}

poNumber, note, tags, taxExempt and metafields all sit on the same input, so a quote can carry the reference your finance team will ask about later.

How do you quote a total without creating anything?

draftOrderCalculate takes the same DraftOrderInput and returns a CalculatedDraftOrder with no document saved. Tax, discounts, line totals, shipping, all priced. Nothing to clean up afterwards if the customer says no.

mutation QuoteDraft($input: DraftOrderInput!) {
  draftOrderCalculate(input: $input) {
    calculatedDraftOrder {
      subtotalPriceSet { shopMoney { amount currencyCode } }
      totalTaxSet { shopMoney { amount } }
      totalPriceSet { shopMoney { amount currencyCode } }
      amountDueNowSet { shopMoney { amount } }
      amountDueLaterSet { shopMoney { amount } }
      availableShippingRates { title price { amount currencyCode } }
      warnings { field message }
    }
    userErrors { field message }
  }
}

availableShippingRates comes back empty more often than people expect. The CalculatedDraftOrder reference states the condition plainly: it “requires a customer with a valid shipping address and at least one line item”. Send the calculation without an address and you get a total with no rates, not an error.

amountDueNowSet and amountDueLaterSet are the fields to read when payment terms or a deposit are in play. Do not compute that split yourself. Check warnings and alerts before you put a number in front of a customer, because that is where Shopify puts the reasons a total might not be what the merchant assumes.

For B2B this is the mutation the docs point at specifically. Calculating against a PurchasingEntity is how you find out what a company location actually pays.

How does the customer pay a draft order?

Every draft order carries an invoiceUrl, described as “the link to the checkout, which is sent to the customer in the invoice email”. You can send that link yourself, through whatever channel the conversation is already happening on.

Or let Shopify send it:

mutation SendDraftInvoice($id: ID!) {
  draftOrderInvoiceSend(id: $id) {
    draftOrder { id status invoiceUrl }
    userErrors { field message }
  }
}

The status moves from OPEN to INVOICE_SENT, and reaches COMPLETED only once the order is paid. Those are the only three values in the enum, so treat INVOICE_SENT as your “waiting on the customer” state instead of inventing a parallel one in your own database. The optional email argument overrides the subject line and the message.

What happens to inventory when the draft completes?

Creating a draft order holds no stock at all by default. The draftOrderCreate docs say you cannot reserve or hold inventory for the items by default, and point at reserveInventoryUntil as the opt-in. Pass a DateTime and Shopify holds the stock until then, after which, per the DraftOrder reference, inventory “will automatically be restocked”.

Completion is different. The draftOrderComplete docs are blunt: “When completing a draft order, inventory is reserved for the items in the order. This means the items will no longer be available for other customers to purchase.” Verify availability first. An old quote will complete quite happily against stock somebody else has bought in the meantime.

If you report on inventory states, note the change dated 5 August 2026: inventory previously tracked under reserved for draft orders, transfers and shipments moved to the committed quantity state. A dashboard reading reserved from InventoryLevel.quantities shows a lower number for affected shops, with the difference sitting in committed instead. Our write-up on safe inventory writes with changeFromQuantity covers the other half of that problem.

Then there is ready, the field nobody notices until a mutation fails. It exists because “draft orders might have asynchronous operations that can take time to finish”. Poll it before completing.

query DraftReady($id: ID!) {
  draftOrder(id: $id) {
    ready
    status
    totalPriceSet { shopMoney { amount currencyCode } }
  }
}
mutation CompleteDraft($id: ID!) {
  draftOrderComplete(id: $id) {
    draftOrder {
      status
      order { id name displayFinancialStatus }
    }
    userErrors { field message }
  }
}

The paymentPending argument is deprecated in 2026-07. Do not build on it. Payment terms on the draft order are how you express an order placed now and paid later, and the mutation also takes paymentGatewayId to pick a gateway and sourceName, a channel definition handle, for sales channel attribution. Getting sourceName right is worth five minutes, because it decides whether the merchant’s reporting credits these orders to your integration or to nothing at all.

When is a draft order the wrong tool?

If the customer can serve themselves, they should. A draft order is a merchant-side instrument, and using it to persist an abandoned basket means maintaining a checkout that Shopify already gives you through the Storefront API cart.

There is a housekeeping reason too. Shopify warned on 24 March 2025 that draft orders created on or after 1 April 2025 are purged after a year of inactivity, with automatic removal starting 1 April 2026. Anything you need as a permanent record belongs in a real order, or in your own store.

What I would build: draftOrderCalculate behind the quote screen, draftOrderCreate only once the merchant commits to a number, and reserveInventoryUntil set to the quote’s real expiry rather than an arbitrary week. Wire the ready check into the complete step on day one, not after the first support ticket. Pin the API version while you are there, because these fields move on the quarterly release cadence.

Whoooop builds Shopify integrations where the awkward orders live: wholesale quoting, ERP-driven order entry, and the glue between a merchant’s back office and the Admin API. If that is the shape of your problem, our Shopify development work is where to start.

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