<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Adab ul Qayyum</title>
    <description>The latest articles on DEV Community by Adab ul Qayyum (@adab).</description>
    <link>https://dev.to/adab</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4119230%2F28a7186e-5408-4614-87d2-a8c1f3e71d1f.png</url>
      <title>DEV Community: Adab ul Qayyum</title>
      <link>https://dev.to/adab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adab"/>
    <language>en</language>
    <item>
      <title>Shopify Flow can send customer email. It just can't send this kind.</title>
      <dc:creator>Adab ul Qayyum</dc:creator>
      <pubDate>Thu, 17 Sep 2026 12:27:48 +0000</pubDate>
      <link>https://dev.to/adab/shopify-flow-can-send-customer-email-it-just-cant-send-this-kind-5hhp</link>
      <guid>https://dev.to/adab/shopify-flow-can-send-customer-email-it-just-cant-send-this-kind-5hhp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.heapbyte.com/insights/operational-order-email-shopify" rel="noopener noreferrer"&gt;HeapByte blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A delivery slips by three weeks. Forty open orders are affected, not identically — some can ship short today, some are waiting on the same container, a few have a substitution the rep has already agreed. Someone has worked out which is which, and now has to tell forty customers, each one something slightly different, today.&lt;/p&gt;

&lt;p&gt;The information is in the portal. The email is not. And there is no button in Shopify that sends a specific message to a specific set of orders because someone decided it should go.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Shopify's notifications are for
&lt;/h2&gt;

&lt;p&gt;Shopify's notification system is better than it gets credit for. Templates are Liquid, each one has access to the properties of its corresponding order, and they are editable in the admin once the sender address is confirmed. There is a quirk worth knowing — the order object is not referenced by name, so it is &lt;code&gt;shipping_method.title&lt;/code&gt; rather than &lt;code&gt;order.shipping_method.title&lt;/code&gt; — but the model is sound and for the job it does, it is the right tool.&lt;/p&gt;

&lt;p&gt;That job is one email per event, per order, automatically. An order is placed, a confirmation goes out. A fulfilment is created, a shipping notification goes out. The store decides, the customer is told, nobody touches it. For the overwhelming majority of what a shop needs to say, that is exactly right and you should not be building anything.&lt;/p&gt;

&lt;p&gt;Custom data can partly reach these templates. Metafield support in notifications exists but is uneven — some types in some templates — and shop metafields are still an open feature request in Shopify's own developer community. So it is worth checking what is reachable before designing around it, rather than assuming either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flow, and the shape it produces
&lt;/h2&gt;

&lt;p&gt;The obvious objection is Shopify Flow, and it deserves a straight answer rather than a dismissal, because Flow is more capable here than most people realise.&lt;/p&gt;

&lt;p&gt;Flow's &lt;strong&gt;Send HTTP request&lt;/strong&gt; action will call an external system, store its secrets properly, and return the full response into the workflow, where a &lt;strong&gt;Run code&lt;/strong&gt; step parses it into variables the later steps can use. On Grow, Advanced and Plus, that means ERP data can genuinely reach a customer email without anybody writing an application. If your requirement is &lt;em&gt;when an order's expected date changes in the ERP, the customer is told&lt;/em&gt;, Flow will do it, and building something else would be wasteful.&lt;/p&gt;

&lt;p&gt;Two things bound it.&lt;/p&gt;

&lt;p&gt;Flow's own email action is &lt;strong&gt;Send internal email&lt;/strong&gt; — the documentation says it is best used to send emails to staff, that to automate emails to customers you should create a marketing automation instead, and that you cannot use variables to set the recipient address.&lt;/p&gt;

&lt;p&gt;And a marketing automation is a marketing artefact: consent-gated, campaign-shaped, built around a trigger and an audience rather than a person and a decision.&lt;/p&gt;

&lt;p&gt;That is the fork. Not capability. Shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending is a decision, not a trigger
&lt;/h2&gt;

&lt;p&gt;Everything above is automation: a condition becomes true, a message goes out. The email this article is about is the other kind. A human being looked at a situation, decided what should happen, and now needs to say so — to these orders, not to a segment; today, not on a trigger; in wording that reflects a judgement nobody has encoded and probably cannot.&lt;/p&gt;

&lt;p&gt;Once you put it that way the requirements fall out, and none of them are about email.&lt;/p&gt;

&lt;p&gt;The sending surface has to live where the decision was made, which is the operational portal, because an employee who has just worked out which orders can ship should not then reconstruct that list somewhere else. It has to address a chosen set, with each message carrying its own order's data. It has to be editable by the people who write to customers for a living, without a deploy. And it has to leave a record on the order, because &lt;em&gt;whether we told them&lt;/em&gt; is a question that gets asked three weeks later by someone who was not involved.&lt;/p&gt;

