<?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>Your webhook handler was slow, so Shopify sent it again</title>
      <dc:creator>Adab ul Qayyum</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:27:39 +0000</pubDate>
      <link>https://dev.to/adab/your-webhook-handler-was-slow-so-shopify-sent-it-again-3hgc</link>
      <guid>https://dev.to/adab/your-webhook-handler-was-slow-so-shopify-sent-it-again-3hgc</guid>
      <description>&lt;p&gt;The server reboots at two on a Saturday morning for a security update, which is what it's supposed to do. The app doesn't come back, because nobody told the process manager to start it on boot.&lt;/p&gt;

&lt;p&gt;It's a fundraising weekend. Orders keep arriving, Shopify keeps sending webhooks into nothing, and after four hours of retries it stops.&lt;/p&gt;

&lt;p&gt;On Monday the app is running again and looks perfectly healthy. It's missing two days of sales, and every seller's total is short.&lt;/p&gt;

&lt;p&gt;The two rules, and how they interact&lt;/p&gt;

&lt;p&gt;An app has five seconds to respond to a webhook. Miss it and Shopify retries — up to eight times over four hours — and if failures persist, the subscription is removed. Shopify's guidance for recovering is to re-subscribe and import the missing data.&lt;/p&gt;

&lt;p&gt;Separately, a webhook can arrive more than once after a timeout or a retry.&lt;/p&gt;

&lt;p&gt;Those two rules interact in a way that catches self-hosted apps specifically. A small server under load, writing to its ledger before it answers, can take longer than five seconds. Shopify counts that as a failure and sends the webhook again — so the app records the same order twice. Not because anything was wrong with the delivery. Because it was slow to say thank you.&lt;/p&gt;

&lt;p&gt;The fix is ordering rather than speed: acknowledge first, do the work afterwards, and make the work safe to repeat.&lt;/p&gt;

&lt;p&gt;Read together, those rules say something that should change how you design the thing: webhooks are a notification that something happened, not a guarantee you'll hear about everything. An app whose records are built only from webhooks is complete exactly as long as it has never been down.&lt;/p&gt;

&lt;p&gt;Every self-hosted app is eventually down.&lt;/p&gt;

&lt;p&gt;Reconcile against the orders&lt;/p&gt;

&lt;p&gt;So the app doesn't trust its own memory. On a schedule — daily is enough for most — it fetches the orders Shopify has for a window, compares them against what it recorded, and processes the gaps.&lt;/p&gt;

&lt;p&gt;ts&lt;br&gt;
`/** An order as fetched from the Admin API, with the seller attribute it was placed with. */&lt;br&gt;
type ShopifyOrder = { id: string; createdAt: string; sellerRef: string | null };&lt;/p&gt;

&lt;p&gt;/** What the app recorded when a webhook arrived. */&lt;br&gt;
type Recorded = { orderId: string; webhookId: string };&lt;/p&gt;

&lt;p&gt;type Finding =&lt;br&gt;
  | { kind: "missed"; orderId: string; sellerRef: string }&lt;br&gt;
  | { kind: "unattributed"; orderId: string };&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Compare what Shopify says happened in a window with what the app heard about.
*&lt;/li&gt;
&lt;li&gt;Webhooks are the fast path, not the record. The attribute rides on the order&lt;/li&gt;
&lt;li&gt;itself, so anything the app missed while it was down can be recovered from&lt;/li&gt;
&lt;li&gt;the order, as long as something goes looking.
*/
export function reconcile(fetched: readonly ShopifyOrder[], recorded: readonly Recorded[]): Finding[] {
// Retries and duplicate deliveries mean one order can be recorded several
// times; what matters is whether it was recorded at all.
const seen = new Set(recorded.map((r) =&amp;gt; r.orderId));&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;const findings: Finding[] = [];&lt;br&gt;
  for (const order of [...fetched].sort((a, b) =&amp;gt; a.createdAt.localeCompare(b.createdAt))) {&lt;br&gt;
    if (seen.has(order.id)) continue;&lt;br&gt;
    const ref = order.sellerRef?.trim();&lt;br&gt;
    findings.push(ref ? { kind: "missed", orderId: order.id, sellerRef: ref } : { kind: "unattributed", orderId: order.id });&lt;br&gt;
  }&lt;br&gt;
  return findings;&lt;br&gt;
}`&lt;/p&gt;

&lt;p&gt;This only works because of a decision made much earlier: the seller's identity travels on the order itself, as an attribute, rather than living only in the app's memory of a webhook. That is what makes an outage recoverable. If attribution existed only in what the app was told, four hours of downtime would be four hours of sales nobody could ever credit; because it is on the order, the answer is still sitting in Shopify, waiting to be fetched. The rest is bookkeeping — deduplicate by order rather than by delivery, and report orders with no seller instead of dropping them, since a silent skip is how a missing attribute becomes a missing payout.&lt;/p&gt;

