Case study · PHP · WhatsApp Cloud API · Razorpay · Multi-tenant
I started with a dashboard and ended up building a chat.
Zentry lets small businesses sell inside WhatsApp. This is how it's put together: why the bot is its own, two payment flows that end the same way, the validation bug that blocked every phone number, billing built without a live account, and what isn't proven yet.
The problem
The sale happens in a chat. The record of it lives in someone's memory.
Small businesses in tourism, taxis, homestays and shops take orders on WhatsApp. The whole sale is a conversation: a question, a price, a number to pay, a screenshot, a "confirmed". When business is slow that works. When it's busy, bookings slip, payments are hard to match to people, and the owner can't say how much was sold this week.
The brief was to keep the customer in WhatsApp and put a system underneath: a catalog they can browse, a payment they can make in one tap, a booking record that appears by itself, and a place for the human conversations.
The product turned around
I started with a dashboard and ended with a chat.
The first version was the obvious one: staff create bookings in a dashboard. Then the real shape of the product showed itself. If the customer is already in WhatsApp, the dashboard shouldn't be where bookings begin. It should be where the business looks after the bookings that the chat creates. So the product turned around: the WhatsApp bot became the front door, and the dashboard became the back office.
That change touched the data too. Bookings gained a source (entered by staff, created by the bot, or from the web store), and products got a table of their own so one catalog could feed all three doors.
Its own bot, on purpose
Skipping Meta's catalog to avoid an approval per business.
WhatsApp has a native product catalog, and it looks great, but it needs a Facebook Business Manager and a catalog approved for every business. For a product aimed at very small businesses that is a wall at the very start. So Zentry builds its own bot on WhatsApp's list and button messages: the customer sees a list of up to ten items, taps one to see details, and chooses Buy Now or Talk to Support.
The costs are real and I've listed them: a ten-item cap, no categories, no cart, one item per purchase. The benefit is that a business can be selling in minutes. The bot is also stateless per message, which is enough for a flat catalog, and the columns for a multi-step flow are already in the table for the day a cart is needed.
Two payment flows for two situations
A link in a chat, and a checkout on a page.
In a chat there is no page to put a card form on, so the bot sends a Razorpay Payment Link. Razorpay calls Zentry when the link is paid, Zentry verifies the signature, flips the booking to confirmed and the payment to successful, sends the customer a confirmation in the chat and tells the business who bought what. On the public web store there is a page, so it uses Razorpay's embedded checkout instead, and the staff's Pay Online button uses the same flow.
Both end the same way, which is the point: whichever door a purchase came through, the booking, the payment and the notification look identical, and only the source label differs.
The bug that blocked every phone number
A validation rule that measured the wrong thing.
While testing the web store's order form I found that valid phone numbers were being rejected. The cause was in the shared validator: a rule such as "at most 30 characters" treated a numeric-looking string as a number and compared its value to 30. A ten-digit phone number is far larger than 30, so it failed. The bug had been there since the validator was written and silently affected every form with a length rule on a number-like field, including the customer and onboarding forms.
The fix is that these rules now always measure length, which matches every place they're used. It's the kind of bug that only a real input finds, and the validator now has unit tests.
Billing built without a live account
Everything except the charge itself.
Zentry bills its own tenants, separately from the way tenants bill their customers. Features carry prices, a plan is a set of features, a new tenant starts a trial and Razorpay Subscriptions charge each cycle. A lapsed subscription limits the business to its billing page. All of it was built and checked with real database writes and simulated webhooks, because I had no live platform Razorpay account.
The honest list of what's missing is long enough to keep: no proration, no handling of a plan switch mid-cycle, an edit to a plan's price that doesn't reach existing subscribers, and only three places that actually check a plan's limits. The pattern works. Extending it is routine rather than architectural.
Tenancy and security
Each business sees only its own world.
A middleware attaches the tenant to every request and sends a business that hasn't picked a type, or whose subscription has lapsed, to the right screen. Roles are super admin, admin, manager and staff. Forms are protected by CSRF tokens, sign-in is rate-limited (five attempts, then a fifteen-minute lockout), the public onboarding form is limited to three submissions per hour per address, uploads are checked by their real content and capped at 2 MB, and each tenant's Razorpay and WhatsApp secrets are encrypted at rest.
There's also an onboarding gate that is a product decision as much as a security one: a business asks for access, an admin reviews it, and only then is a tenant provisioned.
The architecture
What's actually running under it.
- Core
- A small custom MVC in
PHP 8.1+: router, request and response, session, CSRF, validator, view and database classes. About 21,000 lines in 19 controllers, with services for each domain. - Data
MySQLthrough PDO and 22 migrations: tenants, users, customers, bookings, payments, products, conversations and messages, notifications, reviews, templates and the billing tables.- A Cloud API service for text, list and button messages, a catalog bot service, a conversation service, template submission and an Embedded Signup flow.
- Payments
- A Razorpay service covering orders, payment links and subscriptions, with signature verification on every webhook.
- Billing
- A billing service for features, plans, trials, subscriptions and invoices, and a middleware that enforces them.
PHPMailerbehind a mailer with log, SMTP and Mailgun drivers.- Verification
- 21 PHPUnit tests (29 assertions) on validation, encryption, configuration and template parsing, run by a CI workflow. The integrations were verified with simulated events.
The outcome
Honest about where it actually is.
It's a working multi-tenant application with the shape I wanted: a customer can browse and pay inside WhatsApp, a business can run the result from a dashboard, and the owner can bill the businesses. Everything that talks to Meta or Razorpay has been built and exercised, and none of it has met a live account.
What it isn't yet: launched, tested live, or feature-complete. The bot needs categories and a cart, the billing needs plan switching, and WhatsApp's Embedded Signup needs Meta's approval before another business can use it. Those are the real next steps, and I'd rather publish them than let a buyer find them.
Run a small business that lives on WhatsApp, or want a booking product built end to end?
Get in touch