Handypro
A home-services marketplace for your city: a customer app that books cleaners, plumbers, electricians and other pros, a Handypro Pro app for the pros and their teams, and one Next.js server that is the website, the pro web panel, the admin panel and the API. You add your own keys; everything else is ready.
Welcome
Thank you for buying Handypro. This guide is written for beginners: follow the chapters in order and you will have the server, the website, the admin panel and both apps running under your own name and keys.
What is in the download
| Folder | What it is |
|---|---|
handypro_customer_flutter/ | The customer app (iOS and Android). People pick an address, browse services and pros, book a time or ask for a pro right now, pay by card, wallet or cash, track the pro on a map, chat, tip, review, post custom jobs for bids, and open warranty claims and disputes. |
handypro_provider_flutter/ | The Handypro Pro app for pros and companies: sign-up with documents, go online, accept requests, run the job (on my way, start code, before/after photos, extra charges, finish), calendar and time off, services and prices, staff, job leads and bids, promo codes, plans, earnings and withdrawals, reviews and chats. |
handypro_flutter_core/ | Code both apps share: API client, sign-in, theme, widgets and shared pages. Both apps use it through a path dependency, so there is nothing to publish. |
handypro_web_nextjs/ | One Next.js app that is the website (with booking and checkout), the pro web panel (/pro), the admin panel (/admin), the API the apps use (/api/v1) and the background worker (pnpm worker). |
deploy/ | The Docker stack: website/API + worker + PostgreSQL + MinIO file storage, started with one command, with optional automatic HTTPS. |
tools/rename.mjs | Renames both apps, their package ids and the brand name in one command. |
docs/ | This documentation (HTML and PDF). |
How the parts fit
Both apps and the website talk to the same server. All data (services, pros, bookings, payments, chats, reviews) lives in your PostgreSQL database. Photos, job proof photos and pro documents live in S3-compatible storage (MinIO in the Docker stack, or Amazon S3 / Cloudflare R2). Firebase is used only for sign-in and push notifications.
The life of a booking: a customer books a service for a time slot (or asks for an instant pro) → the pro gets a request with a countdown and accepts it (a company assigns a team member) → on the day the pro taps On my way and the customer sees them on the map → the customer reads a 4-digit start code to the pro → the pro takes before photos, works, adds any extra charge the customer approves, and finishes with after photos → the card is charged (or the pro collects cash) → the customer rates and tips → the pro's earnings are paid out on your payout days.
Screenshots
The main screens, so you know what you are setting up. The names, photos and numbers are demo data; yours replace them.
Customer app
Pro app
Website
Pro web panel (/pro)
Admin panel (/admin)
Requirements
To run the server
- A Linux server (VPS) with at least 2 CPU cores, 4 GB RAM and 30 GB disk. Ubuntu 24.04 is used in this guide.
- Docker with the Compose plugin (install guide).
- A domain name, for example
your-domain.com, with a DNS A record pointing at the server.
To build the apps
- Flutter 3.44 or newer (Dart 3.12) (install), Android Studio for Android, a Mac with Xcode 26 for iOS (iOS 15 or newer on the phone).
- Node.js 22 or newer for the rename tool, and pnpm 11 (
npm i -g pnpm) if you run the website without Docker.
Third-party services (accounts you will create)
- Firebase (required: sign-in and push).
- Optional, when you want them: Stripe (card payments and pro plans on the web panel), RevenueCat (pro plans bought in the Pro app), Twilio (booking SMS), Google Cloud (address search with the Geocoding API), a map tile provider such as MapTiler, and any SMTP email provider.
What each service costs
Every service below is run by another company and has its own pricing; you pay them directly, never us. Check each provider's pricing page before you launch.
| Service | Needed for | Cost |
|---|---|---|
| Your server (VPS) or Vercel + managed PostgreSQL | Website, panels, API, database | Hosting company's monthly price for the size you pick. |
| Firebase (Google) | Sign-in, push, crash reports | Free tier, then Google's prices; phone sign-in is billed per SMS. |
| Stripe | Card payments, wallet top-ups, pro plans on the web | A fee per successful payment in your country; no monthly fee on the standard plan. |
| RevenueCat | Pro plans bought in the Pro app | Free up to a revenue limit, then a share of tracked revenue. Apple and Google also take their store commission. |
| Twilio | Booking SMS (optional) | A price per SMS and per phone number, by country. |
| Google Maps Platform | Address search with the Geocoding API (optional) | Monthly free credit, then a price per request. |
| Map tiles (MapTiler, Stadia, …) | The maps (optional; OpenStreetMap tiles are the default) | Free tier, then by map views. |
| SMTP provider (SES, Brevo, Mailgun, Postmark…) | Receipts and staff invites | Free tier or a price per email. |
| S3-compatible storage (AWS S3, Cloudflare R2…) | Photos and documents (optional; MinIO in the Docker stack is the default) | By storage and traffic. |
| Apple Developer Program, Google Play Console | Publishing the apps | Apple's yearly fee; Google's one-time fee. |
Quick start (Docker)
This gets the whole server running on your VPS in about 15 minutes. You need the Firebase keys from chapter 4 for sign-in; you can start this chapter first and add them before the install wizard.
- Copy the kit to the server, for example with
scp handypro-1.0.0.zip root@YOUR_SERVER_IP:, then on the server:apt install -y unzip unzip handypro-1.0.0.zip cd handypro/deploy cp .env.example .env nano .env - In
.env, fill at least these values:Key What to put POSTGRES_PASSWORDA long random password, letters and digits only (run openssl rand -hex 24to make one).S3_SECRET_ACCESS_KEYAnother random value for the built-in MinIO storage (at least 8 characters). APP_URLYour website address, e.g. https://your-domain.com(orhttp://YOUR_SERVER_IP:3000for a first test).NEXT_PUBLIC_FIREBASE_*,FIREBASE_*From chapter 4. - Start everything:
The first build takes 5–10 minutes. On every start the database tables are created or updated, and the base data (categories, plans, a first zone and tax rule) is added if it is missing.docker compose up -d --build - Open
APP_URL/installin your browser. The install wizard creates your admin account, names the business, sets the currency and colour, and can load the sample marketplace (chapter 7). - Check it:
APP_URL/api/v1/healthshows{"ok":true,…}. Then put it on your domain with HTTPS (chapter 5).
.env? Run docker compose up -d again. The Firebase web values are read when the page loads, so no rebuild is needed for them.Firebase setup
Firebase handles sign-in (phone, Google, Apple and guest in the apps; email and password for admin staff) and push notifications. Your data stays in your own database. One Firebase project serves the website and both apps.
- Go to the Firebase console → Add project. Give it your business name.
- Build → Authentication → Get started → Sign-in method. Turn on: Phone, Google, Apple (see chapter 16), Anonymous (customers browsing as guests) and Email/Password (admin staff).
- Phone sign-in: Authentication → Settings → SMS region policy — allow the countries your users are in. Without it Firebase refuses to send the code.
- Authentication → Settings → Authorized domains: add
your-domain.com. - Project settings (gear) → General → Your apps → Add app → Web (name it "Website"). Copy the values from the
firebaseConfigshown intodeploy/.env:NEXT_PUBLIC_FIREBASE_API_KEY="…apiKey…" NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN="your-project.firebaseapp.com" NEXT_PUBLIC_FIREBASE_PROJECT_ID="your-project" NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET="your-project.firebasestorage.app" NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID="…" NEXT_PUBLIC_FIREBASE_APP_ID="1:…:web:…" - Project settings → Service accounts → Generate new private key. A JSON file downloads. Copy three values from it into
deploy/.env:
Keep theFIREBASE_PROJECT_ID="your-project" FIREBASE_CLIENT_EMAIL="firebase-adminsdk-xxxx@your-project.iam.gserviceaccount.com" FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIE…\n-----END PRIVATE KEY-----\n"\nas they are in the file. Keep this file secret. - Register the four mobile apps: Project settings → Your apps → Add app → Android and iOS for the customer app (e.g.
com.yourcompany.handypro) and Android and iOS for the Pro app (e.g.com.yourcompany.handypro.provider). Rename the apps first if you want your own ids (chapter 17). Or let the FlutterFire CLI register them:dart pub global activate flutterfire_cli cd handypro_customer_flutter flutterfire configure --project your-project --platforms android,ios \ --android-package-name com.yourcompany.handypro --ios-bundle-id com.yourcompany.handypro cd ../handypro_provider_flutter flutterfire configure --project your-project --platforms android,ios \ --android-package-name com.yourcompany.handypro.provider --ios-bundle-id com.yourcompany.handypro.provider - Copy each app's values into its own
.env(handypro_customer_flutter/.envandhandypro_provider_flutter/.env):FIREBASE_PROJECT_ID,FIREBASE_MESSAGING_SENDER_ID,FIREBASE_STORAGE_BUCKET,FIREBASE_ANDROID_API_KEY,FIREBASE_ANDROID_APP_ID,FIREBASE_IOS_API_KEY,FIREBASE_IOS_APP_ID,FIREBASE_IOS_CLIENT_ID,FIREBASE_IOS_BUNDLE_ID. You find them in each app'sgoogle-services.json/GoogleService-Info.plist(download them from Project settings). The apps read these values from.env, so you do not need to put the downloaded files in the projects. - Google sign-in on Android needs
GOOGLE_SERVER_CLIENT_IDin both apps'.env: Authentication → Sign-in method → Google → Web SDK configuration → Web client ID. - iOS (Google and phone sign-in): in each app, copy
ios/Flutter/Firebase.xcconfig.exampletoios/Flutter/Firebase.xcconfigand fill inGOOGLE_REVERSED_CLIENT_ID(theREVERSED_CLIENT_IDin that app'sGoogleService-Info.plist) andFIREBASE_ENCODED_APP_ID(itsGOOGLE_APP_IDwith:replaced by-andapp-in front, as the example shows). - Android Google and phone sign-in need your signing key fingerprints: run
cd android && ./gradlew signingReportin each app and add the SHA-1 and SHA-256 under Project settings → Your apps → Android → Add fingerprint. Add the fingerprints of your upload key and of Google Play's app signing key too when you publish.
Put it online (VPS, HTTPS)
The quickest way: the stack has a built-in Caddy web server that gets a free HTTPS certificate for your domain.
- Point your domain's DNS A record at the server and wait until it resolves.
- In
deploy/.envsetDOMAIN=your-domain.comandAPP_URL=https://your-domain.com. - Open ports 80 and 443 in the firewall (
ufw allow 80,443/tcp) and start the stack with the https profile:docker compose --profile https up -d - Add
your-domain.comto Firebase → Authentication → Settings → Authorized domains, if you have not yet.
Already run nginx, Traefik or another proxy? Skip the profile and forward your domain to 127.0.0.1:3000 (the WEB_PORT).
Backups
Back up the database every day, for example with a cron job:
docker compose exec -T postgres pg_dump -U handypro handypro | gzip > /root/backups/handypro-$(date +%F).sql.gz
Also back up the minio-data volume (photos and pro documents), or use a cloud bucket (next chapter).
Other hosting
Website on Vercel, database on a managed PostgreSQL
Costs: Vercel and managed database providers are third-party services with their own pricing; you are responsible for any hosting costs.
- Create a PostgreSQL database (Neon, Supabase, Railway…) and copy its connection string.
- Import
handypro_web_nextjs/into Vercel. Add every key fromhandypro_web_nextjs/.env.exampleas an environment variable, withDATABASE_URLset to your database and the fiveS3_*keys set to a cloud bucket (Vercel has no disk for uploads). - On your computer, create the tables once:
cd handypro_web_nextjs && pnpm install && pnpm prisma:migrate:deploy(withDATABASE_URLin.env). Then open/installon your Vercel domain. - The background worker does not run on Vercel. Run
pnpm workeron any small server or service that keeps a process running (Railway, Render, a VPS) with the same environment variables. Without the worker, unanswered requests do not expire, cards are not authorised before jobs, job requests do not close, plans do not renew and scheduled campaigns stay unsent.
Cloud storage instead of MinIO
Create a private bucket on Amazon S3 or Cloudflare R2 and set S3_ENDPOINT (empty for AWS, your R2 endpoint for R2), S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY. Files are served through the API, so the bucket stays private. Pro ID and insurance documents are only reachable through links that expire after an hour (FILES_SECRET signs them).
Costs: Amazon S3 and Cloudflare R2 are third-party services with their own pricing (storage and traffic); you are responsible for any costs.
Without Docker
Install Node.js 22 and pnpm 11, then in handypro_web_nextjs/: cp .env.example .env, fill it in, and run pnpm install, pnpm prisma:migrate:deploy, pnpm build, then keep pnpm start and pnpm worker running (for example with pm2). Without S3 keys, files are stored in the local uploads/ folder. Open /install to finish.
Install wizard and sample data
The first time, open /install on your website. It asks for:
- Your admin account (name, email and password). This is the super admin; you add more staff later in Admin → Roles.
- Your business: app name, currency, distance unit (miles or kilometres) and time zone. Slots, opening hours and every time in the apps, website and panels follow this time zone, wherever the phone or browser is.
- The look and the content: your brand colour, and Load the sample pros and bookings — 18 pros with 58 services in 12 categories, customers, reviews and a year of bookings around Austin, Texas, so every screen has content. Leave it off to start with only the basics (categories, plans, a first zone and tax rule).
The wizard closes for good once a super admin exists. Sign in later at /admin.
Setup check
Two ways to see which keys are missing and whether each service answers:
- Admin → Setup check: every key from every
.env.example(server, Docker, both apps) marked set or missing, and a live test of the database, Firebase, push, card payments, plan purchases, storage, email, SMS, address search and the worker. Values are never shown. - Command line: in
handypro_web_nextjs/runpnpm run doctor(with Docker:docker compose exec worker pnpm run doctor).
Run the two apps
Do this for handypro_customer_flutter and for handypro_provider_flutter:
- Copy the settings file and fill it in:
Setcd handypro_customer_flutter cp .env.example .envAPI_BASE_URLto your server (https://your-domain.com; for a server on your computer usehttp://10.0.2.2:3000in the Android emulator) and the Firebase values from chapter 4. The Pro app also takesREVENUECAT_ANDROID_KEYandREVENUECAT_IOS_KEY(chapter 12). - Get the packages and run:
flutter pub get flutter run
Until .env has the server and Firebase values, the apps open a setup screen that names what is missing.
- iOS signing: the Xcode projects ship with Automatic signing and no team or provisioning profile set. Open
ios/Runner.xcworkspace→ select Runner → Signing & Capabilities → keep Automatically manage signing on and choose your own Team. Xcode then creates the certificates and profiles for your bundle id. Do this on the All tab so Debug, Release and Profile use it, and in both apps.
android/app/build.gradle.kts in each app sets compileSdk = 36, targetSdk = 36 and minSdk = 24 explicitly. Google Play requires a recent target SDK; when Google raises it, change both numbers there.Build for release
flutter build appbundle # Android, for Google Play
flutter build ipa # iOS, for the App Store (on a Mac)
Run your marketplace
| Task | Where |
|---|---|
| Service areas | Admin → Zones: click the map to draw a zone; set it Live, Planned (customers see "coming soon") or Off, and whether instant jobs are allowed there. |
| Categories and services | Admin → Categories and Services. Pros add their own services under your categories; you can feature some in Admin → Featured. |
| Approve new pros | Admin → Providers → Pending: open the application, check the ID, insurance and licence documents, approve or ask for changes with a note the pro sees in the app. You can also add a pro yourself or import a CSV. |
| Bookings | Admin → Bookings: assign a pro, refund, cancel, open a dispute, or create a booking for a customer by phone. |
| Disputes and warranty claims | Admin → Disputes: read both sides with the job photos and chat, then refund, re-do or close. |
| Custom jobs | Admin → Custom jobs: jobs customers posted for bids. |
| Reviews | Admin → Reviews: reviews that share phone numbers or links are flagged automatically, and pros can report a review; keep or remove each one. |
| Banners, promo codes, gift codes, push campaigns | Admin → Banners, Promo codes, Notifications. |
| Taxes | Admin → Taxes: a rate per zone and/or category. The most specific active rule wins. |
| Staff and permissions | Admin → Roles: add staff and choose what each role may view and edit. The audit log shows who changed what. |
Pros can also work from the website: /pro is the pro web panel (bookings, calendar, services, staff, earnings, withdrawals, plan, messages, reviews).
Feature: booking, start code and card holds
A booking moves through these states. Each step is checked on the server, and a step taken twice at the same moment (two taps, two phones) counts once.
- Requested
- Accepted
- On the way
- Arrived
- Start code
- In progress
- Completed
- Book. The customer picks a service, add-ons and a free time slot. The server prices the basket (fees, promo code, tax) itself; the price the app shows is never trusted.
- Pay. Wallet: the money is taken at once. Card: Stripe places a hold (authorised, not charged). Cash: nothing is taken; the pro collects it.
- Accept. The pro (or a staff member) accepts. Unanswered requests expire, and the hold is released.
- On the way and arrival. The Pro app shares its live position while travelling; the customer sees the ETA.
- Start code. The customer's app shows a 4-digit code. The pro types it in to start; after 5 wrong tries the job is locked until an admin unlocks it (Admin → Bookings).
- Start. The pro takes "before" photos; the card hold is charged now.
- Extras. The pro can propose an extra charge with a reason and a photo; the customer approves or declines it in the app.
- Finish. "After" photos; the pro's earning (price minus your commission) goes to their balance; the customer can tip, rate and claim the warranty.
Cancellation fees, the free-cancel window and the warranty length are in Admin → Settings → Bookings.
Feature: custom jobs and bids
For work that has no fixed price (a ceiling repair, a full repaint), customers post a custom job with photos and a budget. Pros in that category and area see it as a lead in the Pro app, ask questions in chat and send a bid (price, time, note). The customer compares bids and accepts one; it becomes a normal booking with the same start code, photos and payment rules. A pro can withdraw a bid until it is accepted. Admin → Custom jobs shows every job and its bids.
Payments, commission and payouts
Customers pay by card, from their wallet, or in cash to the pro. Turn each method on or off, set the tip buttons and the wallet top-up amounts in Admin → Settings → Payment gateways.
Card payments: Stripe
Costs: Stripe is a third-party service with its own fees per payment; you are responsible for any Stripe costs.
- In the Stripe dashboard copy the Secret key into
STRIPE_SECRET_KEY. - Developers → Webhooks → Add endpoint: URL
https://your-domain.com/api/v1/webhooks/stripe, eventscheckout.session.completed,checkout.session.async_payment_succeeded,invoice.paidandcustomer.subscription.deleted. Copy the signing secret intoSTRIPE_WEBHOOK_SECRET. - Restart:
docker compose up -d.
A card booking is authorised, not charged, when it is booked (Stripe Checkout opens in the browser and returns to the app, and the card is saved with Stripe). The card is charged when the pro starts the job, after the customer's start code. Extra charges the customer approves and tips are charged to the same card afterwards (or taken from the wallet). A booking far in the future is authorised by the worker shortly before the job. Wallet top-ups use the same checkout.
Without a Stripe key there is no card option, so nobody can add wallet money or pay without a real payment. To try the card screens before your key is in, set DEMO_PAYMENTS="true": a Card (DEMO) option then marks orders and top-ups paid without money. Never leave it on for real customers.
Commission and payouts
- Commission comes from the pro's plan (Admin → Plans) and can be overridden per category or per pro in Admin → Commissions.
- Card jobs add the pro's share to their balance when the job is finished. Cash jobs: the pro keeps the cash, and your commission, service fee and tax are added as a cash adjustment that is taken from their next payout.
- Payouts: pros request a withdrawal (minimum in Admin → Settings → Payment gateways). On your payout days, Admin → Payouts lists the requests with what to send after any cash debt. Approve, send the money from your bank, then mark each one paid with the bank reference; a failed payout returns the money to the pro's balance. The export button gives a CSV for your bank.
Pro plans
Plans (Admin → Plans) set a pro's commission and extras such as more staff and promo codes. Pros upgrade in the Pro app or in the web panel.
In the Pro app: RevenueCat (App Store and Google Play)
- Create one auto-renewing subscription per paid plan in App Store Connect and the Play Console.
- Create a project in RevenueCat, add both Pro apps, and import the products.
- Put each plan's product id in Admin → Plans → Edit → App store product id.
- App keys:
REVENUECAT_ANDROID_KEYandREVENUECAT_IOS_KEYinhandypro_provider_flutter/.env(RevenueCat → API keys → public app keys). - Server keys in
deploy/.env:REVENUECAT_API_KEY(secret key), andREVENUECAT_WEBHOOK_SECRET— the same value you enter as the authorization header in RevenueCat → Integrations → Webhooks, with the URLhttps://your-domain.com/api/v1/webhooks/revenuecat. Purchases, renewals, cancellations and expiries reach the server through this webhook.
In the pro web panel: Stripe
Costs: Stripe is a third-party service with its own fees per payment; you are responsible for any Stripe costs.
With STRIPE_SECRET_KEY set, the web panel's Plan page opens Stripe Checkout. Create a monthly price for each plan in Stripe and put its id in Admin → Plans → Edit → Stripe price id.
Without keys, plans can't be bought. To try the plan screens first, set DEMO_PURCHASES="true": plans are then granted at once with a DEMO label and no payment. Never leave it on for real pros.
Push notifications and SMS
New requests, booking updates, messages, payouts, review replies and admin campaigns are sent with Firebase Cloud Messaging through your FIREBASE_* service account — nothing else to set for Android. For iOS, upload an APNs key: Apple Developer → Keys → + (Apple Push Notifications service), then Firebase → Project settings → Cloud Messaging → Apple app configuration → Upload. In Xcode, both apps have the Push Notifications capability; add Background Modes → Remote notifications if you turned it off.
Every user also has an in-app notification inbox. The message texts are in Admin → Settings → SMS & email.
SMS (booking confirmations and "your pro is on the way" texts to customers): set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN and TWILIO_FROM_NUMBER from the Twilio console, then switch on Send booking text messages in Admin → Settings → SMS & email.
Costs: RevenueCat is a third-party service with its own pricing (free up to a revenue limit, then a share of tracked revenue), and Apple and Google take their store commission on in-app plans; you are responsible for these costs.
Costs: Firebase Cloud Messaging has no per-message fee today, but it is part of Firebase, a third-party service with its own pricing. Twilio is a third-party service that charges per SMS and per phone number; you are responsible for these costs.
Maps, zones and addresses
- Zones decide where customers can book and which pros they see. A pro also sets a base address and a travel radius during sign-up.
- Map tiles: OpenStreetMap's public tiles by default — fine to start, but their usage policy asks heavy users to use a provider. Set
MAP_TILE_URL(e.g. MapTiler:https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png?key=YOUR_KEY) andMAP_ATTRIBUTION. The apps and the website read them from the server. - Address search: with
GOOGLE_MAPS_API_KEY(Geocoding API enabled) the server uses Google; without it, OpenStreetMap's Nominatim (rate-limited, results cached). - Travel times are estimates from the straight-line distance and a road factor, so no paid routing API is needed.
Costs: Google Maps Platform and map tile providers are third-party services with their own pricing (free credit or a free tier, then per request or per map view); you are responsible for any costs.
Set SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASS (any provider: Amazon SES, Brevo, Mailgun, Postmark, your host's mail server). Use port 587, or 465 with SMTP_SECURE=true. Until SMTP is set, emails (receipts, staff invites) are printed to the web container's log (docker compose logs web).
Costs: SMTP providers are third-party services with their own pricing (often a free tier, then per email); you are responsible for any costs.
Sign-in methods
| Who | Methods |
|---|---|
| Customer app | Phone number (SMS code), Google, Apple (iOS), and guest (browse and price services; sign in to book). |
| Handypro Pro app | Phone number, Google and Apple (iOS). New pros then fill in the 6-step application. |
| Website and pro web panel | Phone number and Google. |
| Admin | Email and password. |
Apple sign-in (required by Apple when an iOS app offers Google sign-in): in the Apple Developer account, enable Sign in with Apple for both app ids and add the capability in Xcode, then turn Apple on in Firebase → Authentication → Sign-in method.
App permissions
The apps ask only for what a feature needs, at the moment it is used. The texts the phone shows are in ios/Runner/Info.plist (iOS) and are explained in the store listing (Android). Change them there if you change what a feature does.
| Permission | Customer app | Pro app |
|---|---|---|
| Camera | Take photos of the job for your pro, a warranty claim or a support request. | Take photos of your documents, your work before and after a job, and any extra parts you charge for. |
| Photos | Choose a profile photo or attach photos of the job to a booking or support request. | Choose photos of your documents, your work and your portfolio. |
| Location (while in use) | Show pros who serve your street and set the address for your booking. | Share your live location with the customer while you are on the way to a job, and get nearby job requests while you are online. |
| Notifications | Booking updates and messages. | New requests, job updates, messages and payouts. |
Android also lists INTERNET, ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION, CAMERA and POST_NOTIFICATIONS in AndroidManifest.xml.
Rename the apps and package ids
From the kit folder (the one holding handypro_customer_flutter; requires Node.js):
node tools/rename.mjs --id com.yourcompany.fixly --name "Fixly"
--idsets the customer app's Android package and iOS bundle id. The Pro app gets<id>.provider, or choose it with--pro-id. Android'sMainActivityfolder is moved andFIREBASE_IOS_BUNDLE_IDin each app's.envis updated.--namerenames the customer app, names the Pro app "Name Pro" (or choose with--pro-name), and replaces the brand name in the app and website texts.
Then register the new ids in Firebase (chapter 4). The name people see inside the apps and on the website also comes from Admin → Settings → General → App name, so you can change it any time without a new build.
Logo, icon, colours and fonts
- App icons and splash screens: each app keeps its images in
assets/branding/—icon.png(1024×1024, no transparency),icon_foreground.png(the mark on a transparent 1024×1024 canvas, for Android's adaptive icon),splash.pngandsplash_android12.png. Replace them, change the colours underflutter_launcher_iconsandflutter_native_splashinpubspec.yamlif you like, then run in that app:dart run flutter_launcher_icons dart run flutter_native_splash:create - Logo inside the apps:
assets/images/logo.pngin each app, or upload a logo in Admin → Settings → General, which the apps and website use instead. - Website icon:
handypro_web_nextjs/src/app/icon.png(256×256) andapple-icon.png(180×180). - Brand colour: Admin → Settings → General → Primary colour changes it in both apps and on the website without a new build. The default palette is in
handypro_flutter_core/lib/src/theme/tokens.dartandhandypro_web_nextjs/src/app/globals.css. - Fonts: the apps use Manrope from
handypro_flutter_core/assets/fonts/(declared in that package'spubspec.yaml); the website loads it from Google Fonts insrc/app/layout.tsx.
Basic edits
| I want to change… | Where |
|---|---|
| Terms, privacy policy, provider terms and About | Admin → Settings → Legal pages (shown in both apps and on the website). |
| Currency, distance unit, time zone, support email | Admin → Settings → General. |
| Service fee, instant jobs, slot length, cancel and reschedule fees, warranty days | Admin → Settings → Bookings. |
| Payment methods, tips, wallet amounts, payout days, referral rewards | Admin → Settings → Payment gateways. |
| Push, email and SMS texts | Admin → Settings → SMS & email. |
| Force an app update, store links | Admin → Settings → App versions. |
| Maintenance message, guest browsing | Admin → Settings → General. |
| Help centre questions | Admin → Settings → General. |
| Home banners | Admin → Banners. |
| Texts inside the apps | The screens are in lib/pages/ of each app (shared screens in handypro_flutter_core/lib/src/pages/); search for the text and edit it. |
| Sample images | Upload your own photos in the admin. The sample job photos are in handypro_web_nextjs/public/samples/. |
Every key, explained
Each folder has a .env.example that lists its keys with comments. This table lists all of them: website = handypro_web_nextjs/.env, Docker = deploy/.env, customer app / Pro app = each app's .env.
| Key | Required | Where | What it does |
|---|---|---|---|
| Database | |||
DATABASE_URL | Yes | web | PostgreSQL connection string |
POSTGRES_DB | Yes | Docker | Database the Docker stack creates |
POSTGRES_USER | Yes | Docker | Database user for the Docker stack |
POSTGRES_PASSWORD | Yes | Docker | Database password for the Docker stack |
| App | |||
APP_URL | Yes | web, Docker | Public URL of the website/API, e.g. https://handypro.example.com |
NEXT_PUBLIC_APP_NAME | — | web, Docker | Name shown before the admin sets one |
WEB_PORT | — | Docker | Host port the web container listens on |
DOMAIN | — | Docker | Your domain for automatic HTTPS (docker compose --profile https) |
CORS_ORIGINS | — | web, Docker | Browser apps allowed to call /api/v1 (Flutter web builds) |
DEMO_MODE | — | web, Docker | "true" only for a public demo: one-click demo sign-in |
| Firebase sign-in | |||
NEXT_PUBLIC_FIREBASE_API_KEY | Yes | web, Docker | Firebase web app config |
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN | Yes | web, Docker | Firebase web app config |
NEXT_PUBLIC_FIREBASE_PROJECT_ID | Yes | web, Docker | Firebase web app config |
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET | — | web, Docker | Firebase web app config |
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID | — | web, Docker | Firebase web app config |
NEXT_PUBLIC_FIREBASE_APP_ID | Yes | web, Docker | Firebase web app config |
| Firebase Admin + push | |||
FIREBASE_PROJECT_ID | Yes | web, Docker | Firebase project id |
FIREBASE_CLIENT_EMAIL | Yes | web, Docker | Service account e-mail |
FIREBASE_PRIVATE_KEY | Yes | web, Docker | Service account private key |
| Payments | |||
STRIPE_SECRET_KEY | — | web, Docker | Stripe secret key — card payments (empty = cash and wallet only) |
STRIPE_WEBHOOK_SECRET | — | web, Docker | Stripe webhook signing secret (/api/v1/webhooks/stripe) |
DEMO_PAYMENTS | — | web, Docker | "true" shows a demo card (orders marked paid without money) while Stripe is not set; empty = only on the public demo |
| Pro plans | |||
REVENUECAT_API_KEY | — | web, Docker | RevenueCat secret API key — verifies plan purchases made in Handypro Pro (empty = demo purchases) |
REVENUECAT_WEBHOOK_SECRET | — | web, Docker | Authorization value you set on the RevenueCat webhook (/api/v1/webhooks/revenuecat) |
REVENUECAT_ALLOW_SANDBOX | — | web, Docker | "true" lets store test (sandbox) purchases unlock plans while you test; keep "false" in production |
DEMO_PURCHASES | — | web, Docker | "true" grants plans without payment while no RevenueCat/Stripe key is set; empty = only on the public demo |
| SMS | |||
TWILIO_ACCOUNT_SID | — | web, Docker | Twilio account SID — booking text messages (empty = push and inbox only) |
TWILIO_AUTH_TOKEN | — | web, Docker | Twilio auth token |
TWILIO_FROM_NUMBER | — | web, Docker | Your Twilio phone number, e.g. +15125550100 |
| Maps | |||
GOOGLE_MAPS_API_KEY | — | web, Docker | Google Geocoding for address search (empty = OpenStreetMap) |
MAP_TILE_URL | — | web, Docker | Map tiles URL template (MapTiler, Stadia, Mapbox…) |
MAP_ATTRIBUTION | — | web, Docker | Credit line your tile provider requires |
| Storage | |||
S3_ENDPOINT | — | web, Docker | S3-compatible endpoint (MinIO, R2); empty for AWS |
S3_REGION | — | web, Docker | Bucket region |
S3_BUCKET | — | web, Docker | Bucket for photos and documents (empty = local uploads folder) |
S3_ACCESS_KEY_ID | — | web, Docker | Storage access key |
S3_SECRET_ACCESS_KEY | — | web, Docker | Storage secret key |
SMTP_HOST | — | web, Docker | SMTP server; empty = emails are printed to the log |
SMTP_PORT | — | web, Docker | 587 (STARTTLS) or 465 (TLS) |
SMTP_USER | — | web, Docker | SMTP user |
SMTP_PASS | — | web, Docker | SMTP password |
SMTP_SECURE | — | web, Docker | "true" for port 465 (TLS); empty for 587 |
| Storage | |||
UPLOAD_DIR | — | web | Folder for uploads when no bucket is set (default ./uploads) |
FILES_SECRET | — | web, Docker | Signs the 1-hour links to pro documents (default: derived from DATABASE_URL) |
| Mobile apps | |||
API_BASE_URL | Yes | customer, provider | Your server; the app calls <url>/api/v1 |
APP_NAME | — | customer, provider | App name in the UI |
FIREBASE_PROJECT_ID | Yes | customer, provider | Firebase project id |
FIREBASE_MESSAGING_SENDER_ID | Yes | customer, provider | Firebase config |
FIREBASE_STORAGE_BUCKET | — | customer, provider | Firebase config |
FIREBASE_ANDROID_API_KEY | Yes | customer, provider | Firebase Android config |
FIREBASE_ANDROID_APP_ID | Yes | customer, provider | Firebase Android config |
FIREBASE_IOS_API_KEY | Yes | customer, provider | Firebase iOS config |
FIREBASE_IOS_APP_ID | Yes | customer, provider | Firebase iOS config |
FIREBASE_IOS_CLIENT_ID | — | customer, provider | Google sign-in on iOS |
FIREBASE_IOS_BUNDLE_ID | — | customer, provider | iOS bundle id |
GOOGLE_SERVER_CLIENT_ID | — | customer, provider | Web OAuth client id — Google sign-in on Android |
SUPPORT_EMAIL | — | customer, provider | Fallback support e-mail until the server config loads |
DEMO_SIGN_IN | — | customer, provider | "true" shows one-tap demo sign-in (your public demo only) |
REVENUECAT_ANDROID_KEY | — | provider | RevenueCat public SDK key for Google Play (plan purchases; empty = demo) |
REVENUECAT_IOS_KEY | — | provider | RevenueCat public SDK key for the App Store (plan purchases; empty = demo) |
File structure
Apps (handypro_customer_flutter/lib, handypro_provider_flutter/lib)
main.dart starts Firebase and the app
app.dart theme (with the admin's brand colour) and push handling
router.dart every screen's route and the start-up gates (setup → onboarding → sign-in → …)
pages/ one folder per area
customer: launch, location, home, catalog, search, providers, booking, bookings,
jobs (custom jobs and bids), wallet, chat, account
pro: launch, apply (sign-up), home, requests, jobs, calendar, services,
staff, leads, grow (promos, plans), earnings, reviews, chat, profile
providers/ app state with Riverpod
widgets/, utils/ app-only widgets and helpers
assets/branding/ icon and splash images
Shared app code (handypro_flutter_core/lib/src)
api/ API client
auth/ Firebase sign-in (phone, Google, Apple, guest)
config/ .env reading and Firebase options
models/ Booking, Service, Provider, Config…
pages/ shared screens (legal pages, maintenance, update required…)
providers/ shared Riverpod providers
theme/ colour tokens, text styles, light and dark themes
widgets/ buttons, cards, fields, sheets, map, states (Hp*)
utils/ formatting and helpers
Long lists use lazy .builder lists; state flows through Riverpod providers.
Server (handypro_web_nextjs/src)
app/(site)/ the public website (home, services, categories, pros, search, booking,
checkout, bookings, account, help, become a pro, legal pages)
app/pro/ the pro web panel
app/admin/ the admin panel
app/install/ the first-run wizard
app/api/v1/ one route that hands every API call to lib/server/router.ts
lib/server/ business logic: handlers/ (the API routes), bookings, pricing, slots, payments
(Stripe), wallet, notify (push), sms, email, storage, geo, settings, setup-check
lib/client/ browser code: Firebase sign-in, API calls
components/ site/ (website), panel/ (admin and pro panel), app/ (shared), ui/ (building blocks)
database/ Prisma schema, migrations, seed (sample data)
worker/ background jobs (pnpm worker)
tests/ API tests (pnpm test)
Outside src, scripts/doctor.ts is pnpm run doctor, and e2e/ holds browser smoke tests for a running server:
E2E_BASE_URL=https://your-domain.com pnpm e2e (run pnpm exec playwright install chromium once first).
API reference
The apps, the website and both panels use one JSON API under /api/v1 (237 routes, listed from the code when this guide is built). Signed-in calls send the Firebase ID token as Authorization: Bearer <token>; the website and panels use a session cookie. Errors come back as {"error": {"code", "message", "fields"}} with the usual HTTP status codes; limits answer 429.
| Method | Path | Sign-in | What it does |
|---|---|---|---|
| System, files, sign-in and webhooks | |||
GET | /api/v1/health | No | Health check |
GET | /api/v1/firebase-config.js | No | Firebase web config for the website and panels |
GET | /api/v1/config | No | Public app config: brand, currency, features, links |
GET | /api/v1/legal/:doc | No | A legal document (terms, privacy, provider terms) |
GET | /api/v1/geo/search | No | Address search |
GET | /api/v1/geo/reverse | No | Address of a point |
GET | /api/v1/zones/check | No | Whether a point is inside a service zone |
GET | /api/v1/files/* | Signed link for private files | A stored file (private files need a signed link) |
POST | /api/v1/uploads | Signed in | Upload a photo or document (images and PDF only, size limit) |
POST | /api/v1/auth/demo | No | One-click demo sign-in (demo server only) |
POST | /api/v1/webhooks/stripe | Signed by the provider | Stripe events: card holds, top-ups, plans (signed) |
| First-run install | |||
GET | /api/v1/install/status | No | Whether the install wizard still needs to run |
POST | /api/v1/install | No | First run: admin account, business details, optional sample data |
| Account, addresses, cards and notifications | |||
GET | /api/v1/me | Signed in | The signed-in user |
PATCH | /api/v1/me | Signed in | Edit name, photo, email or phone |
PATCH | /api/v1/me/notification-prefs | Signed in | Notification settings |
DELETE | /api/v1/me | Signed in | Delete the account |
POST | /api/v1/me/referral | Signed in | Enter an invite code (first days only) |
POST | /api/v1/me/devices | Signed in | Register a device for push |
DELETE | /api/v1/me/devices | Signed in | Remove a device |
GET | /api/v1/me/addresses | Signed in | Saved addresses |
POST | /api/v1/me/addresses | Signed in | Add an address (checked against the zones) |
PATCH | /api/v1/me/addresses/:id | Signed in | Edit an address |
DELETE | /api/v1/me/addresses/:id | Signed in | Delete an address |
GET | /api/v1/me/favourites | Signed in | Saved services |
PUT | /api/v1/me/favourites/:serviceId | Signed in | Save a service |
DELETE | /api/v1/me/favourites/:serviceId | Signed in | Remove a saved service |
GET | /api/v1/me/notifications | Signed in | Notifications, paged |
POST | /api/v1/me/notifications/read | Signed in | Mark notifications read |
DELETE | /api/v1/me/notifications | Signed in | Clear notifications |
GET | /api/v1/me/cards | Signed in | Saved cards |
POST | /api/v1/me/cards | Signed in | Start adding a card (Stripe page) |
POST | /api/v1/me/cards/sync | Signed in | Load cards saved on Stripe |
PATCH | /api/v1/me/cards/:id | Signed in | Make a card the default |
DELETE | /api/v1/me/cards/:id | Signed in | Remove a card |
| Catalogue, pros and search | |||
GET | /api/v1/home | No | Home screen: banners, categories, featured sections, nearby pros |
POST | /api/v1/banners/:id/click | No | Count a banner click |
GET | /api/v1/categories | No | Category tree |
GET | /api/v1/categories/:slug | No | One category with its services |
GET | /api/v1/services | No | Services with filters and sorting |
GET | /api/v1/services/:id | No | One service with add-ons and reviews |
GET | /api/v1/providers | No | Pros near an address |
GET | /api/v1/providers/:id | No | One pro's profile |
GET | /api/v1/providers/:id/reviews | No | A pro's reviews, paged |
GET | /api/v1/providers/:id/slots | No | A pro's free times for a service length |
GET | /api/v1/search | No | Search services, pros and categories |
| Bookings (customer) | |||
POST | /api/v1/bookings/quote | Optional | Price a basket: items, add-ons, fees, coupon and tax |
POST | /api/v1/bookings | Signed in | Book: checks the slot, takes the wallet payment or card hold |
GET | /api/v1/bookings | Signed in | The customer's bookings by tab |
GET | /api/v1/bookings/:id | Signed in | One booking of the customer |
GET | /api/v1/bookings/:id/track | Signed in | Live position and ETA of the pro |
GET | /api/v1/bookings/:id/slots | Signed in | Free times for a reschedule |
POST | /api/v1/bookings/:id/cancel | Signed in | Cancel (fee by the cancellation rules) |
POST | /api/v1/bookings/:id/reschedule | Signed in | Move to a new time |
POST | /api/v1/bookings/:id/extras/:extraId | Signed in | Approve or decline an extra charge |
POST | /api/v1/bookings/:id/rate | Signed in | Rate and review a completed booking |
POST | /api/v1/bookings/:id/tip | Signed in | Tip the pro from the wallet or card |
POST | /api/v1/bookings/:id/warranty | Signed in | Open a warranty claim |
POST | /api/v1/bookings/:id/confirm-payment | Signed in | Re-check a Stripe Checkout after the return page |
GET | /api/v1/bookings/:id/invoice | Signed in | Invoice of a booking (HTML) |
| Custom jobs, leads and bids | |||
GET | /api/v1/jobs | Signed in | The customer's custom job requests |
POST | /api/v1/jobs | Signed in | Post a custom job for pros to bid on |
GET | /api/v1/jobs/:id | Signed in | One job request with its bids |
GET | /api/v1/jobs/:id/bids/:bidId | Signed in | One bid with the pro's profile |
POST | /api/v1/jobs/:id/bids/:bidId/accept | Signed in | Accept a bid: becomes a booking |
POST | /api/v1/jobs/:id/cancel | Signed in | Cancel a job request |
GET | /api/v1/provider/leads | Provider | Open job requests in the pro's categories and area |
GET | /api/v1/provider/leads/:id | Provider | One lead |
POST | /api/v1/provider/leads/:id/bids | Provider | Bid on a lead |
GET | /api/v1/provider/bids | Provider | The pro's bids |
POST | /api/v1/provider/bids/:id/withdraw | Provider | Withdraw a bid |
| Chat | |||
GET | /api/v1/chats | Signed in | The customer's chat threads |
POST | /api/v1/chats | Signed in | Open a chat with a pro (optionally about a booking or job) |
GET | /api/v1/chats/:id | Signed in | One thread with its messages |
POST | /api/v1/chats/:id/messages | Signed in | Send a message |
GET | /api/v1/provider/chats | Provider | The pro's chat threads |
POST | /api/v1/provider/chats | Provider | Open a chat with a customer about a booking or a lead |
GET | /api/v1/provider/chats/:id | Provider | One thread with its messages |
POST | /api/v1/provider/chats/:id/messages | Provider | Send a message |
| Wallet, referrals and support | |||
GET | /api/v1/wallet | Signed in | Wallet balance and history |
POST | /api/v1/wallet/topup | Signed in | Add money by card (Stripe Checkout) |
POST | /api/v1/wallet/redeem | Signed in | Redeem a gift code |
GET | /api/v1/referral | Signed in | Invite code and rewards |
GET | /api/v1/help | No | Help centre questions and answers |
GET | /api/v1/support/tickets | Signed in | The customer's support tickets |
POST | /api/v1/support/tickets | Signed in | Open a support ticket |
GET | /api/v1/support/tickets/:id | Signed in | One ticket with its messages |
POST | /api/v1/support/tickets/:id/messages | Signed in | Reply to a ticket |
| Provider plans and store purchases | |||
GET | /api/v1/plans | No | Provider plans |
GET | /api/v1/provider/plans | Provider | Plans with the pro's current plan |
POST | /api/v1/purchases/demo | Provider | Demo plan purchase (only with DEMO_PURCHASES="true") |
POST | /api/v1/purchases/sync | Provider | Re-read the pro's store subscription from RevenueCat |
POST | /api/v1/webhooks/revenuecat | Signed by the provider | RevenueCat subscription events (shared secret) |
POST | /api/v1/provider/plans/checkout | Provider | Stripe subscription Checkout for a plan |
POST | /api/v1/provider/plans/cancel | Provider | Cancel a Stripe plan at the period end |
| Provider app and panel | |||
GET | /api/v1/provider/me | Provider | The pro's profile, plan and application |
POST | /api/v1/provider/apply | Signed in | Start a pro application |
PATCH | /api/v1/provider/application | Provider | Save application steps (business, categories, documents) |
POST | /api/v1/provider/application/submit | Provider | Submit the application for review |
PATCH | /api/v1/provider/status | Provider | Go online or offline |
PATCH | /api/v1/provider/location | Provider | Share the live position while travelling |
GET | /api/v1/provider/dashboard | Provider | Today's run, figures and requests |
GET | /api/v1/provider/requests | Provider | New booking requests |
POST | /api/v1/provider/requests/:id/accept | Provider | Accept a request (optionally for a staff member) |
POST | /api/v1/provider/requests/:id/decline | Provider | Decline a request with a reason |
GET | /api/v1/provider/jobs | Provider | The pro's jobs by tab |
GET | /api/v1/provider/jobs/:id | Provider | One job with the customer's details |
POST | /api/v1/provider/jobs/:id/assign | Provider | Assign a staff member |
POST | /api/v1/provider/jobs/:id/on-my-way | Provider | Set off (ETA from the distance) |
POST | /api/v1/provider/jobs/:id/arrived | Provider | Mark arrival |
POST | /api/v1/provider/jobs/:id/verify-code | Provider | Check the customer's start code (5 tries) |
POST | /api/v1/provider/jobs/:id/start | Provider | Start with before photos; the card is charged |
POST | /api/v1/provider/jobs/:id/extras | Provider | Propose an extra charge |
POST | /api/v1/provider/jobs/:id/finish | Provider | Finish with after photos; the pro is paid |
POST | /api/v1/provider/jobs/:id/collect-cash | Provider | Record cash taken from the customer |
GET | /api/v1/provider/calendar | Provider | Jobs and time off for a range |
GET | /api/v1/provider/hours | Provider | Weekly opening hours |
PUT | /api/v1/provider/hours | Provider | Save opening hours |
POST | /api/v1/provider/time-off | Provider | Block time off |
DELETE | /api/v1/provider/time-off/:id | Provider | Remove time off |
GET | /api/v1/provider/categories | No | Categories the pro works in |
GET | /api/v1/provider/services | Provider | The pro's services |
POST | /api/v1/provider/services | Provider | Add a service (plan limits apply) |
PATCH | /api/v1/provider/services/:id | Provider | Edit a service |
DELETE | /api/v1/provider/services/:id | Provider | Delete a service |
GET | /api/v1/provider/staff | Provider | Staff members with job counts |
POST | /api/v1/provider/staff | Provider | Add a staff member (plan limits apply) |
GET | /api/v1/provider/staff/:id | Provider | One staff member with their jobs |
PATCH | /api/v1/provider/staff/:id | Provider | Edit a staff member |
DELETE | /api/v1/provider/staff/:id | Provider | Remove a staff member |
GET | /api/v1/provider/promos | Provider | The pro's own promo codes |
POST | /api/v1/provider/promos | Provider | Create a promo code |
PATCH | /api/v1/provider/promos/:id | Provider | Edit a promo code |
DELETE | /api/v1/provider/promos/:id | Provider | Delete a promo code |
GET | /api/v1/provider/earnings | Provider | Balance, ledger and earnings by period |
POST | /api/v1/provider/withdrawals | Provider | Request a withdrawal of the available balance |
GET | /api/v1/provider/payouts | Provider | Payouts and withdrawals |
PUT | /api/v1/provider/payout-account | Provider | Save the payout bank account |
GET | /api/v1/provider/reviews | Provider | Reviews of the pro |
POST | /api/v1/provider/reviews/:id/reply | Provider | Reply to a review |
POST | /api/v1/provider/reviews/:id/report | Provider | Report a review to the admin |
PATCH | /api/v1/provider/profile | Provider | Edit the business profile |
GET | /api/v1/provider/portfolio | Provider | Portfolio photos |
POST | /api/v1/provider/portfolio | Provider | Add a portfolio photo |
DELETE | /api/v1/provider/portfolio/:id | Provider | Remove a portfolio photo |
GET | /api/v1/provider/tickets | Provider | The pro's support tickets |
GET | /api/v1/provider/tickets/:id | Provider | One ticket with its messages |
POST | /api/v1/provider/tickets/:id/messages | Provider | Reply to a ticket |
| Admin: bookings, disputes, pros and customers | |||
GET | /api/v1/admin/dashboard | Staff: bookings | Today's board, decisions queue, figures and cash due |
GET | /api/v1/admin/reports | Staff: bookings | Revenue, bookings and top categories for a range and zone |
GET | /api/v1/admin/reports/bookings.csv | Staff: bookings | Bookings of a range as CSV |
GET | /api/v1/admin/search | Staff: bookings | Search bookings, customers and pros |
GET | /api/v1/admin/bookings | Staff: bookings | Bookings list with filters |
GET | /api/v1/admin/bookings/:id | Staff: bookings | One booking with payments, events and chat |
GET | /api/v1/admin/bookings/:id/candidates | Staff: bookings | Pros who could take a booking |
POST | /api/v1/admin/bookings/:id/assign | Staff: bookings | Assign or reassign a pro |
POST | /api/v1/admin/bookings/:id/refund | Staff: disputes | Refund part or all of a booking |
POST | /api/v1/admin/bookings/:id/cancel | Staff: bookings | Cancel a booking |
POST | /api/v1/admin/bookings/:id/unlock-code | Staff: bookings | Unlock a start code after too many wrong tries |
POST | /api/v1/admin/bookings/:id/dispute | Staff: disputes | Open a dispute for a booking |
POST | /api/v1/admin/bookings | Staff: bookings | Book on behalf of a customer |
GET | /api/v1/admin/disputes | Staff: disputes | Disputes and warranty claims |
GET | /api/v1/admin/tickets | Staff: disputes | Support tickets |
GET | /api/v1/admin/disputes/:id | Staff: disputes | One dispute or ticket with its messages |
POST | /api/v1/admin/disputes/:id/reply | Staff: disputes | Reply to a dispute or ticket |
POST | /api/v1/admin/disputes/:id/resolve | Staff: disputes | Resolve a dispute (optionally with a refund) |
GET | /api/v1/admin/jobs | Staff: bookings | Custom job requests |
GET | /api/v1/admin/jobs/:id | Staff: bookings | One job request with its bids |
POST | /api/v1/admin/jobs/:id/close | Staff: bookings | Close a job request |
GET | /api/v1/admin/providers | Staff: providers | Pros and applications |
GET | /api/v1/admin/providers/:id | Staff: providers | One pro: profile, documents, services, money |
POST | /api/v1/admin/providers/:id/documents/:key | Staff: providers | Approve or reject one KYC document |
POST | /api/v1/admin/providers/:id/approve | Staff: providers | Approve an application |
POST | /api/v1/admin/providers/:id/reject | Staff: providers | Reject an application with a reason |
POST | /api/v1/admin/providers/:id/suspend | Staff: providers | Suspend or reinstate a pro |
PATCH | /api/v1/admin/providers/:id | Staff: providers | Edit a pro (plan, featured, zone) |
POST | /api/v1/admin/providers/:id/cash-deposit | Staff: payouts | Record cash a pro paid back |
POST | /api/v1/admin/providers | Staff: providers | Add a pro |
POST | /api/v1/admin/providers/import | Staff: providers | Import pros from CSV |
GET | /api/v1/admin/customers | Staff: customers | Customers list |
GET | /api/v1/admin/customers/export.csv | Staff: customers | Customers as CSV |
GET | /api/v1/admin/customers/:id | Staff: customers | One customer: bookings, wallet, tickets |
POST | /api/v1/admin/customers/:id/ban | Staff: customers | Ban or unban a customer |
POST | /api/v1/admin/customers/:id/wallet | Staff: customers | Credit or debit a wallet with a reason |
| Admin: catalogue, money, marketing and settings | |||
GET | /api/v1/admin/categories | Staff: catalog | Category tree with service counts |
POST | /api/v1/admin/categories | Staff: catalog | Create a category |
PATCH | /api/v1/admin/categories/:id | Staff: catalog | Edit a category (name, icon, photo, active) |
POST | /api/v1/admin/categories/reorder | Staff: catalog | Save the category order |
DELETE | /api/v1/admin/categories/:id | Staff: catalog | Delete an empty category |
GET | /api/v1/admin/services | Staff: catalog | Every service, filterable by category, pro and status |
PATCH | /api/v1/admin/services/:id | Staff: catalog | Edit or hide a service |
GET | /api/v1/admin/banners | Staff: promotions | Home banners with click counts |
POST | /api/v1/admin/banners | Staff: promotions | Create a banner |
PATCH | /api/v1/admin/banners/:id | Staff: promotions | Edit a banner |
DELETE | /api/v1/admin/banners/:id | Staff: promotions | Delete a banner |
GET | /api/v1/admin/featured | Staff: promotions | Featured home sections |
POST | /api/v1/admin/featured | Staff: promotions | Create a featured section |
PATCH | /api/v1/admin/featured/:id | Staff: promotions | Edit a featured section |
DELETE | /api/v1/admin/featured/:id | Staff: promotions | Delete a featured section |
GET | /api/v1/admin/commissions | Staff: payouts | Plans, commission overrides, 30-day commission and MRR from paid plans |
POST | /api/v1/admin/commissions/overrides | Staff: payouts | Add a commission override for a category or a pro |
DELETE | /api/v1/admin/commissions/overrides/:id | Staff: payouts | Remove a commission override |
PATCH | /api/v1/admin/plans/:code | Staff: payouts | Edit a provider plan (price, commission, limits) |
GET | /api/v1/admin/payouts | Staff: payouts | Payouts and withdrawal requests |
POST | /api/v1/admin/payouts/:id/status | Staff: payouts | Approve, mark paid or reject a payout |
GET | /api/v1/admin/payouts/export.csv | Staff: payouts | Payouts as CSV |
GET | /api/v1/admin/coupons | Staff: promotions | Promo codes with uses and cost |
POST | /api/v1/admin/coupons | Staff: promotions | Create a promo code |
PATCH | /api/v1/admin/coupons/:id | Staff: promotions | Edit a promo code |
DELETE | /api/v1/admin/coupons/:id | Staff: promotions | Delete a promo code |
GET | /api/v1/admin/gift-codes | Staff: promotions | Gift codes and who redeemed them |
POST | /api/v1/admin/gift-codes | Staff: promotions | Create gift codes |
DELETE | /api/v1/admin/gift-codes/:id | Staff: promotions | Delete an unused gift code |
GET | /api/v1/admin/taxes | Staff: settings | Tax rules |
POST | /api/v1/admin/taxes | Staff: settings | Create a tax rule |
PATCH | /api/v1/admin/taxes/:id | Staff: settings | Edit a tax rule |
DELETE | /api/v1/admin/taxes/:id | Staff: settings | Delete a tax rule |
GET | /api/v1/admin/zones | Staff: bookings | Service zones with their polygons |
POST | /api/v1/admin/zones | Staff: settings | Create a zone |
PATCH | /api/v1/admin/zones/:id | Staff: settings | Edit a zone |
DELETE | /api/v1/admin/zones/:id | Staff: settings | Delete a zone |
GET | /api/v1/admin/campaigns | Staff: promotions | Push campaigns |
GET | /api/v1/admin/campaigns/reach | Staff: promotions | How many devices a campaign would reach |
POST | /api/v1/admin/campaigns | Staff: promotions | Create a push campaign |
POST | /api/v1/admin/campaigns/:id/send | Staff: promotions | Send a push campaign |
DELETE | /api/v1/admin/campaigns/:id | Staff: promotions | Delete a draft campaign |
GET | /api/v1/admin/reviews | Staff: customers | Reviews to moderate (flagged and reported first) |
PATCH | /api/v1/admin/reviews/:id | Staff: customers | Hide, show or clear a review's flag |
GET | /api/v1/admin/settings | Staff: settings | Every settings group |
PUT | /api/v1/admin/settings/:group | Staff: settings | Save one settings group |
GET | /api/v1/admin/setup | Staff: settings | Setup check: missing keys and connection tests |
GET | /api/v1/admin/audit | Super admin | Admin audit log |
GET | /api/v1/admin/roles | Staff: settings | Staff roles and their permission grid |
PUT | /api/v1/admin/roles/:role | Super admin | Save a role's permission grid (super admin) |
GET | /api/v1/admin/staff | Staff: settings | Staff members |
POST | /api/v1/admin/staff | Super admin | Add a staff member (super admin) |
PATCH | /api/v1/admin/staff/:id | Super admin | Change a staff member's role or status (super admin) |
Database overview
PostgreSQL with Prisma. The schema is handypro_web_nextjs/src/database/prisma_client/schema.prisma; every change is a migration in migrations/ that runs on start (pnpm run prisma:migrate:deploy). Money is stored in cents as whole numbers. 43 tables:
| Table | What it holds | Keys | Links to |
|---|---|---|---|
| People | |||
users | Customers, pros and staff (one row per Firebase account); wallet balance and staff role live here. | PK id unique firebaseUid unique referralCode | referredById → users |
devices | Push tokens of a user's phones. | PK id unique token | userId → users |
addresses | Saved customer addresses with their zone. | PK id | userId → users zoneId → zones |
saved_cards | Cards the customer saved. Only brand + last 4 are kept; Stripe holds the card (stripePmId). Demo cards: demo=true. Cards saved on Stripe (brand and last four only). | PK id unique stripePmId | userId → users |
| Geography + catalogue structure | |||
zones | Service areas drawn on the map (polygon). | PK id | — |
categories | Top-level categories (parentId null) and their service types (parentId set). Service categories and sub-categories. | PK id unique slug | parentId → categories |
| Providers | |||
providers | A pro or company: profile, application, plan, rating and live position. | PK id unique userId unique slug | userId → users zoneId → zones |
provider_categories | Categories a pro is approved for. | PK (providerId, categoryId) | providerId → providers categoryId → categories |
provider_hours | Weekly opening hours of a pro. | PK id unique (providerId, day) | providerId → providers |
time_off | Blocked time in a pro's calendar. | PK id | providerId → providers staffId → staff_members |
staff_members | The provider's team. Staff are assigned to jobs; they don't sign in to the app in release 1. Staff of a company pro. | PK id | providerId → providers |
portfolio_items | Portfolio photos of a pro. | PK id | providerId → providers |
services | A service a pro sells, with price, unit and duration. | PK id unique slug | providerId → providers categoryId → categories |
service_addons | Optional extras of a service. | PK id | serviceId → services |
favourites | Services a customer saved. | PK (userId, serviceId) | userId → users serviceId → services |
| Bookings | |||
bookings | A booking: time, address, money split, payment state, start code and photos. | PK id unique code unique checkoutSessionId unique jobRequestId unique bidId | userId → users providerId → providers staffId → staff_members zoneId → zones couponId → coupons jobRequestId → job_requests bidId → bids |
booking_items | Services and add-ons in a booking. | PK id | bookingId → bookings |
booking_events | Status history of a booking. | PK id | bookingId → bookings |
extra_charges | Extra charges a pro proposed during a job. | PK id | bookingId → bookings |
reviews | Ratings and reviews of completed bookings. | PK id unique bookingId | bookingId → bookings userId → users providerId → providers serviceId → services |
| Custom jobs + bids | |||
job_requests | Custom jobs customers post for bids. | PK id unique code unique acceptedBidId | userId → users categoryId → categories zoneId → zones |
bids | Pros' bids on custom jobs. | PK id unique (jobRequestId, providerId) | jobRequestId → job_requests providerId → providers |
| Chat | |||
chat_threads | Chat between a customer and a pro. | PK id unique (userId, providerId) | userId → users providerId → providers bookingId → bookings jobRequestId → job_requests |
messages | Messages in chats and support tickets. | PK id | threadId → chat_threads ticketId → support_tickets senderId → users |
| Help, disputes, warranty claims | |||
support_tickets | Support tickets, disputes and warranty claims. | PK id unique code | userId → users bookingId → bookings |
| Promotions + catalogue merchandising | |||
coupons | Promo codes (platform or a pro's own). | PK id unique code | providerId → providers categoryId → categories |
coupon_redemptions | Which booking used which promo code. | PK id unique bookingId | couponId → coupons userId → users bookingId → bookings |
redeem_codes | Wallet codes customers redeem: gift cards and promo credit. Gift codes that credit the wallet. | PK id unique code | — |
banners | Home screen banners. | PK id | zoneId → zones |
featured_sections | Home sections the admin curates: services or providers, in order. Featured sections on the home screen. | PK id | — |
push_campaigns | Push campaigns sent from the admin. | PK id | — |
| Money | |||
provider_plans | Provider plans. Commission and limits come from the provider's plan. Provider subscription plans and their commission. | PK code | — |
commission_overrides | Category or provider commission that wins over the plan rate until until. Commission rates for a category or a pro. | PK id | categoryId → categories providerId → providers |
tax_rules | Tax rules by zone and category. | PK id | zoneId → zones categoryId → categories |
purchase_events | Plan purchases (demo path, RevenueCat or Stripe). idempotencyKey makes retries safe. Plan purchases and renewals (Stripe, store, demo, admin), once per event. | PK id unique idempotencyKey | — |
wallet_txs | Every wallet movement (top-ups, payments, refunds, rewards). | PK id unique externalId | userId → users |
earning_entries | Provider ledger. Positive = owed to them; payouts and cash commission are negative. The pro's ledger: job earnings, tips, fees and cash owed. | PK id | providerId → providers payoutId → payouts |
payouts | Payouts and withdrawal requests. | PK id unique code | providerId → providers |
| System | |||
notifications | In-app notifications. | PK id | userId → users |
settings | Admin settings, one row per group. | PK (group, key) | — |
audit_logs | Admin actions log. | PK id | actorId → users |
geo_cache | Cached address lookups. | PK query | — |
rate_limits | Fixed-window counters for the API's rate limits (one row per key and window; old rows are swept by the worker). Request counters for rate limits. | PK (key, windowStart) | — |
Extend it: a worked example
This example adds a gate code the customer can leave for the pro on a booking, end to end: database, API and both apps. The same four steps work for any new field.
1. Database
In schema.prisma, add the field to model Booking, then create the migration:
gateCode String?
cd handypro_web_nextjs
pnpm run prisma:migrate:dev --name booking_gate_code
2. API
In src/lib/server/handlers/bookings.ts, accept the field in the POST /bookings body schema and pass it on; in src/lib/server/bookings.ts (createBooking) save it with the booking:
gateCode: z.string().trim().max(20).optional(), // body schema
gateCode: input.gateCode ?? null, // prisma.booking.create data
Show it to the pro only: in src/lib/server/views.ts add gateCode: b.gateCode to the provider's booking view. Add a test in src/tests/bookings.test.ts and run pnpm test.
3. Customer app
In handypro_customer_flutter/lib/pages/booking/, add a text field to the checkout notes and send it with the booking request ('gateCode': controller.text).
4. Pro app
Add final String? gateCode; to the job model in handypro_flutter_core/lib/src/models/ (read it in fromJson), and show it on the job detail page next to the address. Run flutter analyze in each package.
Publish to the stores
You publish both apps under your own developer accounts. Rename them first (chapter 17). Repeat these steps for each app.
Google Play
- Create an upload key:
keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload. - Create
android/key.propertiesin the app:storeFile=/home/you/upload-keystore.jks storePassword=… keyAlias=upload keyPassword=…storeFileis the full path to your keystore. The build uses it automatically (without it, release builds are signed with the debug key, which Google Play refuses). Never share this file or the keystore. - Set the version in
pubspec.yaml(version: 1.0.0+1), runflutter build appbundle, and uploadbuild/app/outputs/bundle/release/app-release.aabin the Play Console. - Fill the store listing, the data safety form (the apps collect name, phone, email, precise location while a pro travels to a job, approximate location while browsing, photos and documents the user uploads, payment info through Stripe, and messages) and the content rating. The Pro app shares location in the foreground only while a job is "On my way".
- Add the Play app signing key's SHA-1 and SHA-256 to Firebase (chapter 4).
App Store
- In Apple Developer, create the app id with Push Notifications and Sign in with Apple.
- Open
ios/Runner.xcworkspacein Xcode → Runner → Signing & Capabilities → choose your team (Automatic signing is already on; chapter 9). flutter build ipa, then upload with Xcode's Organizer or Transporter, and submit in App Store Connect. Give the reviewer a test phone number and code (chapter 4 tip). For the Pro app, also give them an approved test pro account, because new pros wait for your approval.
Updating
When a new version comes out, read its changelog, back up your database, and copy the new files over your copy (keep your .env files and your edits — a tool like git makes merging easy). Then docker compose up -d --build; migrations run on start. For the apps, run flutter pub get and build again.
FAQ and troubleshooting
An app shows the setup screen
Its .env is missing API_BASE_URL or Firebase values. Fill them in and restart the app (a hot reload does not re-read .env).
The app can't reach the server
Open API_BASE_URL/api/v1/health in the phone's browser. On the Android emulator, localhost is the emulator itself — use http://10.0.2.2:3000. Android blocks plain http:// to other hosts; use HTTPS.
Sign-in fails on the website
Add your domain under Firebase → Authentication → Settings → Authorized domains, and check the NEXT_PUBLIC_FIREBASE_* values in Admin → Setup check.
Google sign-in fails on Android
Add the SHA-1 and SHA-256 of the key that signed the build (debug, upload and Play signing) to that Android app in Firebase, and set GOOGLE_SERVER_CLIENT_ID.
Phone sign-in says the SMS can't be sent
Allow your users' countries in Firebase → Authentication → Settings → SMS region policy.
Customers see no pros
The address must be inside a Live zone (Admin → Zones), and pros must be approved, online or have open hours, and offer a service in that category within their travel radius.
A new pro can't get requests
Approve them in Admin → Providers → Pending. Then they need at least one active service and their hours; the Pro app home reminds them.
Card payments say "DEMO"
No Stripe key is set and DEMO_PAYMENTS="true" (or the server runs as a public demo). See chapter 11. Plan purchases say DEMO without RevenueCat (app) or Stripe (web panel) keys when DEMO_PURCHASES="true", see chapter 12.
Photos or documents do not upload
Run the setup check: the storage test shows the problem (wrong S3_* keys, or the bucket does not exist).
Requests never expire, cards are not authorised, campaigns stay scheduled
The worker must be running: docker compose ps shows worker up, and Admin → Setup check shows its last heartbeat.
I changed .env and nothing happened
Server: docker compose up -d recreates the containers with the new values. Apps: stop and start the app again.
Where are the logs?
docker compose logs -f web and docker compose logs -f worker.
Credits
The services in Third-party services are separate companies, not part of this item. Each has its own pricing tiers and terms, and you are responsible for any costs from them.
Handypro is built on these open-source projects, each under its own licence:
Apps
Flutter, flutter_riverpod, go_router, firebase_core, firebase_auth, firebase_messaging, google_sign_in, sign_in_with_apple, purchases_flutter, flutter_map, latlong2, geolocator, image_picker, http, flutter_dotenv, shared_preferences, share_plus, url_launcher, package_info_plus, intl, lucide_icons_flutter, flutter_launcher_icons, flutter_native_splash.
Website, panels and API
Next.js, React, Prisma, PostgreSQL, Tailwind CSS, shadcn/ui, Base UI, Lucide, Leaflet and React Leaflet, Firebase JS SDK and Firebase Admin, Stripe Node, AWS SDK for JavaScript, Nodemailer, Zod, Sonner, next-themes, node-qrcode, MinIO, Caddy.
Fonts, maps and content
Manrope (SIL Open Font License 1.1, licence file in handypro_flutter_core/assets/fonts/OFL.txt). Map data © OpenStreetMap contributors. The sample businesses, people, reviews and texts are original and fictional. The sample images are placeholders; the photos on the live demo are not part of the download.
Changelog
1.0.0 — 2026-10-23
First release. The full list is in CHANGELOG.md.
Not in this version
Video estimates, recurring bookings, app languages other than English, and two-step sign-in for admins.
Support
Use the Support tab on the item's CodeCanyon page. Please include your purchase code, what you did, what you expected and what happened, and the output of pnpm run doctor or a screenshot of Admin → Setup check.
Support covers questions about the item's features and fixing bugs in the item. Custom changes and installation on your server are not part of item support.