&lt;p&gt;Make the windows overlap. A daily job that looks back exactly twenty-four hours has a seam at midnight, and an order placed while the previous run was still working can fall into it. Looking back forty-eight hours every day costs almost nothing, because the comparison is safe to repeat — an order already recorded is simply skipped, however many times the job sees it.&lt;/p&gt;

&lt;p&gt;Overlap is only cheap when the processing is idempotent, which is one more reason to build it that way.&lt;/p&gt;

&lt;p&gt;The same job is also the cheapest monitor you'll ever write. A reconciliation that finds gaps every day is telling you the webhooks are failing, long before anyone asks why their total looks low. It should check the subscriptions still exist too, because after a long enough outage they may not.&lt;/p&gt;

&lt;p&gt;The defaults are built for getting started&lt;/p&gt;

&lt;p&gt;Worth knowing what you're inheriting. Shopify's app template stores sessions in SQLite through Prisma, and its own deployment guide notes you can only run more than one web container if the database gets its own container or volume. The same guide warns that one popular host may suspend idle containers and reset disk storage — fine for a demo, a quiet catastrophe for a database living on that disk.&lt;/p&gt;

&lt;p&gt;Managed hosting isn't automatically safer. It moves the failure somewhere you're less likely to look.&lt;/p&gt;

&lt;p&gt;The address is part of the contract too. When the app's URL changes, the configuration has to be updated and redeployed with shopify app deploy. Moving a self-hosted app to a new server isn't only a server migration — until Shopify is told, the admin is embedding the old address and every webhook is going to it.&lt;/p&gt;

&lt;p&gt;What self-hosting doesn't cover&lt;/p&gt;

&lt;p&gt;The server. Patching, firewalls, TLS renewal and restore drills are yours now, and a backup nobody has ever restored is a hope rather than a backup.&lt;/p&gt;

&lt;p&gt;Data obligations. Shopify makes its privacy compliance webhooks mandatory for App Store apps, with thirty days to act. A custom app on one store isn't named there, but the personal data it holds is still personal data.&lt;/p&gt;

&lt;p&gt;Reconciliation, on any host. A managed platform reduces how often the app is down. It does not make it never down.&lt;/p&gt;

&lt;p&gt;When this is the right shape&lt;/p&gt;

&lt;p&gt;It isn't, if an App Store app does the job. Someone else hosts it, monitors it, and answers for it — for most needs that's a better deal than any server you could run.&lt;/p&gt;

&lt;p&gt;It's right when the app holds records the business couldn't rebuild from Shopify alone — money, attribution, an append-only ledger — and the business wants to own where those records live. Then hosting, backups, monitoring and reconciliation are part of the build, not chores after it.&lt;/p&gt;

&lt;p&gt;The cost should be said out loud at the start: you now own uptime. Nobody at Shopify will notice the app is down. Nobody at a hosting company will restart it. If the process stops, it stays stopped until a human or a monitor notices.&lt;/p&gt;

&lt;p&gt;Takeaways&lt;br&gt;
Shopify hosts the store, not your app. A custom app needs HTTPS, credentials, a database and a process that stays up — and nobody at Shopify will notice if it doesn't.&lt;br&gt;
A webhook gets five seconds and up to eight retries over four hours. If failures persist, the subscription is removed.&lt;br&gt;
Webhooks can arrive more than once. Deduplicate by the event, not the delivery.&lt;br&gt;
Webhooks are the fast path, not the record. Reconcile against the orders on a schedule, which only works if what you need is stored on the order.&lt;br&gt;
Self-hosting suits an app holding records the business cannot rebuild elsewhere. The price is owning uptime, patching and restore drills.&lt;/p&gt;

&lt;p&gt;Two things I'd like other people's experience on.&lt;/p&gt;

&lt;p&gt;The five-second budget, in practice. Acknowledge-then-work is the obvious answer, but it means the webhook handler returns 200 before anything is durable — so a crash between the acknowledgement and the write loses the event, and now reconciliation is the only thing that catches it. The alternative is writing to a queue before acknowledging, which is durable but puts a write back inside the budget. I've gone with the queue. Interested in whether people find the bare acknowledge-then-work version holds up under real load.&lt;/p&gt;

&lt;p&gt;Reconciliation windows. Forty-eight hours covers the midnight seam cheaply. It doesn't cover a weekend outage, which is exactly when nobody is watching. I've not found a window that's both cheap enough to run daily and wide enough to catch the outages that actually happen — so the real answer is probably monitoring rather than a bigger window, and the reconciliation job is just the backstop.&lt;/p&gt;

&lt;p&gt;If you run a self-hosted Shopify app, how long did it take before the first outage taught you something the design hadn't anticipated?&lt;/p&gt;

