Skip to content
heapbyte - A name of excellence

Architecture · 11 September 2026

Supplier portals: the operations layer Shopify doesn't have

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.

7 min read
Written by the HeapByte engineering team

The operations layer Shopify doesn't have

Two failure modes, both quiet

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.

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.

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.

What Shopify does and does not have

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.

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.

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.

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.

What a supplier portal actually has to do

Six things, and the first one constrains everything after it.

Authentication for people who are not customers. 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.

Purchase order visibility and confirmation. 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.

Partial deliveries, and the balance that survives them. 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.

A state between "arrived" and "sellable". 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 QC workflow 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.

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.

Document handling. Artwork, specifications, certificates, attached to the line they belong to rather than to an inbox.

An audit trail, because disputes happen and "who approved this, and when" has to be answerable long after everyone has forgotten.

js
/** QC gates, in the order they are passed. */
export const GATES = ["design", "sample", "countersample"];

/**
 * 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.
 */
export function isPresent(value, { numeric = false, zeroAllowed = false } = {}) {
  if (value === undefined || value === null) return false;
  if (numeric) {
    // Coerce before deciding. A form POST sends "0" as a string, so testing the
    // string branch first would let a zero through as a populated field — which
    // is the exact failure this function exists to catch.
    if (typeof value === "string" && value.trim() === "") return false;
    const n = Number(value);
    if (!Number.isFinite(n)) return false;
    return zeroAllowed ? true : n !== 0;
  }
  if (typeof value === "string") return value.trim() !== "";
  return true;
}

/**
 * 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.
 */
export function readiness(product, spec) {
  const missing = [];

  for (const field of spec.fields) {
    const value = product[field.key];
    if (!isPresent(value, { numeric: field.numeric, zeroAllowed: field.zeroAllowed })) {
      missing.push(field.label);
    }
  }

  const gatesPassed = GATES.filter((g) => product.gates?.[g] === "approved");
  const gatesOutstanding = GATES.filter((g) => product.gates?.[g] !== "approved");

  return {
    ready: missing.length === 0 && gatesOutstanding.length === 0,
    missing,
    gatesPassed,
    gatesOutstanding,
  };
}

/**
 * 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.
 */
export function orderReadiness(products, spec) {
  const results = products.map((p) => ({ sku: p.sku, ...readiness(p, spec) }));
  const blocked = results.filter((r) => !r.ready);

  return {
    ready: blocked.length === 0,
    total: results.length,
    readyCount: results.length - blocked.length,
    blocked,
  };
}
Readiness calculated rather than eyeballed. The zero handling is the point: a form POST sends "0" 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.

Where it should live

Three homes, and no universally right answer.

Embedded in Shopify as an app 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.

Alongside, as a separate application, gives you your own authentication and your own data model, and stops you bending suppliers into an identity Shopify already defined. The portal we built 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.

Inside the ERP 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.

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.

What makes stock sellable

Whatever you build, it comes down to one narrow question at the boundary: what makes stock sellable, and what holds it back.

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.

The synchronisation itself is its own discipline, with failure modes worth understanding before you design around them — we covered those in the connector piece. 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."

When this needs an engineer

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.

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 supplier and back-office tooling, the pattern held every time — the value was in making a state explicit, not in adding another screen.

If that is where you are, it is an operations layer you are missing, not a Shopify feature.

Send us the store and the symptom.

Insights

Apply this to your store.

An audit turns the general principle into a specific list of changes, ordered by what actually pays back.