Your Phone Is the Pager: Killing the Plastic Buzzer with a QR Code
How I designed a kiosk-to-phone pager for restaurant ordering — scan the QR on the kiosk screen and your own phone becomes the buzzer that vibrates when the food is ready. The link handoff, the native bridge, and the fallbacks that never dead-end — plus part two: the version that needs no scan at all, driven from the kitchen screen straight to app push, WhatsApp or SMS.
Namith K P
· 35 min read
Every food court in every shopping mall has the same object in it: a chunky plastic coaster with a red LED ring, sitting on the table in front of you, waiting to buzz. You paid for your food, you got handed the puck, and now you're tethered to it. It's one of those pieces of technology that works well enough that nobody questions it — and the moment you actually look at it, it's absurd. Every single customer is already carrying a device with a screen, a speaker, a vibration motor, a network connection, and a notification system that they check compulsively. And we hand them a second, worse device to carry, because the first one wasn't wired into our system.
So I've been building the thing that removes it. The idea in one sentence: you order at the kiosk, a QR code appears on the confirmation screen, you scan it, and your own phone becomes the pager — live order status while you wait, and a push notification with a vibration alert the moment your food is ready.
This post is the design and engineering story of that feature — how the handoff actually works, why the QR points where it points, what happens when the customer doesn't have the app, and the failure modes I had to design around. It's built on top of the cloud POS and kiosk platform I work on, and the first implementation targets one of the quick-service brands running on it. I'll be upfront about status at the end: the web side is shipped, the native and backend halves are the next phase. I'd rather write about a real system mid-build than a fake one that's finished.
What I'm actually replacing
Before designing anything, it's worth being precise about what the plastic pager is good at, because if you don't understand why an incumbent works you will confidently ship something worse.
The physical pager has real virtues. It requires zero setup from the customer — no app, no account, no scanning, no network. It's completely reliable inside its range. It works for the customer who doesn't speak the local language and the customer whose phone is dead. Staff hand it over in one second without explaining anything. That's a high bar, and any replacement that ignores it is a replacement that gets quietly abandoned by store staff within a week.
But the costs are real too, and they're the kind that hide in an operations budget rather than a product spec. The units cost money and they get lost, stolen, dropped in drinks, and walked out the door — every venue I've seen has a slow bleed of pagers and a manager who is mildly annoyed about it. They need charging, so there's a base station occupying counter space and a staff ritual around it. They're limited by radio range, which means the customer can't wander two shops down the mall while they wait, which in a food court is precisely what people want to do. They carry no information — a buzz tells you something happened, not what stage the order is at or how long is left. They're a shared physical object passed hand to hand, which nobody loved thinking about after 2020. And they don't scale: a busy venue needs as many pagers as it has concurrent orders, forever.
There's a detail I find quietly funny here. The POS system already has a pagerNo
field on the order — the number of the physical puck handed to that customer, stored
alongside the order like any other attribute. The data model has been carrying the
plastic around for years. Part of what this feature does is make that field vestigial.
The design, in one line
Kiosk order placed
→ QR on the success screen
→ brand-owned /order-status URL
→ app installed? open in app → native push + vibration when ready
→ app not installed? open in browser → live tracker + store download promptThat's the whole thing. Everything below is what it takes to make each arrow in that diagram survive contact with reality.
The architecture on one page
Three planes: what's in the store, what's on the customer's phone, and what's in the cloud. The purple path is the pager mechanism itself — the bridge call that registers the device, and the push that comes back.
Two properties of that picture are worth naming, because they're the whole design.
Both phone branches load the identical URL. The app doesn't get a special deep link and the browser doesn't get a degraded one — there is exactly one page, and the only difference is what it's running inside. That's what makes the "no app installed" case a fallback rather than a separate feature to build and maintain.
The push travels back on a different path than the status. The tracker pulls status from the POS; the alert is pushed from a service that knows which device to wake. Those are two different questions with two different answers, and conflating them is the mistake that makes people try to build a pager out of polling.
The decision flow, scan to buzz
Every branch below terminates somewhere useful. That's the property I was designing for — no path ends in an error or a wall.
The shape I care about is that the tree is wide at the top and safe at every leaf. The customer with the app gets a pager. The customer without it gets a live tracker and an honest offer. The customer who denies notifications gets the tracker too, through the same code path rather than a special case. And underneath all of it sits a printed number that works with no phone at all.
Why the QR points at my domain, not the POS
The first real decision, and the one that everything else depends on, is what URL the QR code actually encodes. The lazy answer is to point it straight at the POS platform's order-status endpoint — the data lives there, so why add a hop?
Two reasons, and the second one is structural.
The first is ownership of the experience. The customer scanning that code is standing in a Chicking restaurant, and what opens on their phone should look like Chicking, not like a backend vendor's generic status page. The QR handoff is a brand moment, not a plumbing detail.
The second reason is the one that makes this non-negotiable: a native app can only
claim links on a domain it has verified. iOS Universal Links require an
apple-app-site-association file served from the domain, tied to the app's bundle ID
and team ID. Android App Links require an assetlinks.json naming the package and its
release signing fingerprint. That verification is the entire mechanism by which
"scanning a link opens the app instead of the browser" works at all. If the QR points
at a domain I don't control, I can never make the app intercept it, and the whole
pager concept collapses into a web page. The QR must live on a domain the brand's app
can claim. Everything downstream — the tracker, the app handoff, the push
registration — hangs off that one property.
There's a smaller sibling decision in the same area that took me a minute to spot. The kiosk itself runs on a kiosk-specific host, and the naive implementation builds the QR URL from whatever origin the kiosk happens to be running on. That would print a QR pointing at a kiosk domain, and a customer's phone would end up on a screen built for a 27-inch touch panel bolted to a wall. So origin selection is explicit, with the kiosk host actively rejected:
function getPublicOrderStatusOrigin() {
const configured =
process.env.NEXT_PUBLIC_KIOSK_ORDER_STATUS_ORIGIN ||
process.env.NEXT_PUBLIC_SITE_URL ||
'https://chicking.nz';
// Local dev scans its own origin so a phone on the same LAN can test.
if (isLocalhost(window.location.hostname)) return window.location.origin;
const url = new URL(configured);
// A customer phone must never land on a kiosk host.
if (url.hostname.toLowerCase().includes('kiosk')) return 'https://chicking.nz';
return url.origin;
}It's five lines of defensive code, and it's the difference between a polished handoff and a customer staring at a kiosk attract screen on a 6-inch phone.
What the QR carries
The URL is deliberately self-describing, because the page it opens has to work for a stranger's phone with no session, no cookie, and no account:
/order-status
?id=<orderId>
&ticketId=<base64 orderId>
&source=kiosk_qr
&orderType=DINEIN|TAKEAWAY|DELIVERY
&storeOnlineKey=<branch key>Each parameter earns its place. id is what the status API is queried with.
ticketId is the base64-encoded order ID the receipt/details endpoint expects — I
carry both rather than re-deriving one from the other on the client, so a change in
either API's expectations doesn't silently break the other. source marks the traffic
as kiosk-QR so this flow is distinguishable in analytics from someone opening their
order history in the app. orderType lets the page render correct copy on first paint
instead of flickering from "pickup" to "dine in" once the API answers. And
storeOnlineKey scopes the order to a branch, which matters the moment a brand has
more than one outlet.
The page that receives it is a standalone public route — no site header, no footer, no
kiosk chrome — and it's marked noindex, nofollow. That last part isn't an
afterthought. These URLs contain live order identifiers; they exist to be scanned once
by one person and then to become worthless. They have no business in a search index.
The tracker: polling with an off switch
While the order is live, the page polls the POS order-status API every 15 seconds and renders a four-step progress bar: Placed → Kitchen → Ready → Complete.
Two things about that loop are more considered than they look.
The first is that polling stops at terminal states. Once an order reaches READY,
COMPLETED, or REJECTED, there is nothing further to learn, and a page left open on
a table — which is exactly what this page is designed to encourage — would otherwise
poll forever, burning the customer's battery and my API quota on a question that has a
permanent answer. A polling loop with no termination condition is a small leak that
scales linearly with how successful the feature is.
The second is status normalization. The POS reports order state through several
overlapping fields — orderstatus, order_state, ticket_state, ticket_status,
plus a human-readable response_text — and different flows populate them differently.
Rather than scatter that ambiguity through the UI, everything funnels through one
function that collapses the real world into the five states the interface knows how to
draw:
function deriveStatus(orderStatus, orderState) {
const status = String(orderStatus || '').toUpperCase().trim();
const state = String(orderState || '').toLowerCase().trim();
if (status === 'REJECTED' || status === 'CANCELLED') return 'REJECTED';
if (state === 'completed' || status === 'COMPLETED') return 'COMPLETED';
if (state === 'ready' || status === 'READY') return 'READY';
if (status === 'ACCEPTED') return 'ACCEPTED';
if (status === 'PENDING' || status === 'PLACED') return 'PENDING';
return 'PROCESSING';
}Note the fallback. An unrecognized status doesn't crash and doesn't render an empty
shell — it renders PROCESSING, which says "we're checking the kitchen screen, this
page updates automatically." When you're translating another system's vocabulary into
your own, the honest default is I don't know yet, not a blank.
The order receipt — items, modifiers, totals — is fetched separately through my own API route rather than called directly from the browser, because the POS's details endpoint isn't CORS-friendly from arbitrary origins. That fetch is treated as strictly best-effort: if the details don't load, the tracker still renders the status, the number, and the progress. The customer's question is "is my food ready?", and an itemised receipt failing to load must never be allowed to block the answer.
The number on the screen is not the order ID
A small detail that turns out to matter enormously for whether this feels like a real product or a demo.
Internal order IDs look like OOT194638331781787784919. That is not a number a human
can hold in their head, read out to a staff member, or match against a screen above a
counter. What the customer needs is the short collection number — the same one printed
on the receipt and shouted across the counter.
So resolving the display number is a deliberate preference chain, not a single field read: the short ID from the status response first, then the one attached to the order record, then a number parsed out of the POS's human-readable response text, and only as a last resort the trailing six digits of the full ID.
Full order id: OOT194638331781787784919
Short ID: 784919
Ticket suffix: 87784919That last fallback is inelegant and I keep it anyway, because the alternative is a customer holding a phone that displays nothing where a number should be. A slightly wrong-looking number they can still match to the counter screen beats a confident blank. Graceful degradation is mostly a series of small, ugly, correct decisions like this one.
Dine-in, takeaway, and delivery are three different feelings
The same tracker serves all three fulfilment types, and the copy shifts under it, because "your order is ready" means three genuinely different things.
For takeaway, ready means go get it: "Your order is hot and ready — pick up number 784919. Please proceed to the counter."
For dine-in, ready means stay where you are, we're bringing it — so the third progress step is labelled Serve rather than Ready, and the action banner says "Show #784919 at the counter." The customer's job is to be findable, not to walk.
For delivery, ready isn't the end of the story at all — it becomes "On the way."
This is the least technically interesting part of the feature and one of the parts most likely to decide whether staff like it. Get it wrong and you have dine-in customers standing up and crowding the counter because their phone told them to fetch food that was already being carried to their table. Copy is operational design.
Knowing you're inside the app
Everything so far works in a plain mobile browser. The pager part — the part where you can pocket the phone, walk two shops down, and get buzzed — needs the native app, because a web page cannot notify a customer who has closed it. That's the entire reason the native layer exists, and it's worth stating plainly: polling is for the page in front of you; push is for the phone in your pocket. No amount of clever web engineering closes that gap.
The web page detects its context through a flag the Flutter shell injects before the web app hydrates:
export function isFlutterWebView(): boolean {
const win = window as FlutterWindow;
return win.isFlutterWebView === true || isLocalMobileAppPreview(win);
}The isLocalMobileAppPreview branch is a development affordance I've come to think of
as mandatory on any hybrid app: on localhost only, ?mobile_app=1 makes the web app
behave as though it's inside the shell. Without it, every iteration on app-only UI
requires a native rebuild, and a feedback loop that slow means the app-only paths get
tested least and ship worst. The security property that keeps this honest is that it's
gated on localhost, so it can't be triggered in production by a crafted URL.
The bridge contract
When the page knows it's inside the app, it swaps the "download our app" card for a Ready alerts card with an Enable alerts button. Tapping it calls across the WebView bridge into native code:
window.flutter_inappwebview.callHandler('enableOrderAlerts', {
orderId: 'OOT194638331781787784919',
ticketId: 'T09UMTk0NjM4MzMxNzgxNzg3Nzg0OTE5',
shortId: '784919',
orderType: 'DINEIN',
storeOnlineKey: 'chickingbushinn',
branchName: 'BUSH INN',
status: 'PENDING',
receiptUrl: '…',
source: 'kiosk_order_status',
});The native side's job is the part the web genuinely cannot do: request notification
permission if it hasn't been granted, obtain or refresh the FCM/APNs device token,
send the order plus that token to the backend, and answer the WebView with
{ ok: true, tokenRegistered: true } or a reason for failure such as
permission_denied.
Designing that boundary, I kept coming back to one principle: the bridge is a
network call that happens to be in-process. It can be absent (older app build), it
can hang, it can reject, and it can succeed while the thing it promised silently
doesn't happen. So the handler name is configurable rather than hardcoded, so an app
release that renames it doesn't require a coordinated web deploy. Every call is
wrapped, and a missing bridge returns a typed bridge_unavailable rather than throwing
into a React event handler. And the failure copy tells the customer something true and
useful rather than something technical:
Ready alerts need the latest Chicking app update. Keep this page open for live status.
That sentence does two jobs at once — it names the actual likely cause, and it redirects the customer to the fallback that still works. The button is also disabled outright for terminal orders, because offering to alert someone about food they're already holding is the kind of small incoherence that makes people trust software less.
The vibration is the product
The moment the whole feature exists for is the buzz. A notification you have to look at is not a pager; the plastic puck's entire user interface is "it vibrates and you stand up." So haptics get first-class treatment rather than being an afterthought.
Inside the app, feedback goes through the bridge to Flutter's native haptics — a
success pattern when alerts are enabled, a warning pattern when something fails. On the
plain mobile web, it falls back to navigator.vibrate with hand-tuned patterns
(success is [12, 40, 18] — a short tap, a pause, a slightly longer confirm).
The detail I'm most pleased with is the gate on that fallback:
const isTouchPrimary = window.matchMedia('(hover: none) and (pointer: coarse)').matches;
if (!isTouchPrimary) return;Vibration only fires on devices whose primary input is a finger. A Windows touchscreen
laptop technically supports navigator.vibrate and technically has a touch pointer,
but a machine that buzzes mid-click when someone is using a trackpad feels broken, not
delightful. iOS Safari ignores vibrate entirely; Android Chrome honours it. Knowing
which platforms will silently no-op is part of designing the feature honestly rather
than shipping something that works on the one phone in your pocket.
The customer without the app
Here's the case that makes or breaks adoption in a real food court, because on day one approximately nobody has the app installed: the same URL has to work for someone who has never heard of it.
The pattern I settled on is that the status URL is the dynamic link. There's no third-party link-shortening service in the middle — which is just as well, given that the industry's default answer for years, Firebase Dynamic Links, has been sunset. The behaviour falls out of the platform primitives instead:
- App installed → iOS Universal Links / Android App Links intercept the URL and it opens inside the app's WebView, where the native alert card is available.
- App not installed → the identical URL opens in the mobile browser as a fully working live tracker, plus a card offering the app, with the store chosen by platform detection: App Store for iOS, Google Play for Android.
The important property is that there is no dead end. The unhappy path isn't an error, or an interstitial, or a forced install wall — it's a slightly less magical version of the same product. The customer who ignores the app entirely still gets a live status page, which is already better than a plastic coaster that can only say one thing. The app upgrades the experience from "check your phone" to "your phone taps you on the shoulder," and that's a good enough reason to install it without being coerced.
The platform detection includes the iPad wrinkle that catches everyone, since modern iPadOS reports a Mac user agent:
const isIPad = /iPad/.test(ua) || (ua.includes('Mac') && 'ontouchend' in document);Restraint in the download prompt
There's a mobile bottom sheet that offers the app. Getting it to not be obnoxious took more thought than building it, and every condition on it is a rule I'd defend:
It only appears 3.5 seconds after the page settles, so the customer's first
experience is their order status — the thing they scanned for — not an ad. It only
appears on coarse-pointer devices, because a download prompt on a desktop browser
is noise. It never appears for terminal orders, since a customer whose food is
already ready has no use for a pager. Dismissing it is remembered in localStorage
for three days, so a regular customer isn't nagged at every visit. And its two
buttons are Download app and "Keep tracking" — the decline option is framed as
the thing they actually want to keep doing, not as a guilt-shaped "No thanks."
The condition I care about most: the prompt does not render at all unless a real store URL is configured. During the build, the store URLs didn't exist yet, and the tempting move is to ship the CTA behind a placeholder and fill it in later. A download button that goes nowhere doesn't just fail — it teaches the customer that this feature is broken, and they will not try it a second time. So when the store link is missing, the card degrades to honest copy: "Keep this page open for live updates. App pickup alerts are being connected for kiosk orders."
The thing I deliberately did not ship
Related, and worth its own section because it's the decision I'd most want a junior engineer to take from this post.
Universal Links and App Links need those verification files:
/.well-known/apple-app-site-association
/.well-known/assetlinks.jsonI had the routes ready and I did not publish them, because I didn't yet have the real Apple team ID, bundle ID, Android package name, and release signing fingerprint. Publishing placeholders would have felt like progress on a checklist. In practice, both platforms fetch and cache these files, verification fails against wrong identifiers, and you inherit a stretch of time where scanning the QR sometimes opens the app and sometimes doesn't — which is far worse than consistently opening the browser. An unreliable magic feature is worse than a reliable ordinary one, because customers can't form a correct mental model of unreliable, and they stop trusting the whole flow.
"Not built yet" is a clean state. "Built wrong and cached by Apple" is a mess with a tail. The temptation to fill in a placeholder to make a checklist look complete is one of the more expensive habits in this line of work.
The backend half
The web layer answers "what is the status of this order?" — the existing status API does that well. It cannot answer the question the pager actually depends on: "which phone should be notified when this order changes?" Nothing in the current system stores that relationship, so it needs a small, purpose-built layer:
- Register:
POST /api/order-status/alertsbinds an order to one or more device tokens, with platform and current status. - Watch: either poll the status API server-side for registered orders, or consume a POS webhook if one is available.
- Send: push when the order reaches ready, with fulfilment-appropriate copy — "Order #784919 is ready for pickup" for takeaway, "Order #784919 is ready to serve. Please show this screen at the counter" for dine-in.
- Deduplicate: mark the notification sent, because a status watcher that fires twice buzzes the customer twice, and a pager that cries wolf gets ignored exactly like a smoke alarm does.
I want to flag the naming choice, small as it is. I went with
/api/order-status/alerts rather than a sprawling /api/order-alert-subscriptions,
because grouping it under order-status says something true about where this belongs:
it's not a new subscriptions subsystem, it's one more thing you can do with an order's
status. API paths are documentation that can't drift, and the shape of your URLs is
the shape of how the next engineer will think about your system.
Note also that one order maps to many tokens, not one. Two people at a table can both scan the same QR from the same receipt, and both should get buzzed. Modelling that as one-to-one on day one would be a migration later for no reason.
What still worries me
Honest engineering means naming the things I haven't solved, and there are a few.
Notification permission is a hard gate. If the customer denies it — or denied it
months ago for unrelated reasons — the pager silently isn't a pager. The native handler
returns permission_denied and the web page can explain, but a customer who taps
"Enable alerts," sees a success-ish state, and then never gets buzzed is worse off than
one who never tried. That path needs the most careful copy in the whole feature, and
it's on my list.
Silent mode and Do Not Disturb. A plastic pager vibrates regardless of what your phone thinks about interruptions. A push notification respects a focus mode that the customer configured for entirely different reasons. There is no fully satisfying answer here — which is exactly why the visual tracker and the printed short number stay first-class rather than being treated as legacy.
The dead battery. The physical pager is charged by the venue; the phone is charged by the customer. This is a real regression for a real slice of people, and the honest answer is that the printed receipt with the collection number is still the floor of the system. I'm not removing the physical pagers from a venue on day one — I'm making them unnecessary for the large majority who'd rather use their own phone.
Staff behaviour. The counter still needs to work when a customer's phone flow failed. Anything that requires staff to know which customers are on phone alerts and which have pucks is a bad design. The system has to stay one queue with one collection number, where the phone is a notification channel and never a source of truth.
Notice the pattern in all four: the answer is never "make the phone path more clever." It's "make sure the layer beneath it still holds." The stack degrades — native push, then live web tracker, then the number printed on the receipt — and every layer works on its own.
Part two: the pager that needs no scan
Everything above starts with a customer scanning a QR code. That's a real action, and real actions have drop-off. Some people won't scan. Some phones have a camera app that fights them. Someone hands the receipt to a friend. And on a busy counter, "scan this" is one more sentence a staff member has to say and one more thing to go wrong.
So the natural next question is: when do we already know who the customer is, and can we skip the scan entirely?
Quite often, it turns out. The order isn't anonymous nearly as often as the QR design assumes. A member scans their loyalty QR at the counter. A cashier types a phone number for the receipt. The customer is standing there with the brand's app already open and signed in. In every one of those cases the system has an identity before the food is cooked — and an identity is all a pager really needs.
That reframes the whole feature. The QR flow answers "how do I reach a stranger's phone?" Part two answers a different and easier question: "how do I reach a customer I already recognise?"
What identity looks like at order time
Three things can bind an order to a person, in descending order of how good the resulting experience is.
A member code. The loyalty account already carries a customer_code, and the app
already displays it as a scannable member QR in the wallet and profile screens. That
QR exists today so customers can earn points at the counter. The realisation worth
having is that it's the same handshake, running the other direction: instead of the
customer scanning the kiosk, the counter scans the customer. One scan, already part of
the loyalty ritual, and now the order knows whose phone to buzz.
A signed-in app session. If the customer ordered from the app in the first place —
or is simply signed in on the device — the identity is already established. The native
shell hands the web layer the customer identifier over the same bridge it uses for
everything else, preferring customer_code and falling back to customer_id, and the
Firebase UID and push token are already on hand.
A phone number. The weakest form and by far the most common: someone reads out ten digits at the counter. No app, no account, no loyalty. But it's enough to send a message, which turns out to be enough to build a pager on.
The important detail is that the checkout payload already has room for all of this. It
accepts customer_id, fuid, and — this is the one that matters most — fcmToken.
A push token, at order-creation time, in the same call that creates the order.
That's the whole trick of part two. If the token is on the order when the order is born, there is nothing to register later, no bridge call to make, no page for the customer to keep open, and no QR to scan. The binding that the entire part-one architecture works to establish through a scan can, in the identity case, simply be an attribute of the order from the first millisecond.
The trigger moves to the kitchen
Part one is customer-triggered: someone scans, a page opens, that page polls. Part two is kitchen-triggered, and that's a better shape.
The chef is already touching a screen. When an order is accepted, when it's fired, when
it's plated and ready — the kitchen display is where the truth about that order actually
changes. The status vocabulary already exists and already moves: an order goes from
placed, to confirmed by the kitchen, to prepared, to served, carried in order_state
and ticket_state on the ticket.
Nobody has to do anything new. The chef advances the ticket exactly as they do today, and that single tap becomes the event that notifies every customer who's bound to that order, on every channel they're bound through. The notification stops being a feature the customer opts into and becomes a consequence of the kitchen doing its job.
This is the part I like most architecturally, because it inverts the effort. In part one, the customer does work (scan, open, enable, keep the page alive) so that the system can tell them something. In part two, the system does the work, and the customer does nothing at all. The best interface for "your food is ready" is no interface — just a buzz that arrives.
Three channels, one event
Once the trigger is a status change rather than a page, the delivery mechanism becomes a fan-out: one event, dispatched to whichever channels that order actually has bound.
App push is the real pager — instant, silent until it matters, and the only channel that can vibrate the phone in someone's pocket the way the plastic puck did. It fires whenever a device token is on the order, whether the token arrived at checkout or through the QR path from part one. Same push, same payload, two ways of getting bound.
WhatsApp is the one that changes the economics, and in most of the markets I build for it isn't a nice-to-have — it's where people actually read messages. If all we have is a phone number, a WhatsApp template message on each meaningful status is a genuinely good pager: it lands on the lock screen, it buzzes, it threads into a conversation the customer can scroll back through, and it costs a fraction of what the equivalent SMS volume does. Crucially it requires nothing from the customer — no app, no install prompt, no account. The number they gave for the receipt is the number that buzzes.
SMS is the floor beneath WhatsApp. It works on a phone with no data, no smartphone OS, and no messaging app, which is exactly the customer the other two channels quietly exclude. It's the most expensive per message and the least expressive, so it's the fallback rather than the default — used when WhatsApp can't deliver, or when a venue serves a demographic where it's simply the right call.
The design rule tying them together: one message per meaningful state, per order, per channel, and never two channels saying the same thing. If a device token is bound, the push is the pager and the WhatsApp message doesn't fire — a customer who gets buzzed twice for one plate of food learns to ignore both. Channel selection resolves once, in priority order, when the notifier runs.
Which states are worth interrupting someone for
More status updates is not a better product. Every message is an interruption, and the quickest way to make a customer mute a channel forever is to narrate the entire life of their order at them.
So the states earn their message individually:
Accepted by the kitchen — worth one message, and only for orders placed remotely or scheduled ahead. It answers a real anxiety ("did that actually go through?"). For a customer standing ten feet from the counter who just paid, it's noise; they watched it happen.
Ready — the message the whole system exists for. Always sent, on every channel, on every path. This is the buzz.
Delayed or rejected — the underrated one. Physical pagers cannot express this at all, which means the customer's only recourse today is to walk up and ask. A message that says the kitchen is running behind, or that something on the order can't be made, turns the worst service moment into a manageable one. In many ways this is a bigger upgrade over the plastic puck than the ready alert itself, because it's information the old hardware was categorically incapable of carrying.
Completed or served — almost never worth sending. The customer is holding the food. They know. The only reason to send anything here is a receipt or a rating prompt, and that's a different feature with different consent, not a pager alert.
That editorial judgement — which events deserve a vibration and which are just the system talking about itself — is most of the design work in a notification feature. The plumbing is easy. Knowing when to shut up is not.
What gets harder
I want to be as honest about part two's costs as I was about part one's, because the identity-first path is more convenient and more dangerous.
Consent is not implied by a phone number. Someone giving their number so a receipt can be emailed to them has not asked to be messaged. Order-status updates are transactional and defensible; anything drifting toward marketing is a different conversation with different rules, and platform policy on WhatsApp business messaging draws that line hard. The engineering fix is boring and non-negotiable: separate the transactional binding from any marketing consent, store them apart, and never let a promotional send borrow a transactional binding.
A wrong number messages a stranger. Ten digits read aloud across a noisy counter is an error-prone channel, and the failure mode isn't a lost notification — it's an unrelated person receiving somebody else's order status. That's a small privacy incident, and it's why the message should carry the collection number and the venue but never a customer's name or the itemised order.
Stale tokens rot. A device token captured at checkout is only valid until the customer reinstalls, logs out, or the OS rotates it. Push silently fails against a dead token — you don't get an error the customer can see, you get nothing. Any serious implementation has to treat delivery failure as a signal, prune dead tokens, and fall through to the next channel rather than assuming a queued push is a delivered one.
Cost per message is real. Polling a status API is nearly free; WhatsApp and SMS are priced per send. That changes the calculus about which states are worth a message from a design opinion into a line item, which is a useful discipline — it makes restraint economically obvious as well as correct.
The two halves are one system
It's tempting to read part two as a replacement for part one. It isn't, and the distinction matters: they bind the same thing through different doors.
The QR path binds an anonymous walk-in to their order in the ten seconds after they pay. The identity path binds a recognised customer to their order before the kitchen even sees it. Both end at the same place — an order that knows how to reach a human — and both feed the same notifier, the same status vocabulary, the same dedupe rules, and in the app case the exact same push.
Which is why the sequencing works: part one had to be built first, because it forces you to solve the general problem of "how does an order reach a phone" without the luxury of knowing who anybody is. Once that exists, the identity path is a shortcut into it rather than a parallel system. Part two doesn't replace the scan — it just means most customers never need one.
And that's the honest end state I'm building toward. Not "scan this QR code to track your order," which is still asking something of a person who just wants lunch. Just: the food is ready, and their phone tells them, through whatever channel they already had.
Where it stands
Being straight about status, because I think mid-build posts are more useful than retrospectives written after everything went well.
Shipped: the whole web layer. The kiosk QR points at the brand-owned status URL with the right parameters and the right origin rules. The public tracker renders live status, the short collection number, fulfilment-aware copy, receipt and PDF actions, and it polls with proper termination. It detects the app WebView, shows the native alert card there and the app-pager prompt in a browser, and calls the bridge. Store URLs, Smart App Banner metadata, and the alert handler name are all configuration, so the remaining pieces slot in without touching the UI. It's been QA'd on mobile and desktop with real order IDs and passes lint, type check, and a production build.
Next: real App Store and Play Store identifiers, then the verification files, then
the native enableOrderAlerts implementation on the Flutter side, then the backend
registration endpoint and the status watcher that actually sends the push.
Part two is design, not code. The identity-first path is grounded in things that
already exist — the member QR and customer_code, the bridge that hands the app's
customer identifier to the web layer, and a checkout payload that already accepts
fcmToken and fuid — but nothing in it is built yet. It shares a dependency with part
one anyway: both need the notifier and the status watcher, so the first half of that
work is the same work.
The sequencing is deliberate. The web layer is the piece every path depends on and the piece that's useful on its own — a customer scanning today already gets live tracking, without a single line of native code existing. Each subsequent phase is a strict upgrade to something already working, rather than a prerequisite for anything working at all. That's how I try to sequence any feature that spans web, native, and backend: make the first slice independently valuable, so a delay on one layer is a smaller experience rather than no experience.
Why I like this problem
I've written before that the software I find most interesting is the software that reaches out of the screen and changes something physical. This is a small, unglamorous example, and it's exactly my kind of problem.
There's no novel algorithm in it. There's no machine learning. It's a QR code, a status poll, a link that opens an app, and a push notification — all completely ordinary technology. What makes it work or not work is entirely a set of judgement calls about the messy edges: which domain the link lives on, what happens to the customer who has no app, whether the prompt appears before or after the thing they actually came for, what the page says when the receipt fails to load, and whether you have the discipline to not publish a placeholder file that would make a checklist look finished.
And the outcome, if it lands, is that a physical object disappears from the world. Ten thousand plastic pucks that don't need to be manufactured, charged, sanitised, replaced, or chased across a food court. A customer who can wander to the other end of the mall and still get tapped on the shoulder when their chicken is up. A venue that stops absorbing the slow bleed of lost hardware.
That's the trade I keep looking for in this job — take something ordinary that everyone has stopped noticing, look at it properly, and replace it with software that was already in everybody's pocket.