</description>
      <category>css</category>
      <category>shopify</category>
      <category>ai</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Every tag was correct, and together they leaked data</title>
      <dc:creator>Adab ul Qayyum</dc:creator>
      <pubDate>Fri, 02 Oct 2026 06:51:04 +0000</pubDate>
      <link>https://dev.to/adab/every-tag-was-correct-and-together-they-leaked-data-4cmg</link>
      <guid>https://dev.to/adab/every-tag-was-correct-and-together-they-leaked-data-4cmg</guid>
      <description>&lt;p&gt;Anna leads the under-twelves and plays for the under-fourteens.&lt;/p&gt;

&lt;p&gt;The system knows both things. Her customer record carries role:leader, team:u12 and team:u14 — accurate in every particular. On Monday she opens the leader report to see how her team's fundraising went, and it shows her the under-twelves' sales. And the under-fourteens'. Her own teammates, by name, with totals.&lt;/p&gt;

&lt;p&gt;Nobody configured that. Every tag is correct, and together they say something nobody meant.&lt;/p&gt;

&lt;p&gt;The role is on the edge&lt;/p&gt;

&lt;p&gt;"Leader" isn't a property of a person. It's a property of a person's relationship with one team.&lt;/p&gt;

&lt;p&gt;Anna is a leader of the under-twelves. She is a member of the under-fourteens. Two relationships, each with its own role — and the moment the role is stored on the person rather than on the relationship, they collapse into one: a leader, who is in two teams.&lt;/p&gt;

&lt;p&gt;So the model is one row per person per team, and the role belongs to the row. Not novel; it's where any membership system ends up after contact with real organisations. What's worth writing about is how naturally the other version arrives in a Shopify build, where the customer record is right there and tags are free.&lt;br&gt;
`type Role = "member" | "leader" | "club-admin";&lt;/p&gt;

&lt;p&gt;/** One row per person per team. The role belongs to the row, not the person. */&lt;br&gt;
type Membership = { personId: string; teamId: string; clubId: string; role: Role };&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Which sellers' sales may this person see?
*&lt;/li&gt;
&lt;li&gt;Everyone sees their own. A leader sees the members of the teams they lead,&lt;/li&gt;
&lt;li&gt;and only those. A club admin sees every seller in that club.
*/
export function visibleSellers(viewerId: string, memberships: readonly Membership[]): Set {
const visible = new Set([viewerId]);
const own = memberships.filter((m) =&amp;gt; m.personId === viewerId);&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;const ledTeams = new Set(own.filter((m) =&amp;gt; m.role === "leader").map((m) =&amp;gt; m.teamId));&lt;br&gt;
  const adminClubs = new Set(own.filter((m) =&amp;gt; m.role === "club-admin").map((m) =&amp;gt; m.clubId));&lt;/p&gt;

&lt;p&gt;for (const m of memberships) {&lt;br&gt;
    if (ledTeams.has(m.teamId) || adminClubs.has(m.clubId)) visible.add(m.personId);&lt;br&gt;
  }&lt;br&gt;
  return visible;&lt;br&gt;
}`&lt;/p&gt;

&lt;p&gt;The line that matters is the filter on role === "leader" before collecting team ids. The version tags produce asks a different question — is this person a leader, and which teams are they in — and for anyone who leads one team and plays in another, the two answers combine into authority over a team they only belong to. Nothing errors. The leader's report simply includes their teammates. Holding the role on the membership row makes that combination impossible to express, which is a stronger guarantee than remembering not to write it.&lt;/p&gt;

&lt;p&gt;The function is short because the model does the work. Every visibility question becomes a question about rows, and a row can only say one thing about one relationship.&lt;/p&gt;

&lt;p&gt;Attribution has the same shape, and it's quieter&lt;/p&gt;

&lt;p&gt;A fundraising sale is attributed to a seller, and totals roll up: seller to team, team to club.&lt;/p&gt;

&lt;p&gt;If Anna sells for both her teams and the link she shares identifies only Anna, every sale is ambiguous at the first roll-up. It belongs to Anna, and Anna belongs to two teams. Somebody writes a rule to break the tie — usually "the first team on her record" — and one team's total is quietly short for the rest of the season.&lt;/p&gt;

&lt;p&gt;Nobody notices, because the club total is right.&lt;/p&gt;

&lt;p&gt;Same fix: attribute the sale to the membership, not the person. Anna-in-the-under-fourteens. The link carries both and the roll-up has nothing to guess.&lt;/p&gt;

&lt;p&gt;Attribution and visibility turn out to be the same question asked from opposite ends: which relationship does this fact belong to?&lt;/p&gt;

&lt;p&gt;Shopify already has this shape, for buyers&lt;/p&gt;

&lt;p&gt;Shopify's own model gets this exactly right in a different context. In B2B, a customer is attached to company locations and permissions are assigned per location — Ordering only, or Location admin, which can see every order placed for that location. A person can be an admin at one location and an orderer at another.&lt;/p&gt;