&lt;p&gt;That last one is what turns it from a feature into part of the operation. &lt;strong&gt;An email that is not logged against the order did not, operationally speaking, happen.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which orders those are is &lt;a href="https://www.heapbyte.com/insights/wholesale-order-allocation-shopify" rel="noopener noreferrer"&gt;a calculation in its own right&lt;/a&gt;, and it comes first — the email explains a decision that something else has already made.&lt;/p&gt;

&lt;h2&gt;
  
  
  A template the team can edit, safely
&lt;/h2&gt;

&lt;p&gt;Letting non-developers edit HTML that goes to customers is the part that needs care, and the care is mostly about refusing things.&lt;/p&gt;

&lt;p&gt;Placeholders resolve from an explicit allow-list rather than from the order object directly. The allow-list is a smaller surface to document, it means a field can be renamed internally without breaking every template, and it makes an undefined placeholder detectable instead of empty.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ESCAPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;#39;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escapeHtml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;&amp;amp;&amp;lt;&amp;gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ESCAPES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="cm"&gt;/** Every placeholder the team may use, and where each one comes from. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;order.number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;order.date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.company&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;order.total&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;formatted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;order.expected_date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expectedDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PLACEHOLDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{\{\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9_.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\}\}&lt;/span&gt;&lt;span class="sr"&gt;/gi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderTemplate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;template&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;template must be a string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PLACEHOLDER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_match&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rawKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FIELDS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Empty string counts as missing. Rendering it as nothing produces&lt;/span&gt;
    &lt;span class="c1"&gt;// "Your order for  will ship on .", which reads as carelessness to the&lt;/span&gt;
    &lt;span class="c1"&gt;// customer and is invisible to whoever wrote the template.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`unknown placeholder(s): &lt;/span&gt;&lt;span class="p"&gt;${[...&lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`no value for placeholder(s): &lt;/span&gt;&lt;span class="p"&gt;${[...&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Two things a template engine for non-developers has to refuse. A placeholder nobody defined is a typo, and rendering it as nothing produces a sentence like "Your order for will ship on ." — which reads as carelessness to the customer and is invisible to the person who wrote the template, because on their screen it looked like a field. A value that is merely missing on this particular order is the same sentence, arriving for one customer in forty. Both fail the whole send rather than degrade quietly: a refused job gets looked at, and a badly rendered email gets forwarded to a sales rep three days later.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HTML rather than a builder
&lt;/h2&gt;

&lt;p&gt;The team gets HTML and CSS rather than a drag-and-drop builder, which sounds like the worse option and was not. A builder constrains what can be expressed and still needs maintaining; a text area with a documented list of placeholders and a preview turned out to be more flexible and considerably less code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deliverability is DNS, not code
&lt;/h2&gt;

&lt;p&gt;The part that catches people out is not the application. Transactional deliverability is largely a function of DNS records and sender reputation — SPF, DKIM, DMARC, a sending domain that has a history — and a correct, well-tested email module will still land in spam if that groundwork is missing.&lt;/p&gt;

&lt;p&gt;Which is also why sending is worth abstracting from the moment you start. Templates, recipients and logging are your domain; the thing that actually delivers is a vendor you may well change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It does not replace Shopify's notifications&lt;/strong&gt; and should not try. Order confirmations and shipping notifications are event-driven, they work, and moving them into a custom system buys you nothing and a maintenance obligation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is not marketing email.&lt;/strong&gt; No campaigns, no segments, no unsubscribe flows — and the moment somebody asks for a newsletter through it, the answer is a marketing tool, because consent handling is a legal surface that does not belong in an order portal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not write the message.&lt;/strong&gt; Bulk sending makes it trivial to send forty customers the same paragraph, which is worse than silence when the forty situations differ. The tool removes the labour of sending; the judgement about what to say stays where it was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it inherits its data.&lt;/strong&gt; An email composed from a stale cache will confidently tell a customer a date the ERP has already moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this needs an engineer
&lt;/h2&gt;

&lt;p&gt;Frequently it does not. If the emails a business needs to send are event-shaped, Shopify's notifications cover them. If they are condition-shaped, Flow covers them, including with data fetched from an external system. If a person needs to write to a customer occasionally, that is what an email client is for, and building software to replace typing is rarely the win it looks like.&lt;/p&gt;

