woocommerce Case study
Transactional SMS Platform
A shop believes its customers were told. The whole class of bug in transactional SMS is that nothing in the shop can see that they were not.
The business problem
A watch and jewellery retailer sent text messages from eight places: one-time login codes, abandoned-cart and failed-order recovery, post-purchase reward coupons, back-in-stock alerts, the loyalty club, the repair desk, the marketplace, and order confirmation from an invoicing plugin. They shared one gateway account and nothing else. Some paths went through a plugin that had been deactivated, some through the gateway's own SDK, and none of them wrote down what they sent or what the gateway answered.
The failures were invisible by construction. The gateway returns HTTP 200 with a status code of 424 for a pattern that is unregistered or not yet approved, so an unapproved template looks like success to code that only checks the HTTP status. There is no endpoint that lists a store's patterns, and this account's outbox listing was blocked, so the only way to know whether a pattern existed was to send with it. Two whole systems had been silently dead for weeks.
What I delivered
- One send function that actually delivers, in a must-use plugin, with every other path retired: the inactive SMS plugin's route was dead, and the code that still called it was rewired to the one that works.
- A passive audit log hooked into WordPress's HTTP layer that records every send to the gateway — masked number, pattern name, gateway status and message id — without any sending code having to remember to log.
- A management tab that carries each of the 55 registered patterns with its registered text, its purpose and its usage, and sorts them into four states that look identical from outside: wired and proven by the log; sent but invisible to the log, because the invoicing plugin calls the gateway's SDK directly and a zero there does not mean nothing went; registered but unwired, with no code sending them; and registered but unapproved, which fail silently with 424.
- Cart and failed-order recovery on Action Scheduler rather than wp-cron, gated by a popup-submit event, a rate limit, a purchased check and a gift-card rule, also covering pending-to-failed orders and a pending sweep; the recovery-sent action feeds the shop phone's alert queue, because 'the SMS went' is a fact while 'the cart was abandoned' is a guess.
- Birthday coupons issued seven days ahead and sent by pattern, with the send status kept on the voucher and a resend button on the operator app's birthday view; post-purchase reward coupons fired once per order on a paid or delivered status, with success and failure recorded on the order.
- A repair-desk SMS screen where each pattern's name is editable and a probe button sends a real message only to the operator pressing it, so renaming a pattern needs no deploy — plus a master switch that stops every repair message and nothing else.
Technical approach
- The audit log is passive on purpose. It hooks the HTTP layer, not the senders, so a new SMS path is logged the day it ships and nobody has to remember it. The one blind spot — a plugin that bypasses WordPress's HTTP functions — is named on the management tab rather than papered over.
- The token rules were measured on the live gateway, not read from documentation: a zero-width non-joiner is refused in every slot and discards the entire message; spaces are forbidden in plain tokens and allowed in the spaced ones; a spaced token sends thirty Persian characters and fails at thirty-one; a link of forty-eight characters fits a plain token. An earlier diagnosis had blamed link length for the recovery and stock-alert failures. The real causes were a non-joiner inside a product name and a pattern name that never existed in the panel.
- Silent status codes get surfaced. The send path records the gateway's status on the voucher or the order, the operator view shows it, and the birthday view can resend; a 424 is now a thing a person can see instead of a text the shop assumes went.
- The admin-speed guard that protects slow admin requests had a safe mode that short-circuited outbound HTTP for cron and admin-ajax without firing the HTTP hook — so cron sends failed with no audit line at all. The gateway host is now hard-coded as business-critical in every context; the lesson, kept as a rule, is that a failed send with nothing in the log means a short-circuit before it means the gateway.
- Names are treated as configuration. Pattern names cannot be renamed at the gateway and editing an approved text sends it back for review, so the repair family reads its names from an option through a filter at send time; the constant in code is only the key.
Result and evidence
Every transactional message on the store now goes through one key, one send function and one log. The recovery pattern in use since the rename has passed every scheduled run clean; a dozen birthday vouchers that had failed under the guard's safe mode were resent after the fix; the back-in-stock pattern is wired and proven by the log with the product name in a spaced token and the link in a plain one. The management tab's four states are what I would point a reviewer at: the platform's honesty is that it shows the difference between 'sent', 'sent but not visible to me', 'not wired' and 'not approved' instead of one green light. No delivery-rate figure is claimed; the gateway's per-number outcomes are not something the store controls.
Commercial value
For an Iranian retailer, pattern SMS is the transactional channel — the login code, the recovery nudge, the coupon, the 'your watch is ready'. When two systems can be dead for weeks with every dashboard green, the cost is not a bug report, it is customers who were never told. A platform where every send is logged, every silent status is surfaced and every pattern's state is visible turns the channel from something the shop hopes works into something it can check.
Readable implementation brief
implementation_brief {
project: "Transactional SMS Platform"
status: running on a live WooCommerce store
gateway: one key, one send function; the inactive plugin path retired
audit: passive HTTP hook -> masked number, pattern, status, message id
patterns: 55 registered -> proven | invisible-to-log | unwired | unapproved(424)
measured: ZWNJ refused everywhere; spaced token caps at 30 Persian chars
recovery: Action Scheduler, gated by popup + rate limit + purchased + gift card
occasions: birthday coupon 7 days ahead, status on the voucher, resend in /op/
rewards: coupon once per order on paid/delivered, outcome on the order
repair: pattern names editable + probe button, no deploy to rename
rule: "failed with no audit line" => a short-circuit, not the gateway
showcase: github.com/shiny-a2/mu-plugins-showcase
}What this project shows
The management tab's four states are the design. Anyone can build a send function; the work was admitting that 'sent' has three false friends and giving the operator a screen that tells them apart. That came from measuring the gateway rather than trusting its documentation, and from a log that senders cannot forget to write.
The guard incident is the kind of bug I expect in production systems: not the SMS code, but a neighbouring safeguard doing its job in the wrong context. Finding it meant reading the absence of a log line as evidence, which is now written down as a rule for the next integration that runs from cron.