&lt;p&gt;The role is on the edge.&lt;/p&gt;

&lt;p&gt;If your hierarchy is buyers purchasing on behalf of an organisation, use it. It's native, supported, and already correct.&lt;/p&gt;

&lt;p&gt;It doesn't fit sellers, for two reasons. A customer can belong to only one company, and the people in the opening belong to more than one structure by design. And B2B visibility is over orders placed for a location — a fundraising leader needs to see sales attributed to the people they lead, which are orders placed by members of the public who have never heard of the team.&lt;/p&gt;

&lt;p&gt;Holding the structure isn't the hard part&lt;/p&gt;

&lt;p&gt;Shopify can hold the structure too. A metaobject per team, with a customer reference for the leader and a list of references for the members, is expressible natively and editable in the admin. If the requirement is to record who's in which team, that's enough.&lt;/p&gt;

&lt;p&gt;What it doesn't do is answer who may see what. A customer account shows a customer their own orders. There's no notion in Shopify of a person who isn't staff and may see other people's sales because of where they sit in an organisation — and that question, not the membership list, is what the hierarchy is for.&lt;/p&gt;

&lt;p&gt;What this doesn't do&lt;/p&gt;

&lt;p&gt;It's current state only. Memberships change between seasons, and a leader's authority starting in March is a fact the report needs to show last autumn's figures correctly. Real rows carry dates; the function above doesn't, because its point is about where the role lives, not when.&lt;/p&gt;

&lt;p&gt;It isn't authentication. It answers what an identified person may see. Who that person is, and how they proved it, is a separate problem with its own failure modes.&lt;/p&gt;

&lt;p&gt;It doesn't decide policy. Whether a club admin sees individual sellers or only team totals is the organisation's call, and it changes. The model makes either rule easy to write and neither automatic.&lt;/p&gt;

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

&lt;p&gt;Often it doesn't. If tags are labels for filtering — a club tag for an email segment, a team tag for a report you run yourself — they're the right tool, and replacing them with a database is cost without benefit. If your organisation buys rather than sells, Shopify B2B already has the model.&lt;/p&gt;

&lt;p&gt;It needs engineering when people who aren't staff must see data about other people, and what they may see depends on where they sit in a structure that changes. That's the point where a tag stops being a label and starts being a permission — and a permission written as a string on a customer record is one nobody can audit.&lt;/p&gt;

&lt;p&gt;Takeaways&lt;br&gt;
A role is a property of a relationship, not a person. Anna leads one team and plays in another; storing "leader" on Anna makes her a leader of both.&lt;br&gt;
Customer tags hold up to 250 labels on one record. They filter well and cannot express which relationship a fact belongs to.&lt;br&gt;
Shopify B2B has the right shape — permissions per company location — for buyers. It allows one company per customer and shows orders placed, not sales attributed.&lt;br&gt;
Credit has the same shape. Attribute a sale to the membership, not the person, or a seller in two teams leaves one team's total quietly short.&lt;br&gt;
When a tag starts deciding what someone outside staff can see, it has become a permission, and it belongs in a model that can be audited.&lt;br&gt;
The thing I'd like other people's read on is the temporal version, because I left it out of the function deliberately and I'm not sure the real answer is clean.&lt;/p&gt;

&lt;p&gt;Memberships change. Anna leads the under-twelves this season and moves to the under-fourteens next. Add dates to the rows and visibility becomes a question about a point in time rather than about now — which is correct, and means every report has to decide which point. The sale date? The date the report runs? For a season summary those give different answers, and both are defensible.&lt;/p&gt;

&lt;p&gt;The version I've seen go wrong is a leader who steps down mid-season and keeps seeing historic figures for a team they no longer lead, because the rows were filtered on the sale date and the sales predate their departure. Technically right. Not what anyone wanted.&lt;/p&gt;

&lt;p&gt;If you've built role-based visibility over data with history, how did you resolve which date governs?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>shopify</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>Four hundred distinct strings is not four hundred authors</title>
      <dc:creator>Adab ul Qayyum</dc:creator>
      <pubDate>Thu, 01 Oct 2026 09:12:02 +0000</pubDate>
      <link>https://dev.to/adab/four-hundred-distinct-strings-is-not-four-hundred-authors-3c1f</link>
      <guid>https://dev.to/adab/four-hundred-distinct-strings-is-not-four-hundred-authors-3c1f</guid>
      <description>&lt;p&gt;The export has a column called Author. A couple of thousand rows, maybe four hundred distinct strings — except nobody knows whether it's four hundred, because "Margaret Atwood" and "margaret atwood" are both in there, and so, somewhere, is "Atwood, Margaret".&lt;/p&gt;

&lt;p&gt;In the old system none of this mattered. The column was text, it rendered as text, and nothing ever asked how many authors the catalogue contained.&lt;/p&gt;