&lt;p&gt;It becomes engineering when customer communication is a step in an operational workflow rather than an occasional courtesy — when the information only exists after a calculation, when it has to go to a set someone chose rather than a segment a rule matched, and when &lt;em&gt;whether it was sent&lt;/em&gt; is a question the business needs answered later. That was the case for a wholesale operation whose &lt;a href="https://www.heapbyte.com/work/collections/b2b-operations" rel="noopener noreferrer"&gt;supplier and order tooling&lt;/a&gt; already knew what could ship; the &lt;a href="https://www.heapbyte.com/work/weldaad-order-email-system" rel="noopener noreferrer"&gt;email module&lt;/a&gt; was built into that portal rather than beside it, because the send belongs where the decision was made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shopify's notifications are event-triggered, Liquid, and render their own order. For one message per event per order they are the right tool and nothing needs building.&lt;/li&gt;
&lt;li&gt;Shopify Flow can reach external systems — the Send HTTP request action returns data into the workflow on Grow, Advanced and Plus — so &lt;em&gt;the ERP data cannot get into a customer email&lt;/em&gt; is not the limitation. What Flow produces for customers is a marketing automation.&lt;/li&gt;
&lt;li&gt;The real distinction is who initiates and at what granularity: a person choosing forty specific orders today is not an event, a trigger, or an audience.&lt;/li&gt;
&lt;li&gt;An operational email that is not logged against the order did not happen, as far as anyone asking three weeks later is concerned.&lt;/li&gt;
&lt;li&gt;Deliverability is mostly DNS and sender reputation, not application code — and the sending vendor should be abstracted from day one, because it is the part most likely to change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing I'd like other people's take on.&lt;/p&gt;

&lt;p&gt;The template engine fails the entire send when a placeholder has no value on one order out of forty. That was deliberate — a refused job gets looked at, a badly rendered email gets forwarded to a sales rep three days later. But it means one incomplete record blocks thirty-nine good emails, and the person hitting send is usually under time pressure.&lt;/p&gt;

&lt;p&gt;The alternative is per-order validation with a preflight screen: show which orders will fail and why, let the operator send the rest. More code, more surface, and it puts a decision in front of someone who wanted a button.&lt;/p&gt;

&lt;p&gt;If you've built something similar, which way did you go — and did you regret it?&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>javascript</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Shopify's Delivery Customization API can't calculate a shipping rate</title>
      <dc:creator>Adab ul Qayyum</dc:creator>
      <pubDate>Wed, 16 Sep 2026 09:00:27 +0000</pubDate>
      <link>https://dev.to/adab/shopifys-delivery-customization-api-cant-calculate-a-shipping-rate-207g</link>
      <guid>https://dev.to/adab/shopifys-delivery-customization-api-cant-calculate-a-shipping-rate-207g</guid>
      <description>&lt;p&gt;You reach for a Shopify Function because it's the modern API and the docs are good. You want a shipping rate calculated from the dimensions the customer just configured. You open the Delivery Customization API and find it has three operations: hide, rename, reorder.&lt;/p&gt;

&lt;p&gt;None of them calculate anything.&lt;/p&gt;

&lt;p&gt;That isn't a gap in the documentation — it's the design. A 2.4-metre stone slab and a 300mm shelf can be the same Shopify product with different options selected. One goes on a pallet, on a lorry with a tail lift, to a kerbside delivery someone has to be present for. The other goes in a van. Weight bands and rate tables both assume what it costs to deliver something can be worked out from the product record. For a configured product it can't, because the thing that determines the size doesn't exist until the customer builds it.&lt;/p&gt;

&lt;p&gt;The two APIs, and what each is for&lt;/p&gt;

&lt;p&gt;This is where most of the wasted time happens, and the naming does not help.&lt;/p&gt;

&lt;p&gt;Delivery Customization Functions modify the shipping options a customer already sees. Three operations, exactly what they sound like: hide an option, rename an option, reorder the list. That is the entire surface. A Function can suppress "Express" for a hazardous item, rename "Standard" to "Kerbside delivery, 3–5 days", or float the cheapest option to the top.&lt;/p&gt;

&lt;p&gt;It cannot create an option, and it cannot change a price.&lt;/p&gt;

&lt;p&gt;The Carrier Service API is the other thing entirely. You register an endpoint; at checkout, Shopify posts the cart to it and displays whatever rates come back. It is the only route to a rate that is calculated rather than configured.&lt;/p&gt;

&lt;p&gt;The mistake is reaching for the Function. It is the newer API, it runs on Shopify's infrastructure, it has no endpoint to host, and everything modern about Shopify extensibility points at Functions — so people build one, get it working, and only then discover it can reorder the wrong rate but not produce the right one. That is an easy mistake, and it costs a week.&lt;/p&gt;

&lt;p&gt;What the Carrier Service API demands&lt;/p&gt;

&lt;p&gt;It is the right answer sometimes. It is never the cheap answer, and the cost is operational rather than technical.&lt;/p&gt;

