Sinergi Group: Construction Project Management with WhatsApp Delivery

Sinergi Group: Construction Project Management with WhatsApp Delivery

Table of Contents

Construction work in Indonesia is coordinated on WhatsApp. Not email, not a project management SaaS. WhatsApp.

So when Sinergi Group needed a system to run their construction projects, the interesting problem wasn’t the CRUD. It was making notifications arrive reliably on a channel that was never designed for a server to send on.

This is the internal side of the same Laravel application whose public website I wrote about separately.

The domain

A project here isn’t a task with a due date. It carries the paperwork a construction job actually generates:

  • A bill of quantities — the priced breakdown of materials and work — stored as both a file and a value
  • A work order (SPO), again as file and value
  • An initial permit with the date it was opened
  • A location, an assigned foreman, a target completion date that turns red once it’s passed
  • Status moving through scheduledin_progressfinish
  • Billing progress, tracked separately from work progress, because on construction jobs the two rarely move together
  • Free-text notes, and created_by / updated_by because on a shared internal system somebody eventually asks who changed a number

The projects list is built for the way the office actually asks questions: filter by status, foreman, billing status, deadline, or whether documents are attached, search across title and number, choose your own columns, and export the result to Excel. Nobody requests “a report” in the abstract — they want this quarter’s overdue jobs for one foreman, in a spreadsheet.

Around that sit violation records, users with roles, and the service and portfolio entries that populate the public website — the same admin manages both halves, which is the practical reason the two live in one codebase.

Permissions run on spatie/laravel-permission, with a Gate::before granting Super Admin everything so nobody gets locked out of their own system.

That’s the part that took the time. It isn’t the part worth writing about.

The WhatsApp problem

WhatsApp has no official API you can just use for this. What exists is a set of libraries that pretend to be a linked companion device — the same mechanism as WhatsApp Web.

Two things follow from that, and both shape the design.

Sessions get revoked. WhatsApp invalidates companion sessions regularly, for reasons that are never explained. When it happens, delivery stops until someone re-pairs by scanning a QR code.

The library dictates the language. The best-maintained option I found, whatsapp-rust, is a Rust library with no network interface. It can only be used in-process. That’s why part of this system is written in Rust: not preference, but the ecosystem making the decision for me.

The first attempt, and why it went

Version one was a Go worker polling a localhost-only POST /send endpoint. It worked. It’s now retired.

The reason it went is the QR code. Recovering a revoked session meant SSH-ing into the server and reading a pairing QR out of journalctl. That’s fine when I’m the only operator. It’s useless when the person who notices the problem is in an office and not a terminal.

What replaced it

sinergi-wa-bridge is a small Rust service that embeds whatsapp-rust, holds the session, manages pairing, and exposes an HTTP API. Laravel calls it over HTTPS with a bearer token.

The point of putting an API in front of it is self-service re-login. The WhatsApp page in the app, gated behind its own permission, is built for someone who is not going to open a terminal:

  • Current session state as a badge, plus the linked device name, number, connected-since and logged-out-at timestamps
  • The pairing QR rendered in the browser, refreshing itself every ten seconds the way WhatsApp Web does
  • A phone-number fallback — enter the number in international format and request a pairing code instead, for the times a colleague can’t scan a screen
  • Live counts of the notification queue by state: pending, processing, sent, failed

The controller proxies every call to the bridge, so the bridge token never reaches the browser.

The screenshot in the gallery above is that page mid-pairing: no device linked, QR waiting to be scanned, eight notifications already delivered.

Making delivery survive its own failures

Three properties I wanted, none of which come free.

Nothing is lost when the queue is down. WaNotificationService::queue() writes a wa_notifications row first, then dispatches the job. If the queue worker is dead, the intent to notify is still recorded in the database. php artisan wa:dispatch-pending re-queues whatever is outstanding — including rows stranded mid-flight in processing, which is the state you actually find after a crash.

Retries distinguish “not yet” from “no”. Only transient failures retry: not_connected, unreachable, starting. A rejected recipient — wrong number, not on WhatsApp — fails on the first attempt instead of being tried five times over six hours.

Backoff is sized to the real failure. 1 minute, 5, 15, 1 hour, 6 hours. That last step isn’t arbitrary: it’s long enough for someone to notice a logged-out session, find the QR page, and re-pair. Retrying every 30 seconds for an hour would just burn attempts before a human could possibly intervene.

And because a stopped queue looks like nothing at all from the outside, /app/whatsapp warns when the oldest undelivered notification passes ten minutes. Silence is the failure mode that costs you most.

Infrastructure

The server is provisioned by a separate Ansible repository — roles, inventory, group vars — so the machine is described in code rather than in whatever I remembered to do by hand.

Deployment is GitHub Actions on push, with development and main mapped to the two environments.

One deliberate omission: the deploy does not install the scheduler cron. That’s configured server-side, outside the repo. An earlier version did install it from CI, which meant a deploy could silently change the schedule. I removed it and left a note saying not to add it back.

What I’d tell you honestly

The Rust bridge is small and young. It does one job — hold a session, send messages, expose pairing — and I kept it that way deliberately, because the less it does the less often I have to touch a codebase I’m still getting fluent in.

I’d also say the durability work was worth more than the feature work. Notifications that mostly arrive are worse than no notifications, because people stop checking and nobody tells you they stopped.

Want something similar? Get in touch — appropriately enough, on WhatsApp.

call to action

Ready to build your next project with me?

I’m ready to help you build, improve, and launch your next project — just drop a message and let’s get started.

Get Started Now