&lt;p&gt;Now you're moving it somewhere that has entities, and the question has to be answered.&lt;/p&gt;

&lt;p&gt;The capacity question is already closed&lt;/p&gt;

&lt;p&gt;Worth getting out of the way, because plenty of migration plans spend their time here and it's the wrong place.&lt;/p&gt;

&lt;p&gt;A Shopify store gets 128 metaobject definitions on Basic, Shopify and Advanced, 256 on Plus and Enterprise. Each installed app gets its own 128 on top. Every definition holds up to a million entries — raised from the old 64,000 for non-Plus and 128,000 for Plus, with the plan distinction removed entirely. Shopify's own standard definitions don't count against any of it.&lt;/p&gt;

&lt;p&gt;So you can have an Author entity. Four hundred of them, or four hundred thousand. You can reference them from products and edit a biography once rather than on every title.&lt;/p&gt;

&lt;p&gt;None of that is the hard part.&lt;/p&gt;

&lt;p&gt;The hard part is counting&lt;/p&gt;

&lt;p&gt;Promoting a column to an entity requires you to know how many entities are in it, and a flat text column doesn't tell you.&lt;/p&gt;

&lt;p&gt;Four hundred distinct strings isn't four hundred authors. It's four hundred strings. Some are the same person typed differently. Some are the same person with a middle initial on half their titles. Some are genuinely two different people who share a name.&lt;/p&gt;

&lt;p&gt;Counting distinct values gives you a number that looks authoritative and isn't — and the moment you create one metaobject per distinct string, that wrong number becomes the structure of the catalogue.&lt;/p&gt;

&lt;p&gt;This is an identity problem with no identifier to work from. The source system never issued one, because it never thought of an author as a thing.&lt;/p&gt;

&lt;p&gt;A rule for what to promote&lt;/p&gt;

&lt;p&gt;What you can automate is the triage. Which columns are obviously per-product, which are obviously shared, and which can't be judged until a person looks.&lt;/p&gt;

&lt;p&gt;A column where nearly every value is unique is a metafield — it's an attribute of the product, and giving each value its own entity creates a thousand records referenced once each&lt;br&gt;
A column where values repeat substantially is a metaobject candidate&lt;br&gt;
A column whose distinct count moves depending on how you normalise it is neither, yet — the number the decision depends on isn't knowable&lt;/p&gt;

&lt;p&gt;That third case is the one worth building for, and the useful behaviour is refusal.&lt;br&gt;
`/** A metaobject earns its place when the average value is used more than once.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; Below this, you have a per-product attribute wearing an entity's clothes. */
const MIN_REUSE = 2;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;type Verdict =&lt;br&gt;
  | { kind: "skip"; reason: string }&lt;br&gt;
  | { kind: "metafield"; distinct: number; reuse: number }&lt;br&gt;
  | { kind: "metaobject"; entries: number; reuse: number }&lt;br&gt;
  | { kind: "needs-review"; collisions: { normalised: string; spellings: string[] }[] };&lt;/p&gt;

&lt;p&gt;const norm = (s: string) =&amp;gt; s.trim().toLowerCase().replace(/\s+/g, " ");&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Decides what a denormalised source column should become in Shopify.
*&lt;/li&gt;
&lt;li&gt;Note what is NOT checked: the per-definition entry ceiling. A metaobject&lt;/li&gt;
&lt;li&gt;definition holds a million entries, so no real catalogue reaches it, and a&lt;/li&gt;
&lt;li&gt;check that never fires would imply the ceiling is the risk. It is not.
*/
export function shouldPromote(values: readonly string[]): Verdict {
const present = values.map((v) =&amp;gt; v.trim()).filter((v) =&amp;gt; v !== "");
if (present.length === 0) return { kind: "skip", reason: "column is empty" };&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;const groups = new Map&amp;gt;();&lt;br&gt;
  for (const v of present) {&lt;br&gt;
    const key = norm(v);&lt;br&gt;
    const spellings = groups.get(key) ?? new Set();&lt;br&gt;
    spellings.add(v);&lt;br&gt;
    groups.set(key, spellings);&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// Spelling collisions come first. While they exist the distinct count is a&lt;br&gt;
  // guess, so every number downstream of it would be a guess too.&lt;br&gt;
  const collisions = [...groups.entries()]&lt;br&gt;
    .filter(([, spellings]) =&amp;gt; spellings.size &amp;gt; 1)&lt;br&gt;
    .map(([normalised, spellings]) =&amp;gt; ({ normalised, spellings: [...spellings].sort() }))&lt;br&gt;
    .sort((a, b) =&amp;gt; a.normalised.localeCompare(b.normalised));&lt;br&gt;
  if (collisions.length &amp;gt; 0) return { kind: "needs-review", collisions };&lt;/p&gt;

&lt;p&gt;const distinct = groups.size;&lt;br&gt;
  const reuse = Number((present.length / distinct).toFixed(2));&lt;br&gt;
  return reuse &amp;gt;= MIN_REUSE&lt;br&gt;
    ? { kind: "metaobject", entries: distinct, reuse }&lt;br&gt;
    : { kind: "metafield", distinct, reuse };&lt;br&gt;
}`&lt;/p&gt;

