WhatsApp as a control panel
Restaurant staff live in WhatsApp, not dashboards. So Plate lets them accept, decline or move a booking from a message, and makes sure a stale tap can't do what the dashboard wouldn't.
- Published
- Reading time
- 5 min
- By
- Asif Alam · Lead Engineer
A booking request is only useful if somebody sees it in time. Plate gives venues a web dashboard and a tablet app, and both do their job. But on a busy night, the person who decides whether a party of six fits at nine is usually on the floor, phone in pocket, with WhatsApp open.
So I took the decision to where they already were. When a booking needs an answer, the venue gets a WhatsApp message with buttons: accept, decline, or move it to another time. One tap, and the booking changes in the same database the dashboard reads.
That sounds like a nice shortcut. It's actually a second control panel, sitting in somebody else's app, that I don't control. Most of the work was in treating it that way.
Every tap is untrusted input
A WhatsApp button isn't a form on your own page. The message can sit unread for an hour. It can be tapped twice. By the time anyone taps it, the booking might already have been accepted from the dashboard, moved, or cancelled by the guest.
Each button carries a small payload that comes back to an Edge Function when it's tapped: accept: and the booking's ID, or for a move, the booking, a time and an offset. That string is the whole request, and it deserves the same suspicion as anything a stranger types into a form, even though I wrote it.
So the payloads are parsed by small, pure functions: a string goes in, and either a typed action or nothing comes out. No database, no network, no WhatsApp, which makes them easy to test properly. A malformed ID, an action that doesn't exist or an offset that isn't on the list is a one-line test case, not a production incident.
type Action =
| { kind: 'accept' | 'decline'; bookingId: string }
| { kind: 'move'; bookingId: string; time: number; offset: number }
export function parseAction(payload: string): Action | null {
const [kind, bookingId, time, offset] = payload.split(':')
if (!isUuid(bookingId)) return null
if (kind === 'accept' || kind === 'decline') return { kind, bookingId }
if (kind === 'move' && OFFSETS.includes(Number(offset)))
return { kind, bookingId, time: Number(time), offset: Number(offset) }
return null // anything else is noise, and does nothing
}Ten rows
Moving a booking needs a new time, and WhatsApp doesn't do time pickers. What it does have is the list message: tap a button, get a list, pick a row. A list holds at most ten rows.
So the choice of new times is ten offsets from the booking's time, all within 90 minutes either side. There's a floor, too: a moved booking has to be at least 15 minutes away, because a new time that has nearly passed by the time anyone reads it isn't much of a new time.
It's a constraint I've come to like. The platform decides the shape, and the feature is better for it: nobody on a restaurant floor wants to scroll through every quarter-hour of the night. Ten sensible choices beat a time picker.
The database says no again
Here's the part that matters most. The parser makes sure a payload is well formed. It can't know whether the move still makes sense.
Picture it. Early in the evening, a venue gets a message about a booking. Nobody taps it. Later, someone handles the same booking from the dashboard. Hours after that, someone else finds the old message and taps one of its move options.
The parser is happy: the payload is perfectly valid. It's just old. So the move doesn't stop at the parser. The database enforces the same ±90-minute window again, on its own, at the moment the change is made. It doesn't matter what the edge function already checked. A stale message can't do what the dashboard wouldn't.
Two front doors, one rulebook. The button is a convenience; the database is the authority.
Checking twice isn't redundancy for its own sake. The parser's job is to turn garbage away early and cheaply. The database's job is to be right at the moment the booking changes. Only one of them knows what the booking looks like right now.
Realtime that doesn't die quietly
WhatsApp is one way a venue hears about a booking. The other is the dashboard and the tablet app, which listen for new bookings over Supabase Realtime. And a realtime connection has a nasty way of failing: it can stop without saying so.
A tablet sleeps. The Wi-Fi drops for ten seconds. A socket closes, and nothing opens it again. The screen still shows the last thing it knew, and looks perfectly healthy. A venue in that state isn't receiving bookings, and nobody there knows it. That's money walking out of the door, quietly.
So the subscriptions aren't fire-and-forget. They reconnect and recover on their own, and they report when they drop, so a dead connection is something I can see rather than something a venue finds out about from an empty evening.
The tablet app goes further. It's built to stay listening: it keeps running in the foreground, sends a heartbeat every 30 seconds, and when a new booking lands it sounds an alarm that keeps going until someone acts on it. Not a polite notification that fades away. An alarm.
It's the least elegant thing in the whole system, and it's exactly as loud as it needs to be.
What carries over
Meet people where they work. For restaurant staff that's WhatsApp, so part of the control panel lives there. A dashboard nobody opens on a busy night doesn't help anyone.
Treat every payload that comes back to you as untrusted, including the ones you generated. Those are the ones you'll forget to check.
Keep parsers pure. String in, decision out, nothing else. And a test for every way the string can be wrong.
Let the platform's limits shape the feature. Ten rows was a constraint, and it made a better menu.
Enforce the rule where the data lives. The window is checked in the database, so it doesn't matter which door a request came through, or how long it took to get there.
Make silence loud. A connection that fails quietly is worse than one that fails at the top of its voice.
A control panel doesn't have to be a dashboard. Sometimes it's three buttons in a chat, as long as there's a database behind them that knows when to say no.