For Shopify agencies
What breaks between quote time and conversion time: 17 failure modes of converting a Shopify B2B quote
By Jahangir Alam · September 22, 2026 · 16 min read
- Last verified
- Shopify API
- 2026-07
- Audience
- Shopify agencies, developers and solution architects
- Scope
- draftOrderCalculate, draftOrderCreate, DraftOrderInput and the orders/create webhook at API 2026-07; what changes between an accepted quote and its draft order, and how a conversion must be built to survive it
Between the moment a buyer accepts a quote and the moment a Shopify draft order exists for it, seventeen things can change under the accepted numbers: the catalog price, the stock, the variant itself, the tax, the shipping rate, the currency, the discounts, the location's terms, the buyer's identity, the quote's own expiry, and the API call that carries all of it.
None of them is a bug in Shopify. Each is Shopify recalculating what it owns at the moment the draft order is created, while the quote layer still holds what was agreed. A conversion that does not expect the gap will ship the wrong order without anyone noticing until the invoice.
This page lists the gaps one by one - what Shopify does in each case, what a quote layer must do about it, and what to check - and then the two mechanics that turn them from surprises into states: a pre-flight calculation compared against the sealed version, and a conversion that can be retried without creating a second draft. It is written for the agency building or evaluating the conversion step, the point where the quote layer touches Shopify most and where what lives where and what a draft order is stop being architecture and start being a Tuesday-morning support ticket.
Everything about Shopify below was checked against Shopify's own pages on 22 September 2026 at API version 2026-07; sources are at the end. Where the page describes how QuotWay handles a case, that is one implementation of the pattern, labelled as such.
Why the gap exists at all
A quote records a price the two sides agreed on. A draft order records what Shopify will charge. The first is a snapshot; the second is a calculation, and Shopify runs it fresh every time - draftOrderCalculate "calculates the properties of a DraftOrder without creating it", returning line totals, shipping rates, discounts and taxes for the input you give it now, against the catalog, inventory, tax settings and shipping rates as they are now. The draft order is then created from the same inputs, and the checkout recalculates once more when the buyer pays.
So there are three points in time - proposal, conversion, checkout - and Shopify owns the state at the last two. The quote layer owns exactly one thing across all three: the sealed record of what was accepted, with each line's price, the quantity, the currency, the terms and the totals as the buyer saw them. Conversion is the act of asking Shopify to honour that record, and finding out what has moved since.
The failure modes
The table is the page. Each row is a condition that can be true at conversion time but was not at proposal time, what Shopify does about it, what the quote layer must do, and how to catch it. "Calculate" means draftOrderCalculate, which costs nothing and creates nothing.
| Condition | What Shopify does | What the quote layer must do | How to catch it |
|---|---|---|---|
| Catalog price changed since the proposal | A line without an override takes the catalog price at creation; a line with priceOverride keeps the override. A B2B draft that was price-locked keeps its locked price |
Send every negotiated variant line with priceOverride in the presentment currency, from the sealed version, never from the live catalog. Show the merchant the baseline the negotiation started from beside the price being written |
Calculate: compare each line's originalUnitPriceSet with the baseline stored at proposal time; a difference is information for the merchant, not a change to the order |
| Stock gone | A draft reserves nothing unless reserveInventoryUntil is set. The draft is created anyway; the buyer's checkout then fails with an out-of-stock error unless the variant allows overselling. Reserved units move to "Committed" until the expiry you set |
Decide per deal whether to reserve, and for how long, at conversion - not at proposal, where nothing should be held. Tell the buyer which it is | Calculate: an inventory-related user error on the call is the signal; treat it as drift to show the merchant, not as a failure of the conversion |
| Variant deleted or product unpublished | The line cannot be priced or stock-checked; there is no variant to attach the override to | Resolve every variant ID before calculating; a line that no longer resolves is either replaced by the merchant (a custom line, or a successor variant) or removed with the buyer's agreement - which is a new version, not an edit | Resolve first; a failed resolve is a hard stop before the calculate |
| Tax recalculated | Taxes follow the store's tax settings and the customer's shipping address; a location's exemption applies through purchasingEntity; taxExempt can be set on the draft; checkout recalculates again |
Quote an estimate and say so; carry the location's exemption onto the draft; compare the calculated tax with the estimate and hold the conversion when the difference is material | Calculate: totalTaxSet vs the accepted version's tax estimate, as a percentage; a threshold decides whether a person looks |
| Shipping | Rates come from availableShippingRates, which needs a valid shipping address and a line item; or a custom shippingLine; a product added after the invoice does not update the rate |
Carry a final quoted rate as a custom shipping line; where the rate was left pending, pick one of Shopify's rates at conversion; never let the invoice go out with the rate the proposal had if the lines changed | Calculate: totalShippingPriceSet and the rate list vs the quoted amount; show the difference, let the merchant choose |
| Currency | One presentmentCurrencyCode per draft; priceOverride is in that currency; currencyCode is the shop currency the calculation ran in; a multi-currency draft with payment terms can only be collected by card or mark-as-paid |
Fix the quote's currency at creation from the buyer's market and never convert it later; create the draft in the same currency; keep an exchange-rate snapshot for reporting only | Assert presentmentCurrencyCode equals the quote's currency before create; refuse otherwise |
| Discounts stack | acceptAutomaticDiscounts applies automatic discounts at calculation; allowDiscountCodesInCheckout lets the buyer add a code at checkout; appliedDiscount adds a custom order-level or line discount |
A negotiated price is already the discount. Decide explicitly whether automatic discounts and checkout codes apply on top - the default should be no - and carry a quote-level discount as one appliedDiscount, allocated per draft when the quote splits |
Calculate: platformDiscounts and totalDiscountsSet should reconcile to what the version says; anything extra is stacking |
| Custom lines | No variant, no inventory, no product page; not usable by automated fulfilment or shipping apps; price from originalUnitPriceWithCurrency; taxable and requiresShipping from the input |
Send the agreed price and flags; expect fulfilment to be manual for those lines; do not try to reserve them | Review: a draft with custom lines needs a person at fulfilment |
| Payment terms | Terms are set on the draft as a template plus a schedule (issuedAt for net terms, dueAt for a fixed date); a draft carries its own terms, and the location's default applies where the draft has none |
Write the terms the deal settled on, not the location's current default; if the deal had none, leave it to the location | Assert the template GID on the accepted version still exists; a deleted template is a hard stop |
| Deposit | A percentage on the draft (deposit), available on Shopify Plus; checkout collects it and schedules the balance |
Carry the negotiated percentage from the accepted version; refuse to create a deposit draft on a non-Plus store | Calculate: amountDueNowSet should equal the deposit share of totalPriceSet |
| Company location missing or changed | purchasingEntity is a customer or a purchasing company, never both; the location's catalog, terms, tax exemption and checkout settings apply only when the location is on the draft; DraftOrderInput.customerId is deprecated at 2026-07 |
Store company, location and contact IDs on the quote and resolve them before create; a contact who moved location, or a location that was deleted, is a stop, not a fallback to a D2C draft | Resolve the three IDs; compare the resolved location with the one the price was derived from |
| Quantity rules | Rules are per variant and re-validated at checkout; a quantity below the minimum, above the maximum or off the increment fails there | Validate quantities against the location's rules at proposal time, and again at conversion because the rules can change | Read quantityRule through contextualPricing for the location; a violation is a stop before create |
| Quote expired, or draft purged | A draft created on or after 1 April 2025 is deleted after a year without an edit; an expired quote is the quote layer's state | Convert at acceptance, once; do not create drafts speculatively at proposal, where they would sit unpaid and age | A draft without an order after N days is a reconciliation case, not a wait |
| Partial acceptance and splits | Each draft order is independent: its own totals, tax, shipping line and terms | One draft per accepted subset; shipping and tax allocated to each by line-subtotal share, or overridden per draft by the merchant; the still-open lines stay on the quote | Sum the drafts' subtotals back to the accepted subset's; the difference is rounding or a bug |
| The create call fails midway | draftOrderCreate takes no idempotency key; a second call is a second draft |
Claim the conversion before calling, record the draft ID the moment it returns, and never auto-retry a create whose outcome is unknown - surface it | Reconcile: a conversion with a calculation but no draft ID and no movement for minutes is a case for a person |
| The order comes back late, twice, or not at all | The order is created when the buyer pays; orders/create is delivered with a 1-second connect and 5-second total timeout, retried 8 times over 4 hours, not guaranteed, possibly duplicated |
Write the order ID from the webhook, matched to the draft through an attribute you put on it; make the handler idempotent; run a reconciliation that asks Shopify whether a draft has an order |
Reconcile: invoice sent, no order after a week → query the draft |
| The draft was edited after the invoice | draftOrderUpdate replaces the input wholesale and unlinks a checkout in progress; the order can then be created while the draft stays open |
Never edit a draft after sending; a change after acceptance is a new version, a new acceptance and a new draft | The quote layer's own rule; nothing to catch if it holds |
Three rows deserve the longer explanation below because they are where a build most often has no design at all: tax drift, the create call, and the webhook.
Two numbers, one threshold
Every row in the table that says "calculate" is the same mechanic: run draftOrderCalculate with exactly the input you are about to create, and compare its result with the sealed version. That gives two numbers per quantity - what was accepted and what Shopify will charge - and the design question is what to do when they differ.
The answer that works is a threshold the merchant sets, applied to tax, because tax is the one Shopify recomputes silently and the buyer never negotiated. Below the threshold, the difference is logged against the quote and the conversion proceeds. Above it, the conversion stops in a "calculated" state and a person decides: accept the recalculated figure, adjust, or go back to the buyer. Shipping drift is shown but does not stop the conversion, because the merchant picks the rate anyway. Inventory drift is shown and the merchant decides whether to reserve, take payment in the admin, or wait.
The threshold is not a tolerance for being wrong. It is the point at which "Shopify's tax engine had newer information than the proposal" becomes "the buyer will notice". A B2B invoice that lands two percent different on tax from the proposal is a rounding conversation; one that lands twenty percent different is a tax-exemption that did not apply, or a shipping address that changed the jurisdiction, and someone should look before it goes out.
Two details make this honest. The comparison has to be against the accepted version's estimate, sealed at acceptance, not against whatever the quote object holds now. And when a quote splits into several drafts, the version's tax and shipping have to be allocated to each draft - by each draft's share of the accepted subtotal - so that each comparison is like with like.
Why conversion must be idempotent
draftOrderCreate has no idempotency key. Call it twice with the same input and you have two draft orders, two invoices, and a buyer who pays one and disputes the other. Everything that can call it twice will, eventually: a job queue that retries on timeout, a merchant who double-clicks, a webhook handler that fires a conversion and is then redelivered.
The pattern has three parts. First, a key per conversion - quote, accepted subset and version - stored unique before the call, so a second attempt finds the first. Second, a claim: the conversion row records that a create is in flight before the request goes out, and records the draft ID the instant the response arrives. Third, a rule for the gap between those two writes: if the process died after Shopify created the draft but before the ID was stored, the conversion must not be retried automatically, because the retry is exactly the duplicate. It is surfaced to an operator, who can look the draft up by its attribute and link it.
That last rule is the one custom builds skip, because it means admitting a state that needs a human. It is also the one that prevents the worst outcome in the table.
The order comes back through a webhook, not a return value
draftOrderCreate returns a draft. It does not return an order, and it cannot, because the order exists only when the buyer pays - or when the merchant completes the draft in the admin. The event that carries the order is orders/create, and it arrives on Shopify's terms: a one-second connection timeout, five seconds for the whole request, eight retries over four hours, no guarantee, and the possibility of the same delivery twice.
A conversion that marks itself "order completed" when the create call succeeds will one day show an order for a checkout that was abandoned. A conversion that trusts the webhook alone will one day miss the order. The pattern is both: the order ID is written only from the webhook, matched to its draft through an attribute the quote layer wrote onto the draft when creating it; the handler treats a second delivery as a no-op; and a reconciliation job asks Shopify directly - draftOrder(id) { order { id } } - for any conversion whose invoice went out and whose order has not arrived within a week, replaying the missed event if the draft turns out to have one. Cancellations and refunds come back the same way, through orders/updated, and are handled with the same idempotency.
How QuotWay handles each row
QuotWay's conversion is the pattern above with numbers on it. Every conversion group is calculated with draftOrderCalculate before draftOrderCreate, and the result is compared with the accepted version's figures allocated to that group by line-subtotal share. Tax drift is measured as a percentage against a per-shop threshold that defaults to two percent; above it, an automatic conversion halts in the calculated state and the merchant takes over. Inventory drift is read from the calculate call's inventory-related user errors and surfaced in the preview rather than failing the conversion; any other user error fails it. Quoted shipping is compared with Shopify's calculated shipping, and where the proposal left the rate pending, Shopify's availableShippingRates are offered so the merchant picks one per group.
Prices reach the draft as priceOverride on variant lines and originalUnitPriceWithCurrency on custom lines, in the quote's currency, which was fixed at creation from the buyer's market and never converted; presentmentCurrencyCode is set to it explicitly. A quote-level discount is written as one appliedDiscount, allocated to each draft when a quote splits. Payment terms go on as the template the deal settled on plus the schedule that template type needs - an issue date for net terms, a due date for a fixed date - and a negotiated deposit percentage rides the draft on Plus stores. For company-aware quotes the location's tax exemption is set on the draft from the location's own tax settings, and lines the location cannot see are flagged again at conversion.
Each conversion carries a key built from the quote, the group and the version, unique in the database, and claims the create before calling; a create that died in flight is never retried by the system - it is listed for the operator. The order ID is written only from orders/create, matched through a single attribute on the draft (quotway_conversion_group_id); a second delivery finds the link already made and does nothing; cancellations and refunds arrive through orders/updated and are recorded once. A sweep closes the three stuck states: a calculation with no draft ID and no movement, an invoice sent with no order after seven days - resolved by asking Shopify whether the draft has an order and replaying the event - and a calculated group nobody has acted on in a day. The merchant-facing account of all this is in convert a quote to a draft order and partial acceptance and split conversion; the conversion feature page shows it from the admin.
A pre-flight list for any build
Run these against the conversion step of any quote layer - an app under evaluation or a custom build - with a test quote whose catalog, stock, tax and shipping you can change between acceptance and conversion. The 50-test launch plan has the fixture.
- Change the catalog price after acceptance, convert: the draft carries the accepted price, and the merchant can see the baseline moved.
- Zero the stock after acceptance, convert: the merchant is told before the invoice goes out, and can reserve or hold.
- Delete a variant on the accepted version, convert: the conversion stops before any Shopify call and names the line.
- Change the shipping address to a different tax jurisdiction, convert: tax drift is measured and, above the threshold, a person is asked.
- Leave shipping pending on the proposal, convert: the merchant chooses from Shopify's rates; the invoice is not sent with the proposal's placeholder.
- Quote in a presentment currency, convert: the draft is in that currency and every line's override is too.
- Enable an automatic discount that covers a quoted product, convert: the draft's total equals the accepted total, not less.
- Put net terms on the location after acceptance, convert: the draft carries the deal's terms.
- Accept a strict subset, convert, accept more, convert again: two drafts, still-open lines untouched, totals sum to the accepted subset.
- Kill the process between create and the database write, then retry: no second draft; the case is listed for an operator.
- Deliver
orders/createtwice: one order ID, one state change. - Block
orders/createentirely, pay the invoice: the reconciliation finds the order.
A build that passes twelve of twelve has designed the gap. One that passes the first six has designed the happy path.
FAQ
Why does the tax on the Shopify order differ from the tax on the quote?
Because Shopify recalculates tax at draft-order creation and again at checkout, from the store's tax settings and the shipping address at that moment, while the quote carried an estimate from proposal time. A changed address, a tax exemption that did not apply through the purchasing entity, or a tax-setting change in between all move the number. The fix is not to make the estimate exact - it cannot be - but to compare the two at conversion and hold the conversion when they differ by more than a threshold.
Why did the quote convert at the wrong price?
Usually one of three things. The line was sent without a priceOverride, so Shopify priced it from the live catalog; the override was sent in the wrong currency; or a discount stacked on top of the negotiated price because automatic discounts or checkout codes were left enabled on the draft. Check the calculate result's originalUnitPriceSet, priceOverride flag and platformDiscounts per line against the accepted version.
What happens if stock runs out between acceptance and conversion?
Shopify creates the draft anyway - a draft holds no stock unless reserveInventoryUntil is set - and the buyer's checkout fails with an out-of-stock error unless the variant allows overselling. draftOrderCalculate surfaces the shortfall as a user error, so a conversion that reads it can warn the merchant first; the merchant can add stock and reserve it, take payment in the admin, or hold the invoice.
Can I retry a failed draftOrderCreate?
Only if you know it failed before Shopify created the draft. The mutation has no idempotency key, so a retry after an unknown outcome can create a second draft. Store an idempotency key and a claim before the call, record the draft ID the instant it returns, and route an unknown outcome to a person rather than a retry loop.
Should the quote layer create the draft order at proposal time to lock the price?
No. A draft does not lock its prices unless told to, holds no stock unless reserved, is deleted after a year of inactivity, and unlinks its checkout if edited. Creating it at proposal puts a mutable, ageing object in the middle of the negotiation. Create it once, at acceptance, from the sealed version.
How do I know the order that arrived belongs to this quote?
By an attribute the quote layer wrote on the draft at creation, read back from the orders/create payload's note attributes, never by matching totals or customers. The webhook can arrive twice and late, so the handler must be idempotent and a reconciliation job must be able to ask Shopify whether a draft has an order.
Does a split conversion charge shipping twice?
It can, because each draft order is independent and carries its own shipping line. The quote layer has to allocate the quoted shipping across the drafts - by each draft's share of the accepted subtotal, or by an explicit per-draft amount the merchant sets - and say so on each invoice.
Sources
Shopify pages, all read on 22 September 2026 at API version 2026-07 unless dated otherwise:
- draftOrderCalculate, CalculatedDraftOrder and DraftOrderWarning
- DraftOrderInput, DraftOrderLineItemInput and PurchasingEntityInput
- draftOrderUpdate and draftOrderComplete (read 21 September 2026)
- Creating draft orders and sending invoices for draft orders
- Creating B2B orders using draft orders and setting up payment terms in B2B (read 21 September 2026)
- Quantity rules and volume pricing (read 21 September 2026)
- Webhooks and verifying webhook deliveries (read 20 September 2026)
QuotWay's conversion, drift, idempotency and reconciliation behaviour is described from its source; the merchant-facing account is in the docs linked above. Plan differences are on the pricing page; the draft-order and payment-terms facts are kept current in the Shopify B2B reference.
Related articles
- For Shopify agenciesCompanies, locations, catalogs and the price a Shopify B2B quote should start from15 min read
- For Shopify agenciesA Shopify draft order is not a quote: the state machine each one models16 min read
- For Shopify agenciesShopify B2B launch checklist: 50 tests to run before going live14 min read
See how QuotWay handles this on your store.