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 that 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, and that to automate emails to customers you should create a marketing automation instead, and it notes 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;
}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. Weldaad's sends go through ZeptoMail, and the point of the abstraction is that this sentence could change without the templates noticing.
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, as custom Shopify app development, because the send belongs where the decision was made.
Send us the store and the symptom.
