automation Case study
Shop-Phone Call Bridge
A front-desk phone that is not connected to the CRM is a phone the CRM cannot see. For a watch and jewellery retailer with a front-desk phone I built a REST bridge between the shop's Android handset and the operator desk: click-to-dial from a customer's file, every call logged back to that file, and order and cart alerts arriving on the phone. The server side was proven with a simulated handset before the real phone was ever commissioned.
The business problem
Operators worked from a customer's file in the operator PWA, eleven views of daily customer work where each row opens the customer's CRM sheet, but they dialled by hand on a separate handset. So the call never made it back into the file: no record that it happened, no link to the customer, and an incoming call opened nothing. The handset itself sits on a counter in Doze, and the obvious channel for alerts, Web Push, was not available: WebAPK minting is blocked in the shop's region.
The second half of the problem was how the site talks to anything outside itself. Before this work the bot server held a root SSH key to the site. Anything that wanted the catalogue, customer data or the phone went in through the shell. That is one credential with every permission, and it is exactly what an integration layer should make unnecessary.
What I delivered
- Click-to-dial from the customer card. The action row offers call, SMS, WhatsApp, Telegram and Bale; a dial command is queued for the handset, the call is logged to the customer's file, and an incoming call opens the right card.
- A command queue with explicit expiry. A dial command the phone has not picked up within two minutes is marked failed, visibly. An alert not picked up within ten minutes is dropped silently, because a stale alert is noise, not a failure.
- Live alerts on the same queue as a different kind. Order and abandoned-cart alerts are queued as kind=alert; on the phone's sixty-second poll the bridge diffs the top twenty-five rows of each operator-app tab and sends only what is new. Queueing is never gated on the phone being online.
- An Android companion that stays alive on a counter. The service survives being swiped away through a one-second restart alarm and a fifteen-minute heartbeat, and it reports the handset's position.
- Contact mirroring into WhatsApp, Telegram and Bale, with a self-heal re-walk. It earned its keep when the handset turned out to hold seven thousand contacts of which only two were ours.
- A REST integration layer with three namespaces: a bots API for the catalogue, product media and customer-DNA sync, a CRM API for finance and contacts, and the call-bridge API. All of them sit behind a token header with an IP allowlist option, and the bot server's root SSH key to the site was revoked and deleted.
Technical approach
- I proved the server side end to end with a simulated handset before the real phone existed. Every endpoint, the queue, the expiry and the alert diff were exercised by a fake client, so the real handset, when it arrived, was tested against a server that already worked. It still found one thing, described below.
- Alerts ride the command queue instead of a push channel because Web Push was not an option: WebAPK minting is blocked in the shop's region. Reusing the queue meant one poll, one delivery path and one expiry model for both commands and alerts, with the kind field telling them apart.
- The queue never asks whether the phone is online before accepting work. An operator's dial command is queued regardless; if the phone does not collect it within two minutes it is marked failed and the operator sees that. The phone's heartbeat is fifteen minutes and it sleeps in Doze, so 'online' is never a fact the server can trust. The expiry is the honest answer.
- The month the phone went live I also found that the two-minute expiry had never fired. The queue stamped rows in the site's local time and compared them against MySQL's NOW(), which is UTC, so the comparison silently never matched. In three years it had not expired a single command. It was fixed the same month.
- The REST layer replaces shell access with three narrow namespaces behind a token header plus an IP allowlist, so each bot gets a route to what it needs and nothing else. With those routes in place, the bot server's root SSH key to the site was revoked and deleted.
Result and evidence
The server side was proven end to end with a simulated handset first, and the real handset, running Android 16, was fully commissioned later the same month: a queued ring opened the right customer card automatically. That is the evidence I have. I have not measured call volume, answer rates or time saved, and I will not attribute numbers I did not collect.
Commercial value
A front desk that dials from the customer's file makes every call part of the record without asking anyone to type it up, and a phone that rings the operator about a new order or an abandoned cart shortens the gap between the event and the call. The quieter value is the integration layer: a retailer's site can now talk to its bots and its phone without a root key sitting on another machine.
Readable implementation brief
implementation_brief {
project: "Shop-Phone Call Bridge"
client: "a watch and jewellery retailer with a front-desk phone"
handset: "Android companion; survives swipe-away via a 1-second
restart alarm and a 15-minute heartbeat; sits in Doze"
dial: "queued from the customer card; expires in 2 minutes and
is marked failed; never gated on the phone being online"
alerts: "order + abandoned cart as kind=alert on the same queue;
top-25 diff per operator tab on a 60-second poll;
expire in 10 minutes, dropped silently"
rest: "3 namespaces (bots, CRM, call bridge); token header +
IP allowlist; root SSH key revoked and deleted"
proof: "simulated handset end to end, then the real phone"
bug: "expiry compared local time to MySQL NOW(), which is UTC;
never fired in three years; fixed"
}What this project shows
The part I would point a reviewer at is the simulated handset. Writing the phone side first would have meant debugging two unknowns at once; proving the server against a fake client meant the real phone was tested against something already known to work.
The expiry that never fired is the lesson I keep. Comparing a locally stamped column against NOW() is one line, reads as obviously right, and silently never matches when the database clock is UTC. The database clock is a boundary like any outside provider, and I now treat it as one.