Originally published on the HeapByte blog.
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.
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.
What Shopify's notifications are for
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 shipping_method.title rather than order.shipping_method.title — but the model is sound and for the job it does, it is the right tool.
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.
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.
Flow, and the shape it produces
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.
Flow's Send HTTP request action will call an external system, store its secrets properly, and return the full response into the workflow, where a Run code 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 when an order's expected date changes in the ERP, the customer is told, Flow will do it, and building something else would be wasteful.
Two things bound it.
Flow's own email action is Send internal email — 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.
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.
That is the fork. Not capability. Shape.
Sending is a decision, not a trigger
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.
Once you put it that way the requirements fall out, and none of them are about email.
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 whether we told them is a question that gets asked three weeks later by someone who was not involved.
That last one is what turns it from a feature into part of the operation. An email that is not logged against the order did not, operationally speaking, happen.
Which orders those are is a calculation in its own right, and it comes first — the email explains a decision that something else has already made.
A template the team can edit, safely
Letting non-developers edit HTML that goes to customers is the part that needs care, and the care is mostly about refusing things.
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.
const ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => ESCAPES[c]);
/** Every placeholder the team may use, and where each one comes from. */
export const FIELDS = {
"order.number": (o) => o.number,
"order.date": (o) => o.date,
"customer.name": (o) => o.customer?.name,
"customer.company": (o) => o.customer?.company,
"order.total": (o) => o.totals?.formatted,
"order.expected_date": (o) => o.expectedDate,
};
const PLACEHOLDER = /\{\{\s*([a-z0-9_.]+)\s*\}\}/gi;
export function renderTemplate(template, order) {
if (typeof template !== "string") throw new Error("template must be a string");
const unknown = new Set();
const missing = new Set();
const out = template.replace(PLACEHOLDER, (_match, rawKey) => {
const key = rawKey.toLowerCase();
const resolver = FIELDS[key];
if (!resolver) {
unknown.add(key);
return "";
}
const value = resolver(order);
// Empty string counts as missing. Rendering it as nothing produces
// "Your order for will ship on .", which reads as carelessness to the
// customer and is invisible to whoever wrote the template.
if (value === undefined || value === null || String(value).trim() === "") {
missing.add(key);
return "";
}
return escapeHtml(value);
});
if (unknown.size > 0) {
throw new Error(`unknown placeholder(s): ${[...unknown].sort().join(", ")}`);
}
if (missing.size > 0) {
throw new Error(`no value for placeholder(s): ${[...missing].sort().join(", ")}`);
}
return out;
}
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.
Why HTML rather than a builder
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.
Deliverability is DNS, not code
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.
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.
What this does not do
It does not replace Shopify's notifications 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.
It is not marketing email. 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.
It does not write the message. 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.
And it inherits its data. An email composed from a stale cache will confidently tell a customer a date the ERP has already moved.
When this needs an engineer
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.
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 whether it was sent is a question the business needs answered later. That was the case for a wholesale operation whose supplier and order tooling already knew what could ship; the email module was built into that portal rather than beside it, because the send belongs where the decision was made.
Takeaways
- 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.
- Shopify Flow can reach external systems — the Send HTTP request action returns data into the workflow on Grow, Advanced and Plus — so the ERP data cannot get into a customer email is not the limitation. What Flow produces for customers is a marketing automation.
- 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.
- An operational email that is not logged against the order did not happen, as far as anyone asking three weeks later is concerned.
- 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.
One thing I'd like other people's take on.
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.
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.
If you've built something similar, which way did you go — and did you regret it?