&lt;p&gt;A plan requirement, with more routes than people expect. Advanced or higher qualifies. So does the Shopify plan on yearly billing, and so does paying a monthly fee for the carrier-calculated shipping feature on a lower plan. Worth knowing the reverse too: if a store changes plan and no longer qualifies, its carrier service association is deactivated. Nothing announces this to the customer.&lt;/p&gt;

&lt;p&gt;An endpoint in the checkout path. Every rate request at checkout now depends on a service you operate. That is a different availability conversation than the one you were having about your app yesterday.&lt;/p&gt;

&lt;p&gt;A deadline that tightens as you get busier. Shopify allows ten seconds under 1,500 requests a minute, five seconds between 1,500 and 3,000, and three seconds above that — with no retry. The response has to be right first time. This is the constraint that catches people, because it is backwards from how load usually feels: the busier the day, the less time you get. A rate service that calls a carrier's API live will pass every test you write and fail on the day it matters.&lt;/p&gt;

&lt;p&gt;Caching that makes debugging feel haunted. Successful responses are cached for fifteen minutes, errors for thirty seconds. You will change your rate logic, reload checkout, see the old number, and lose an afternoon. Worth knowing precisely what counts as the same request: variant IDs, box weight and dimensions, quantities, carrier service ID, origin, destination, item weights. Line item properties are not named in that list. In practice two configurations that differ in size usually differ in weight, which is enough to miss the cache — but a configuration that changes shape without changing weight can be served a stale rate for a quarter of an hour.&lt;/p&gt;

&lt;p&gt;A failure mode the customer never sees. If your endpoint times out or errors, Shopify shows backup rates: its own generic rates, so checkout still completes. The customer picks one. The order goes through. For an oversized item that rate is very unlikely to cover what the delivery actually costs. You find out from a banner on the order page in your admin, telling you a backup rate was used — on an order you may already have shipped.&lt;/p&gt;

&lt;p&gt;That is the same shape as most expensive commerce bugs. Nothing failed loudly. Something just quietly cost you money.&lt;/p&gt;

&lt;p&gt;Getting the configuration into the rate request&lt;/p&gt;

&lt;p&gt;The good news is that the configuration can travel. Shopify's rate request includes each line's properties — the same line item properties that carry a configured product's price.&lt;/p&gt;

&lt;p&gt;The bad news is what is not in it: variant dimensions. You get grams, and you do not get size. So if size drives the rate, size has to arrive in the properties or be looked up against the variant ID before the deadline expires.&lt;/p&gt;

&lt;p&gt;Which means this sits directly on top of a problem I've written about separately: those properties are authored by the browser, and where price authority lives applies unchanged to size. A dimension arriving in a rate request is a claim, exactly as a price is. The practical consequence for shipping is narrower and more immediate — an item whose configuration is missing or unreadable should be declined rather than guessed at. Guessing is how a two-metre slab ships on a parcel rate.&lt;/p&gt;

&lt;p&gt;What you actually calculate&lt;/p&gt;

&lt;p&gt;None of the API documentation covers this part, and it is most of the work.&lt;/p&gt;

&lt;p&gt;Chargeable weight is the greater of actual weight and volumetric weight, because carriers bill for space as well as mass. The divisor that converts volume to weight is a commercial term you negotiate — 5000 and 6000 are common — not a constant, which means it belongs in configuration and not in code.&lt;/p&gt;

&lt;p&gt;The pallet threshold is usually a longest-side rule rather than a weight rule. A long thin item can be light and still need a lorry, which is exactly the case that breaks weight-band thinking.&lt;/p&gt;

&lt;p&gt;Consolidation, because two items may or may not travel together, and whether they do changes the answer more than either item's own dimensions.&lt;/p&gt;