&lt;p&gt;The ordering is the argument: collisions are checked before reuse, because while two spellings of one name are still in the column the distinct count is a guess, and the reuse ratio computed from it would be a guess with a decimal point on it. The function refuses to return a number it cannot stand behind. What it does not check is the entry ceiling, and that omission is deliberate — a definition holds a million entries, so a limit check would never fire and would quietly imply the ceiling is what you should worry about. The test worth reading is the last one: "Stephen King" and "King, Stephen" are reported as two separate entities. That is wrong, the function cannot tell, and making it guess would be worse than leaving it visibly wrong.&lt;/p&gt;

&lt;p&gt;Safe normalisation vs. guessing&lt;/p&gt;

&lt;p&gt;The line between them is sharper than it looks.&lt;/p&gt;

&lt;p&gt;Trimming whitespace is safe. A name with a trailing space and the same name without one are the same string with no information between them. Nothing is lost by collapsing them on the way in.&lt;/p&gt;

&lt;p&gt;Lowercasing for comparison is already a judgement. You can match on it, but you can't tell which casing is the one to keep — and picking one silently means the catalogue displays somebody's name the way the import happened to see it first.&lt;/p&gt;

&lt;p&gt;Past that it stops being automatable at all. A reordered name. An initial on some titles and not others. A translator credited as a narrator on one record. An imprint that changed its name halfway through the catalogue. All of these are the same entity to a human and different strings to any rule you can write.&lt;/p&gt;

&lt;p&gt;The honest architecture surfaces them for review rather than resolving them, and accepts that someone who knows the catalogue has to spend an afternoon on it.&lt;/p&gt;

&lt;p&gt;That afternoon is the actual cost of the decision, and it's why the decision gets deferred.&lt;/p&gt;

&lt;p&gt;Deciding late costs more than deciding wrong&lt;/p&gt;

&lt;p&gt;Which is the trap, because deferring is the expensive option.&lt;/p&gt;

&lt;p&gt;Import the column as plain text and everything works. Product pages render, the catalogue is live, and the structural question is still open — but it's now open across the live catalogue rather than across a spreadsheet.&lt;/p&gt;

&lt;p&gt;Promoting a text field to an entity afterwards means creating the entities, resolving the duplicates you avoided resolving the first time, rewriting every product's reference, and doing it on data that customers and staff have since edited.&lt;/p&gt;

&lt;p&gt;Mapping a column to the wrong structure is recoverable. Mapping it before you've asked what's in it means the recovery happens later, with more rows and an audience.&lt;/p&gt;

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

&lt;p&gt;It doesn't, when the data is genuinely per-product. Most columns in most exports are attributes, not entities, and a metafield is the correct and cheap answer. The guides telling you to use a metaobject for your size chart and a metafield for your SKU are right — for data you're about to create, the reuse question is easy, because you control the answer.&lt;/p&gt;

&lt;p&gt;It needs engineering when the data already exists in a shape somebody else chose, at a volume nobody can read. Tens of thousands of products, a column that's probably an entity, and no identifier anywhere in the source to resolve it by. That's a data-modelling job with an audit in front of it, and the audit has to happen before the import rather than after.&lt;/p&gt;

&lt;p&gt;Takeaways&lt;br&gt;
Capacity is not the constraint. 128 metaobject definitions, 256 on Plus, and a million entries each — the old 64,000 and 128,000 caps are gone.&lt;br&gt;
A distinct-value count is not an entity count. Four hundred strings may be three hundred and forty people, and creating one metaobject per string makes the wrong number permanent.&lt;br&gt;
Trimming whitespace is safe; choosing a capitalisation is a guess. Normalise the first silently, escalate the second.&lt;br&gt;
Build the triage to refuse. A tool that reports a confident number it cannot stand behind is worse than one that says a person needs to look.&lt;br&gt;
Decide the model before the transformation. Promoting a text column to an entity after go-live means resolving the same duplicates on live data that people have since edited.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webpack</category>
    </item>
    <item>
      <title>Shopify's barcode field is about to hold 20 values, and reading it still returns 1</title>
      <dc:creator>Adab ul Qayyum</dc:creator>
      <pubDate>Wed, 30 Sep 2026 10:46:09 +0000</pubDate>
      <link>https://dev.to/adab/shopifys-barcode-field-is-about-to-hold-20-values-and-reading-it-still-returns-1-19c2</link>
      <guid>https://dev.to/adab/shopifys-barcode-field-is-about-to-hold-20-values-and-reading-it-still-returns-1-19c2</guid>
      <description>&lt;p&gt;GS1's guidance is unambiguous: each packaging level needs its own GTIN. A case of 24 cans carries a different number from a single can. The consumer unit gets a GTIN-13 in an EAN-13 barcode — the one scanned at a till. Cases and cartons get GTIN-14s derived from it, in ITF-14, which is explicitly not for retail checkout and can be printed straight onto corrugated board rather than onto a label at all.&lt;/p&gt;

