Sandlee سَنَدليArchitect and lead engineer2026 · ~3 months, solo
An on-demand services marketplace for Saudi Arabia, shipped as five products on one Node/Prisma backend with in-app escrow.
Stack: React Native 0.83, Expo SDK 55, Next.js 16, React 19, Express 5, Prisma 7, PostgreSQL, Redis, BullMQ, Socket.IO, Zod, Moyasar, Apple Pay, Cloudinary, Firebase FCM, Google Maps
Trust, for two audiences at once
The problem
Saudi Arabia's services market runs on word-of-mouth and WhatsApp. A homeowner asks a group chat for a plumber, gets a number, and hopes for the best: no price discovery, no vetting, no recourse if the work is bad. Providers have no reliable pipeline and no portable reputation.
The listing is the easy part. Money has to be safe for the client without stranding the provider, and conversation has to stay on-platform or the marketplace is bypassed the moment two people swap numbers.
All of it had to be Arabic-first and RTL, on Saudi payment rails, and built by one engineer fast enough to validate the market.
Decisions
What I chose, what I chose it over, and what it cost
Sandlee is five products on one backend: a client app and a provider app (React Native, Expo), an admin console and a marketing site (Next.js), and the API (Express, PostgreSQL, Redis, BullMQ).
Around the transaction sit real-time GPS tracking, Socket.io chat, Moyasar hold and capture payments, provider KYC, and 5-role access control. Three choices shaped the rest.
Hold the money in the application, not the gateway
| Chose | Moyasar only authorizes and captures the card. The hold itself is an application state machine: funds are authorized when the client picks a quote, captured only on verified completion, and voided or refunded on cancellation. Every transition is enforced server-side inside a database transaction, and ledger entries carry deterministic idempotency keys, so a replayed webhook or a double tap is a no-op. |
|---|---|
| Over | Charging the card when the quote is accepted and refunding when a job falls through. It is simpler to build, but the client pays before any work is verified and every cancellation becomes a refund. |
| Cost | The platform owns the correctness of the money. That meant modelling a payment lifecycle, deduplicating webhooks on a unique event id, comparing amounts in halalas to avoid float drift, and running a scheduled job to auto-release held funds. |
One backend and one shared rules package
| Chose | Every product is a thin client around a single Express and Prisma API. Enums, business rules and Zod schemas are defined once in @sanad/shared and pinned by commit SHA in the client app, provider app, admin console and API, so a rule cannot mean two things in two codebases. In development the backend also validates its own responses against the same schemas. |
|---|---|
| Over | Each codebase keeping its own copy of the enums, constants and validation. That is the faster start for one engineer, and it is how an order status ends up meaning one thing in the provider app and another on the server. |
| Cost | A rule change is a package release, not a one-line edit. The pinned SHA has to be bumped in every consumer, which Renovate does by opening a PR in each one. A single API is also one dependency shared by every product. |
Filter contact details out of chat before delivery
| Chose | A marketplace is bypassed the moment two people swap numbers, so chat is filtered on the way in. The filter normalizes first: Arabic-Indic and Persian digits become ASCII before separator-tolerant patterns look for Saudi mobile numbers, long digit runs, emails, URLs and messaging handles. Chat images get an OCR pass at upload and are deleted before a sendable URL exists if they carry contact info. Violations escalate on a strike ladder. |
|---|---|
| Over | Open chat backed by terms of service and after-the-fact reports, or plain patterns on the raw text. The first acts after the number has already been shared. The second is beaten by typing the number in Arabic digits or sending a screenshot. |
| Cost | Matching long digit runs will sometimes mask a legitimate number, so the app warns before sending and violations surface in the admin console. OCR adds a processing step to every image upload. |
The order drives the money
Money as a state machine
| Order event | Payment becomes |
|---|---|
| Client picks a quote | Authorized |
| Completion verified | Captured |
| Order cancelled | Voided or refunded |
Order lifecycle
- 1New
- 2Quotes received
- 3Provider selected
- 4Payment held
- 5En route
- 6Work started
- 7Completed
- 8Confirmed
- 9Closed
Payment states
- Pending
- Authorized
- Captured
- Voided
- Refunded
- Disputed
- Settled
1M SAR ($270K) in transactions during a 2-month soft launch
The result
Built in about three months by one engineer, and in production.
The brief was to validate the market quickly, and the soft launch did that with real money moving through the in-app escrow described above, not through a group chat and a bank transfer.
Screens
Captured from the production apps, one set per surface
Client app
Request, compare quotes, pay into escrow, track, rate.




Provider app
KYC onboarding, job board, quoting, and a code-gated job flow.




Admin console
Disputes, payouts, KYC review, and the ledger.




Marketing site
Standalone, Arabic and RTL-first.


By the numbers
How the figures on this page are defined
| Engineering countnote 2 | Number |
|---|---|
| Products on one backend | 5 |
| REST endpoints | 165 |
| Data models | 35 |
- 1The transaction figure is a reported business result from the soft-launch period.
- 2Engineering counts are verified from the codebase.