&lt;p&gt;Constraints that are not prices at all. Some goods need two people. Some need a tail lift or a booked slot. Those are delivery methods that must be offered or withheld, and getting that wrong produces a delivery that cannot be completed rather than one that is merely mispriced. Building custom rate logic for a stone merchant, the conclusion the work kept arriving at was that shipping rules are business logic and belong in a testable layer — not in the theme, and not in a rate table pretending to be one.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
/** Reads a configuration value out of Shopify's line item properties. */&lt;br&gt;
export function readProperty(item, key) {&lt;br&gt;
  const props = item.properties;&lt;br&gt;
  if (!props || typeof props !== "object") return undefined;&lt;br&gt;
  // Shopify sends properties as an object, and null when there are none.&lt;br&gt;
  const value = props[key];&lt;br&gt;
  return value === undefined || value === null || value === "" ? undefined : value;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;/**&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chargeable weight is the greater of actual and volumetric weight. Carriers&lt;/li&gt;
&lt;li&gt;bill for the space a thing occupies as well as its mass, which is the whole&lt;/li&gt;
&lt;li&gt;reason a configured product cannot be priced from its weight alone.
*&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;divisor&lt;/code&gt; is the carrier's volumetric factor in cm³ per kg — 5000 and 6000&lt;/li&gt;
&lt;li&gt;are the common ones, and it is a commercial term, not a constant.
*/
export function chargeableWeightGrams(actualGrams, dimsCm, divisor) {
if (!Number.isFinite(divisor) || divisor &amp;lt;= 0) {
throw new Error(&lt;code&gt;volumetric divisor must be a positive number, got ${divisor}&lt;/code&gt;);
}
if (!dimsCm) return actualGrams;
const { lengthCm, widthCm, heightCm } = dimsCm;
if (![lengthCm, widthCm, heightCm].every((n) =&amp;gt; Number.isFinite(n) &amp;amp;&amp;amp; n &amp;gt; 0)) {
return actualGrams;
}
const volumetricGrams = Math.ceil(((lengthCm * widthCm * heightCm) / divisor) * 1000);
return Math.max(actualGrams, volumetricGrams);
}&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;/** Longest single dimension across the cart, which is what decides the vehicle. */&lt;br&gt;
function longestSideCm(parcels) {&lt;br&gt;
  return parcels.reduce((max, p) =&amp;gt; {&lt;br&gt;
    if (!p.dimsCm) return max;&lt;br&gt;
    return Math.max(max, p.dimsCm.lengthCm, p.dimsCm.widthCm, p.dimsCm.heightCm);&lt;br&gt;
  }, 0);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;/**&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Builds the parcel list from the rate request, taking dimensions from the&lt;/li&gt;
&lt;li&gt;line item properties the configurator wrote.
*&lt;/li&gt;
&lt;li&gt;An item whose configuration is missing is returned in &lt;code&gt;unconfigured&lt;/code&gt; rather&lt;/li&gt;
&lt;li&gt;than guessed at. Guessing is how a two-metre slab ships on a parcel rate.
*/
export function parcelsFrom(rateRequest, { divisor, dimensionKeys }) {
const items = rateRequest?.rate?.items ?? [];
const parcels = [];
const unconfigured = [];&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;for (const item of items) {&lt;br&gt;
    if (item.requires_shipping === false) continue;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const lengthCm = Number(readProperty(item, dimensionKeys.length));
const widthCm = Number(readProperty(item, dimensionKeys.width));
const heightCm = Number(readProperty(item, dimensionKeys.height));
const configured = [lengthCm, widthCm, heightCm].every((n) =&amp;gt; Number.isFinite(n) &amp;amp;&amp;amp; n &amp;gt; 0);

if (!configured) {
  unconfigured.push(item);
  continue;
}

const dimsCm = { lengthCm, widthCm, heightCm };
for (let i = 0; i &amp;lt; item.quantity; i++) {
  parcels.push({
    dimsCm,
    chargeableGrams: chargeableWeightGrams(item.grams, dimsCm, divisor),
  });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;return { parcels, unconfigured };&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;/**&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rates for one cart.
*&lt;/li&gt;
&lt;li&gt;Returns Shopify's expected shape: total_price is an integer of subunits, and&lt;/li&gt;
&lt;li&gt;an empty array means "this carrier service cannot rate this cart", which is a&lt;/li&gt;
&lt;li&gt;successful response rather than an error. Returning an error instead would&lt;/li&gt;
&lt;li&gt;put the customer on backup rates, which for oversized goods is usually a&lt;/li&gt;
&lt;li&gt;price that does not cover the delivery.
*/
export function ratesFor(rateRequest, tariff) {
const { parcels, unconfigured } = parcelsFrom(rateRequest, tariff);&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;// Anything we cannot size, we decline to rate. Silence is safer than a guess.&lt;br&gt;
  if (unconfigured.length &amp;gt; 0 || parcels.length === 0) return [];&lt;/p&gt;

&lt;p&gt;const totalGrams = parcels.reduce((sum, p) =&amp;gt; sum + p.chargeableGrams, 0);&lt;br&gt;
  const longest = longestSideCm(parcels);&lt;br&gt;
  const currency = rateRequest.rate.currency;&lt;/p&gt;

&lt;p&gt;const rates = [];&lt;br&gt;
  for (const band of tariff.bands) {&lt;br&gt;
    if (longest &amp;gt; band.maxLongestSideCm) continue;&lt;br&gt;
    if (totalGrams &amp;gt; band.maxChargeableGrams) continue;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const subunits =
  band.baseSubunits + Math.ceil((totalGrams / 1000) * band.perKgSubunits);

rates.push({
  service_name: band.name,
  description: band.description,
  service_code: band.code,
  currency,
  total_price: String(subunits),
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;return rates;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Pure and synchronous, because the deadline shrinks to three seconds under load with no retry — do the I/O before the request arrives and make the request itself arithmetic. An unsized cart returns an empty array, which is the documented "cannot handle this request" response; returning an error instead would put the customer on backup rates.&lt;/p&gt;

&lt;p&gt;The cheaper answers, and when they're right&lt;/p&gt;

&lt;p&gt;Most merchants reading this should not build a rate service.&lt;/p&gt;

&lt;p&gt;A flat rate with margin built in is correct far more often than it is given credit for. If your spread of real delivery costs is narrow enough that one number loses acceptably little on the worst case, that number costs nothing to run and never times out at checkout.&lt;/p&gt;

&lt;p&gt;Quote-on-request for oversized items is not an automation failure. For a genuinely bespoke item it is the honest process — it puts a person in front of a delivery that needs one, and customers buying a two-metre slab are not surprised to be asked.&lt;/p&gt;

&lt;p&gt;Rate tables by product group approximate well when the variation within each group is small. Three groups with three rates will beat a broken rate service every day.&lt;/p&gt;

&lt;p&gt;The test is straightforward. Estimate the error band on the simplest option that could work. Compare it with the cost of building, hosting and maintaining a service that sits in your checkout path with a three-second deadline. If the error band is cheaper, build nothing — that is a real answer and it is frequently the right one.&lt;/p&gt;

&lt;p&gt;When this needs an engineer&lt;/p&gt;

&lt;p&gt;You need computed rates when the cost to deliver is genuinely a function of what the customer configured, when the spread is wide enough that approximating it loses real money, and when getting it wrong produces deliveries that cannot be completed rather than margins that are slightly off.&lt;/p&gt;

&lt;p&gt;Those three together are a narrow case. When they hold, the work is a rate service built properly — inside the deadline, honest about what it cannot size, and reconciled against what the carrier actually invoiced. Across the configurator work the pattern was consistent: the calculation was never the hard part. Everything around it was.&lt;/p&gt;

&lt;p&gt;Takeaways&lt;br&gt;
Delivery Customization Functions hide, rename and reorder delivery options. They cannot create one and they cannot change a price, so they are never the route to a computed rate — which is the week most people lose to this topic.&lt;br&gt;
The Carrier Service API is the only route to a calculated rate. It needs Advanced or higher, the Shopify plan on yearly billing, or a paid add-on — and a plan downgrade silently deactivates the association.&lt;br&gt;
The response deadline tightens as the store gets busier: ten seconds under 1,500 requests a minute, five to 3,000, three above, with no retry. Calling a carrier API live passes every test and fails on the busiest day.&lt;br&gt;
Rate responses cache for fifteen minutes. Line item properties are not named in the documented match list, so a configuration that changes shape without changing weight can be served a stale rate.&lt;br&gt;
On failure the customer sees backup rates rather than an error, so an oversized order completes at a generic price. You find out from a banner in the admin, on an order you may already have shipped.&lt;/p&gt;

</description>
      <category>code</category>
      <category>ecommerce</category>
      <category>shopify</category>
    </item>
    <item>
      <title>Supplier portals — the operations layer Shopify doesn't have</title>
      <dc:creator>Adab ul Qayyum</dc:creator>
      <pubDate>Fri, 11 Sep 2026 07:41:13 +0000</pubDate>
      <link>https://dev.to/adab/supplier-portals-the-operations-layer-shopify-doesnt-have-25da</link>
      <guid>https://dev.to/adab/supplier-portals-the-operations-layer-shopify-doesnt-have-25da</guid>
      <description>&lt;p&gt;Originally published on the &lt;a href="https://www.heapbyte.com/insights/shopify-supplier-portal-architecture" rel="noopener noreferrer"&gt;HeapByte blog&lt;/a&gt;. Shopify B2B is no longer Plus-only, which is genuinely useful — but every B2B feature is buyer-side. There's still no model of a supplier.&lt;/p&gt;




&lt;p&gt;The purchase order leaves as an email attachment. Six weeks later a pallet arrives. Someone counts it against a delivery note. Someone updates a spreadsheet. Someone else, later and probably on a different day, updates Shopify. In between, a product that is physically in the building is either invisible to the store or sellable when it should not be, depending on which of those steps has happened yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two failure modes, both quiet
&lt;/h2&gt;

&lt;p&gt;The first is that stock becomes sellable before anyone has inspected it. To the store, "arrived" and "passed quality control" are the same state, because the store only has one number and no concept of the difference. So the first customer to order the new line gets whatever turned up on the pallet, inspected or not.&lt;/p&gt;

&lt;p&gt;The second is that the count is right in one system and wrong in another, and there is no way to tell which without physically recounting. Neither raises an error. Neither appears in a log. You find out at a stock take, or from a customer.&lt;/p&gt;

&lt;p&gt;This is usually described as a missing feature. It is not. It is a sales system being asked to carry an operation it was never modelling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Shopify does and does not have
&lt;/h2&gt;

&lt;p&gt;Shopify models one relationship well: you sell to someone. B2B extended that to "you sell to a company", which was a genuine improvement — and, worth updating if you last looked a while ago, it is no longer a Plus feature. Companies, company locations, net terms, draft orders and quantity price breaks now reach Basic, Grow and Advanced — with, in Shopify's own wording, "up to 3 active catalogs across all your B2B markets" rather than the unlimited number Plus gets.&lt;/p&gt;

&lt;p&gt;Every one of those is buyer-side. There is a "PO number" field in B2B, which is the closest thing to a trap in this whole topic: it is a reference the buying company puts on an order you are fulfilling. It is not a purchase order you raised.&lt;/p&gt;

&lt;p&gt;Shopify is not entirely silent on inbound stock. Inventory transfers move and track inventory between your locations, or to and from external locations such as suppliers, and you can receive a transfer partially rather than all at once. That is real and it is useful.&lt;/p&gt;

&lt;p&gt;What there is no model of is the counterparty. Nobody logs in. Nobody confirms. Nobody disputes a quantity, uploads a certificate, or is held to a specification they agreed to eight weeks ago. Shopify can record that stock is coming and that some of it arrived. It cannot record the relationship that produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a supplier portal actually has to do
&lt;/h2&gt;

&lt;p&gt;Six things, and the first one constrains everything after it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication for people who are not customers.&lt;/strong&gt; A supplier is not a buyer and should not be a customer record — not in your customer list, not in a marketing audience, not holding a storefront login. Shopify offers exactly two identity models and a supplier fits neither. How you resolve that decides where the portal can live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Purchase order visibility and confirmation.&lt;/strong&gt; The supplier sees what was ordered and responds: yes, no, or yes but not all of it and not by then. The value is that the response becomes a recorded state rather than a sentence in a reply nobody can find in March.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial deliveries, and the balance that survives them.&lt;/strong&gt; Receiving half a line is the easy part. Knowing what half is still owed, against which line, at what price, and when it is now expected — that is the part that lives in somebody's head.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A state between "arrived" and "sellable".&lt;/strong&gt; This is the gap nothing native models. Design approval, sampling, countersampling: each is a gate, and stock that has cleared none of them is in the building and must not be sold. Building the &lt;a href="https://www.heapbyte.com/work/weldaad-supplier-qc-workflow" rel="noopener noreferrer"&gt;QC workflow&lt;/a&gt; for a wholesale operation, the thing that mattered was not adding a status field — it was calculating readiness rather than leaving it to someone scanning a long table. A reviewer looking at forty rows will not reliably notice one blank carton barcode. The cost of not noticing is a product published without it.&lt;/p&gt;

&lt;p&gt;Worth one warning from that build: a required numeric field sitting at zero looks populated to anything checking for empty. A carton quantity of zero is not a quantity, it is a form nobody filled in, and the validation has to know the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Document handling.&lt;/strong&gt; Artwork, specifications, certificates, attached to the line they belong to rather than to an inbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An audit trail&lt;/strong&gt;, because disputes happen and "who approved this, and when" has to be answerable long after everyone has forgotten.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** QC gates, in the order they are passed. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;design&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sample&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;countersample&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * A field is present when it holds a usable value.
 *
 * Zero is the trap. A required numeric field left at 0 looks populated to
 * anything checking for null or "", and a carton quantity of zero is not a
 * quantity — it is a form nobody filled in. Numeric fields therefore declare
 * whether zero is a legitimate value for them.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isPresent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;numeric&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zeroAllowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Coerce before deciding. A form POST sends "0" as a string, so testing the&lt;/span&gt;
    &lt;span class="c1"&gt;// string branch first would let a zero through as a populated field — which&lt;/span&gt;
    &lt;span class="c1"&gt;// is the exact failure this function exists to catch.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;zeroAllowed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Evaluates one product against a field specification.
 *
 * Returns the missing field labels rather than a bare boolean, because the
 * useful output is the list a supplier has to act on, not the verdict.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readiness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isPresent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;zeroAllowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zeroAllowed&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gatesPassed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gates&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gatesOutstanding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gates&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;gatesOutstanding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;gatesPassed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;gatesOutstanding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Rolls product readiness up to the purchase order.
 *
 * An order is not ready because most of it is. One incomplete line holds the
 * whole order, which is exactly the thing a progress badge has to communicate
 * without anyone opening the rows.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;orderReadiness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;readiness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;readyCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Readiness calculated rather than eyeballed. The zero handling is the point: a form POST sends &lt;code&gt;"0"&lt;/code&gt; as a string, and an earlier version of this checked the string branch first — so a required numeric field sitting at zero passed as populated, which is the exact failure the function exists to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it should live
&lt;/h2&gt;

&lt;p&gt;Three homes, and no universally right answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedded in Shopify as an app&lt;/strong&gt; keeps you closest to the catalogue, with one place for staff to work and one login to manage. The difficulty is the suppliers: they do not belong in Shopify admin, and the customer-account route means modelling them as customers, which is a decision that reaches a lot further than this portal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alongside, as a separate application&lt;/strong&gt;, gives you your own authentication and your own data model, and stops you bending suppliers into an identity Shopify already defined. The &lt;a href="https://www.heapbyte.com/work/weldaad-supplier-portal" rel="noopener noreferrer"&gt;portal we built&lt;/a&gt; took this route: purchase orders and their line items are stored independently of Shopify products, so suppliers work on draft data and nothing reaches the live catalogue until it is approved. The cost is a second system to run and a synchronisation surface to keep honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside the ERP&lt;/strong&gt; is where procurement usually already lives, and where stock and cost are already authoritative. You inherit the ERP's interface and its release cycle, and your suppliers will experience both.&lt;/p&gt;

&lt;p&gt;The question that actually decides it is not which stack you prefer. It is where authority for this data already sits — because whichever you choose, the other two will be reading from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes stock sellable
&lt;/h2&gt;

&lt;p&gt;Whatever you build, it comes down to one narrow question at the boundary: what makes stock sellable, and what holds it back.&lt;/p&gt;

&lt;p&gt;That means deciding, field by field, which system is allowed to be right. The portal owns readiness, because it is the only thing that knows whether QC passed. The ERP usually owns stock and cost. Shopify owns what is published and what a customer can buy. Written down, it is obvious; skipped, you get two systems both confident and disagreeing.&lt;/p&gt;

&lt;p&gt;The synchronisation itself is its own discipline, with failure modes worth understanding before you design around them — we covered those in &lt;a href="https://www.heapbyte.com/insights/exact-online-connector-limits" rel="noopener noreferrer"&gt;the connector piece&lt;/a&gt;. The relevant part here is that the sync has to carry a state, not just a number. "Forty units" is not the same fact as "forty units, none of which have passed countersampling."&lt;/p&gt;

&lt;h2&gt;
  
  
  When this needs an engineer
&lt;/h2&gt;

&lt;p&gt;If you place a few orders a month with two suppliers you have known for years, a spreadsheet and a shared drive genuinely work, and nobody should sell you software for it.&lt;/p&gt;

&lt;p&gt;It stops working at a specific point: when you cannot answer "is this order ready?" without opening it and checking every line by eye. That is the moment the information exists but is not legible, and it does not improve by adding columns. Five builds into one wholesale operation's &lt;a href="https://www.heapbyte.com/work/collections/b2b-operations" rel="noopener noreferrer"&gt;supplier and back-office tooling&lt;/a&gt;, the pattern held every time — the value was in making a state explicit, not in adding another screen.&lt;/p&gt;

&lt;p&gt;If that is where you are, it is &lt;a href="https://www.heapbyte.com/services/shopify-b2b" rel="noopener noreferrer"&gt;an operations layer you are missing&lt;/a&gt;, not a Shopify feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shopify models selling to a buyer. B2B extended that to selling to a company. Neither models buying from a supplier, so purchase orders, confirmations and quality control happen in email beside a store that knows nothing about them.&lt;/li&gt;
&lt;li&gt;B2B is no longer Plus-only. Companies, locations, net terms, draft orders and PO numbers now reach Basic, Grow and Advanced, with catalogues capped at three. All of it is buyer-side, and the PO number field is a reference the buying company puts on your sales order.&lt;/li&gt;
&lt;li&gt;Inventory transfers do cover inbound stock from external supplier locations, including partial receiving. What they have no model of is the counterparty — nobody logs in, confirms, disputes, or is held to a specification.&lt;/li&gt;
&lt;li&gt;The gap that costs money is the state between arrived and sellable. Stock that has passed no QC gate is physically present and commercially unsellable, and nothing native distinguishes the two.&lt;/li&gt;
&lt;li&gt;Where the portal lives — embedded, alongside, or in the ERP — follows where data authority already sits, not which stack the team prefers. Whichever you pick, the other two read from it.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Read the original on &lt;a href="https://www.heapbyte.com/insights/shopify-supplier-portal-architecture" rel="noopener noreferrer"&gt;heapbyte.com&lt;/a&gt;. If you're running supplier operations alongside a Shopify store and the spreadsheet has stopped being legible, I'm interested in hearing how you've handled the arrived-versus-sellable gap — that's the part nobody seems to solve the same way twice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