&lt;p&gt;So one product in a warehouse can legitimately carry three identifiers, each meaning a different physical thing: one can, a sleeve of ten, a carton of a hundred and twenty.&lt;/p&gt;

&lt;p&gt;On the current stable API version, a Shopify variant carries one barcode string.&lt;/p&gt;

&lt;p&gt;That's about to change, and the migration detail is more interesting than the feature.&lt;/p&gt;

&lt;p&gt;What the artwork actually needs&lt;/p&gt;

&lt;p&gt;Four sticker designs — product, inner, carton, and an additional format some retailers insist on. Four layouts, three identifiers, different dimensions, different content on each.&lt;/p&gt;

&lt;p&gt;The quantity rule is the part that catches people. Sticker counts come from packaging data, not from the purchase-order quantity. A line for 240 units needs 240 item labels, 24 inner labels and two carton labels: one input, three different numbers, and none of them is the number written on the order.&lt;/p&gt;

&lt;p&gt;"Print the labels for this order" sounds like a single quantity. It never is.&lt;br&gt;
`type Level = "item" | "inner" | "carton";&lt;/p&gt;

&lt;p&gt;type Packaging = {&lt;br&gt;
  level: Level;&lt;br&gt;
  /** The GTIN for this level. Null when the supplier has not supplied one. &lt;em&gt;/&lt;br&gt;
  gtin: string | null;&lt;br&gt;
  /&lt;/em&gt;* How many consumer units this level contains. Always 1 for "item". */&lt;br&gt;
  unitsPerPack: number;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;type Label = {&lt;br&gt;
  level: Level;&lt;br&gt;
  gtin: string;&lt;br&gt;
  symbology: "EAN-13" | "ITF-14";&lt;br&gt;
  /** How many of this label to print. Derived, never taken from the order. */&lt;br&gt;
  quantity: number;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;type Refusal = { level: Level; reason: string };&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Works out which labels a purchase-order line needs.
*&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;orderedUnits&lt;/code&gt; is in consumer units. Label counts are derived from the&lt;/li&gt;
&lt;li&gt;packing hierarchy, so a line for 240 units produces 240 item labels, 24&lt;/li&gt;
&lt;li&gt;inner labels and 2 carton labels — three different numbers from one input.
*/
export function labelsFor(
packaging: readonly Packaging[],
orderedUnits: number,
): { labels: Label[]; refusals: Refusal[] } {
const labels: Label[] = [];
const refusals: Refusal[] = [];&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;if (!Number.isInteger(orderedUnits) || orderedUnits &amp;lt;= 0) {&lt;br&gt;
    return {&lt;br&gt;
      labels,&lt;br&gt;
      refusals: [{ level: "item", reason: "ordered units must be a positive integer" }],&lt;br&gt;
    };&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;for (const pack of packaging) {&lt;br&gt;
    if (!Number.isInteger(pack.unitsPerPack) || pack.unitsPerPack &amp;lt;= 0) {&lt;br&gt;
      refusals.push({&lt;br&gt;
        level: pack.level,&lt;br&gt;
        reason: "units per pack is missing or not a positive integer",&lt;br&gt;
      });&lt;br&gt;
      continue;&lt;br&gt;
    }&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// No fallback. A carton printed with the item's GTIN scans at goods-in as
// one consumer unit, and GS1 requires a distinct GTIN per level anyway.
if (pack.gtin === null || pack.gtin.trim() === "") {
  refusals.push({ level: pack.level, reason: "no GTIN for this packaging level" });
  continue;
}

labels.push({
  level: pack.level,
  gtin: pack.gtin.trim(),
  symbology: pack.level === "item" ? "EAN-13" : "ITF-14",
  quantity: Math.ceil(orderedUnits / pack.unitsPerPack),
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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

&lt;p&gt;return { labels, refusals };&lt;br&gt;
}`&lt;/p&gt;

&lt;p&gt;Two things here are not obvious from the requirement. The first is that quantity is derived rather than read — taking the order quantity and printing that many of everything is the intuitive reading of "print the labels for this order", and it is wrong at every level above the item. The second is the refusal. The tempting shape is to fall back to the item's GTIN when a carton GTIN is missing, because it produces a label instead of an error — but GS1 requires a distinct GTIN per packaging level, so that label would make a carton of 24 scan as a single unit at goods-in. A missing label stops the line. A wrong one is discovered in somebody else's warehouse.&lt;/p&gt;

