Three of our seven Shopify apps sent merchants emails whose buttons went to a 404. Not for a day. For as long as each of those mailers had existed.
Here is the part worth an article: every one of those links was correct in the way a human checks a link. It named the right store. It named the right app. It named the right page inside the app. Anyone reading the email, or the code that built it, or the test that pinned the value, would have said it was fine. The failure was not a typo. It was that the platform keeps two names for the same app, and we had the right app under the wrong name.
Two Namespaces for One Thing
A Shopify app has a listing slug, which is the tail of its App Store URL, and an admin handle, which is what a merchant's own admin uses inside admin.shopify.com/store/<store>/apps/<handle>/app/<page>. They are set separately, they can be the same string, and they often nearly are. That last property is the whole problem. Two vocabularies that overlap almost completely give you nothing to notice when you reach for the wrong one.
A deep link in an email has to use the admin handle. If it uses the listing slug instead, the admin answers with the polite Shopify equivalent of nothing here: There's no page at this address. The link identifies the correct app. It is still the wrong identifier for that URL.
How It Happened Three Times
The mailer in each app builds its link from an environment variable, falling back to a literal written into the code. That design is fine. What went into the variable and the literal was not.
The first app was the only one in the fleet with no handle deployed at all, so it used its literal. The literal was the listing slug, ageproof-age-verification. The admin handle is ageproof. Every statement, alert and alarm that app had ever mailed carried a link built from the slug, and the whole time the literal read as obviously right, because it is the name on the store listing. It was caught from a statement that had just gone out, by the one person who clicked the button rather than reading it.
The second app had a handle deployed. It was neither the slug nor the handle. It was a plausible hyphenated spelling that had been reasoned into the deploy script on the day the mailer shipped, and it had been wrong since that day. Nothing about it looked wrong. It looked like the name of the app.
The third app was found the next day by refusing to stop at the app being fixed and checking all seven against a live admin instead. Its deployed value had a hyphen. Its real handle does not. Same failure, third door.
The property that hid it
A wrong value that looks nothing like the right one gets caught in review. A wrong value that is a plausible spelling of the right one gets waved through by every reviewer, every time, including the reviewer who wrote the test. Overlapping namespaces manufacture exactly that kind of wrong value at scale.
The Test That Had Verified a Value Against Itself
There was a fleet-wide test. It pinned the deployed handle for every app, and one of the values it pinned was wrong, because that value had been read out of the deploy script and written into the test as if reading it were verification.
It is worth being precise about what that test proved. It proved that the deploy script and the test agreed. Both were copied from the same guess. A test that checks configuration against configuration is a change detector, and a useful one, but it is not a verification of anything, and calling it one is how a wrong value acquires a green tick.
The table now records, for each of the seven apps, which real admin the handle was read from and on which day. That is not a bigger test. It is a different kind of claim. The number of apps in the table is the number of apps we have, and every value in it has been observed working in the place a merchant would click it.
The rule this encodes
A value copied from your own configuration is a claim, not a verification, until something outside the configuration has agreed with it. For a URL, the only thing outside is the page loading.
Why the Suffix Stayed
One detail is instructive, because the obvious tidy-up would have reintroduced the bug. One of the corrected handles carries a numeric suffix, which looked like an artefact of a development store and tempting to strip. It is not per-store. The suffix is global to the app: the same store carries two different apps with the same base name and different suffixes, and a third-party app on it shows the same pattern. Keeping the suffix was the right call, and it was only knowable by looking at a real admin, which is the same lesson from the other side.
What Was Not the Problem
Not Shopify. Two namespaces with overlapping vocabularies is a normal fact about a platform that has a public catalogue and a private admin, and both names are visible to anyone who looks in the right place. The failure was ours, twice by reasoning and once by copying, and the fix was to stop reasoning about a value that can be observed.
Nor was it the fallback design. A literal in the code behind an environment variable is a reasonable belt and braces. The fix went into all three layers at once: the deploy script now writes the verified value on every deploy, the literal in each mailer is the verified value, and the test table is the record of where each one was read. A redeploy cannot regress it, because the thing that was wrong is no longer anywhere in the chain.
Three Things Worth Stealing
- When a platform has two names for one thing, write down which one each URL wants. Not in your head. In the place the URL is built, as a comment that names the other namespace and says why it is wrong here.
- Verify identifiers where the user will use them. A deep link is verified by loading it in the admin it is meant for, in a real session. Reading it, however carefully, checks spelling.
- Separate change detection from verification in your tests, and label which is which. A pinned value is a fine change detector. It becomes a verification only when its source is something outside your own configuration, and the test should say what that source was.
The whole episode cost nothing a merchant would have noticed beyond a dead button, and it is still the most ordinary kind of bug we have written about here: correct in every way that can be read, wrong in the one way that has to be clicked.
The Other Shelf: Seven Shopify Apps
The fleet these mailers belong to, and the problem each app was built for.
The Seven Apps