&lt;p&gt;Where the packaging data lives today&lt;/p&gt;

&lt;p&gt;If Shopify can't hold the inner and carton identifiers, something else has to. Metafields are the obvious answer and they do work, but they're a place to put values rather than a model — nothing in a metafield knows that the carton contains ten inners, and that relationship is exactly what the label counts depend on.&lt;/p&gt;

&lt;p&gt;We put it in the supplier portal, which was already the operations layer this business was running on and already held the packing specification. That decision has a cost and it's the usual one: two systems now hold product data, so the boundary has to be stated rather than assumed. Shopify owns what is sold. The portal owns how it is packed.&lt;/p&gt;

&lt;p&gt;What 2026-10 changes, and what breaks quietly&lt;/p&gt;

&lt;p&gt;From API version 2026-10 a variant accepts up to 20 barcodes, each up to 255 characters, and each can declare a type — UPC, EAN, ISBN, GTIN or ASIN. Untyped values are accepted and stored as-is. The packaging hierarchy can live on the variant after all.&lt;/p&gt;

&lt;p&gt;The migration detail matters more than the feature:&lt;/p&gt;

&lt;p&gt;Reading the old singular barcode field returns the first entry in the new list&lt;br&gt;
Writing it updates the first position and leaves the rest alone&lt;br&gt;
A single variant input cannot set both barcode and barcodes&lt;br&gt;
No removal date has been announced for the singular field&lt;/p&gt;

&lt;p&gt;Which means nothing breaks loudly. An integration reading the singular field keeps working, keeps returning a plausible value, and silently stops seeing the other nineteen — Shopify's own changelog names this as a risk of silent truncation.&lt;/p&gt;

&lt;p&gt;If a fulfilment or labelling integration reads it and somebody adds a carton GTIN in the admin, the integration will not error. It will carry on printing the item barcode onto cartons, and the first person to find out will be standing at goods-in.&lt;/p&gt;

&lt;p&gt;A PDF is not a page request&lt;/p&gt;

&lt;p&gt;The generator exports a complete purchase order as one document, and the first version did not survive contact with a real one. Generation logic had to be reworked after testing large purchase orders that exceeded practical request duration — sixty-plus products, each with several image-heavy pages, assembled inside a single request.&lt;/p&gt;

&lt;p&gt;This is the failure that works for the whole of development and then arrives in the first week of use, because development orders have four lines. A document build is a batch workload wearing a web request's clothing: the work grows with the order, the timeout does not, and the first thing to break is the largest and most important order rather than the smallest.&lt;/p&gt;

&lt;p&gt;It wants to be a job with a queue and a result to collect, not a button that blocks until it has finished.&lt;/p&gt;

&lt;p&gt;The thumbnail is product data&lt;/p&gt;

&lt;p&gt;One small decision turned out to matter more than its size suggests. Which Shopify product image becomes the packaging thumbnail is a choice, and it isn't a presentation choice — the same image has to appear on the same product's artwork for the next purchase order, and the one after that. Left as a default it changes the moment somebody reorders the media on the product. Stored as a preference, it becomes part of the product's packaging record.&lt;/p&gt;

&lt;p&gt;Artwork automation only works when the data underneath it is standardised, and that includes the parts that don't look like data.&lt;/p&gt;

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

&lt;p&gt;It doesn't, if you sell consumer units at retail with one barcode per variant. Shopify's own Retail Barcode Labels app and the printers around it do that job properly. The same holds if your purchase orders are small and the labels are a five-minute job — automation has to beat five minutes plus the cost of owning the thing that replaced them.&lt;/p&gt;

&lt;p&gt;It needs engineering when the product has a packaging hierarchy, because that's the point where the label stops being derivable from the product record. Three identifiers, a pack structure that decides the quantities, a supplier submitting the data, and artwork that has to be correct before anything is manufactured. None of that is a setting. It's a data model with a document generator attached.&lt;/p&gt;

&lt;p&gt;Takeaways&lt;br&gt;
GS1 requires a distinct GTIN for every packaging level. A case of 24 and a single unit are different trade items with different numbers, and one Shopify barcode field holds one of them.&lt;br&gt;
Label quantities are derived from the pack structure, not from the order. One line for 240 units produces 240, 24 and 2.&lt;br&gt;
From API version 2026-10 a variant accepts up to 20 typed barcodes. The constraint is being lifted, and the singular field has no announced removal date.&lt;br&gt;
Reading the singular barcode field returns only the first entry. Integrations will not error when the others appear — they will quietly print the wrong one.&lt;br&gt;
Document generation is a batch workload. It scales with the order and fails first on the biggest one, which is never the one it was tested with.&lt;/p&gt;

</description>
      <category>ecommerce</category>
      <category>shopify</category>
      <category>ai</category>
      <category>automation</category>
    </item>
    <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>
