Shootlo is TheRare Visuals' second product: book a verified photographer or videographer near you, for the same day. This page is the working engineering document: the problems we hit while building it, and how each one is solved today. It is updated as the product changes, so anything marked open is genuinely still open.
The shape of the problem
A same-day marketplace is three systems pretending to be one:
- a matching system that finds a person who can be somewhere within the hour,
- a money system that holds a customer's payment until the work is delivered,
- a trust system that lets two strangers meet safely and agree on what happened.
Every hard bug we've had so far lives in one of those three, or in the seam between them.
1. Matching when supply is scarce
Problem. Food delivery works because riders are everywhere. Photographers are not. Broadcasting a job to everyone online produces a stampede for good jobs, silence for the rest, and photographers who learn to ignore notifications.
Solution.
- Filter first: online, verified, offers that shoot type, and the place is inside their travel radius.
- Rank deterministically, no model in the loop: road ETA 40, rating and acceptance 24, fairness (time since last job) 20, customer preferences 16.
- Offer in waves of three with a 60-second window, up to five waves. A scheduled task in the API expires offers and sends the next wave.
- Accepting is one conditional update, so the race resolves in the database:
UPDATE bookings
SET photographer_id = ?, status = 'ACCEPTED'
WHERE id = ? AND status = 'MATCHING'; -- 1 row wins, everyone else gets 0- Bookings are open anywhere in India. "Now" is offered only inside the inner ring of a city with a local team; everywhere else is later today or another day. The customer pays only after a photographer accepts, so a booking nobody takes costs them nothing.
- Photographers who travel choose where they're working. For later shoots, distance is measured from that place; for "now" shoots, from a GPS fix under 15 minutes old (falling back to the chosen place). A photographer going to another state next week can set it before they leave.
- If every wave lapses, the booking is flagged (
dispatch_exhausted) and logged at WARN so the ops team assigns it by hand or refunds. A dead end is never silent.
Why not a queue or a matching engine. At pilot volume the whole thing is a few hundred rows a day. A timer and a conditional update are easier to reason about than a distributed scheduler, and they fail in ways we can debug from a log line.
2. Money that must never get stuck
Problem. The payment window gets closed halfway. The train enters a tunnel. The callback never reaches the browser, but the money has left the customer's account.
Solution.
- The booking is created unpaid (
REQUESTED). No photographer is contacted until the payment is confirmed, so we never promise work we haven't been paid for. - Two paths lead to the same state: the browser's confirm call and Razorpay's webhook. Both verify a signature (the webhook checks the raw request bytes, not the parsed JSON), and both are idempotent, so whichever arrives second changes nothing.
- Amounts always come from the server's own quote. A browser-supplied amount is never trusted.
- Unpaid bookings expire after 30 minutes, so an abandoned checkout doesn't hold a slot.
Paying photographers. Weekly bank transfers, not a payment-gateway payout yet:
- The photographer enters their bank account or UPI ID; ops checks it once against the bank proof from their application. Account numbers are encrypted at rest (AES-256-GCM), and changing them sends the account back for checking. Ops is emailed when details change, and the photographer is emailed when they're verified or sent back.
- A shoot becomes payable when the customer confirms delivery, or 48 hours after delivery if they say nothing. A problem the customer reports holds it until ops marks it sorted.
- Ops pays from a weekly list (or a bank bulk-upload file) and records each transfer's reference number. Recording checks the amount is still the one ops saw, so a late tip can't slip through unpaid.
3. Cancellations, refunds and no-shows
Problem. "Can I cancel?" has a different answer every ten minutes of the booking's life, and the answer has to be identical in the app, in the API, and in what support tells the customer on the phone.
Solution. One policy object, called from every path:
| When | Refund |
|---|---|
| Before payment | Nothing charged |
| Matching or confirmed | Full refund |
| Photographer on the way | Full refund minus a ₹299 travel fee |
| Photographer has arrived | Not cancellable in the app |
| Customer no-show (confirmed) | No refund |
The customer screen shows the exact amount before confirming. Ops can override with FULL, POLICY or NONE, which is recorded with the reason. Refunds are requested after the database transition, so a payment-provider outage leaves a visible "paid, cancelled, refund pending" state rather than a lost cancellation.
4. Location from a browser
Problem. The photographer app is a web page for the pilot. Browser geolocation is noisy, phones suspend background tabs, and a live location is sensitive data.
Solution.
- Every ping is suspect until checked: drop readings worse than 150 m accuracy, drop anything implying more than 150 km/h since the last fix, and store a trail point only every 25 m or 30 s.
- Cadence follows the job: every 60 s while idle and online (dispatch needs to know who is nearby), every 10 s while on the way (the customer's map should move).
- Privacy window: a customer can see the photographer's position only between on the way and arrived. Before and after, the tracking endpoint returns no position. Trails are deleted after 30 days.
- A screen wake lock is held while travelling, and the page says plainly that it must stay open.
- ETA comes from the Google Routes API (two-wheeler, traffic aware), cached for 60 s or 300 m of movement, with a straight-line estimate as the fallback.
iPhone. Safari only shows the location prompt when the request comes straight from a tap, so "Use my current location" calls the browser before any await, retries once at low accuracy if GPS is slow, and explains how to switch location back on if it was denied.
Open. A React Native app with background location, which removes the "keep this page open" compromise entirely.
5. Trust between two strangers
Problem. A stranger is going to meet another stranger in a public place, and money depends on what happens there.
Solution.
- One state machine, no shortcuts:
REQUESTED → MATCHING → ACCEPTED → EN_ROUTE → ARRIVED → SHOOTING → PREVIEWS_SENT → DELIVERED → PAID_OUT, with exitsCANCELLED,NO_SHOW,UNMATCHED,DISPUTED. Every transition is recorded with who did it and when. - The shoot only starts when the photographer types the customer's 4-digit code, so the paid clock can't start from a car park.
- A no-show needs proof of waiting: arrived, then 15 minutes, then the button appears. Timestamps decide, not whoever messages support first.
- Booking ids are guessable, so a booking answers only to its customer, its assigned photographer, and ops. Identity documents live in a separate private bucket behind links that expire in minutes.
6. Identity without passwords
Problem. Three kinds of people use the product: customers, photographers and the ops team. None of them should have a password we store.
Solution. Firebase Auth issues the token; the API verifies it and derives the role:
- phone sign-in →
CUSTOMER - phone that matches an approved photographer → also
PARTNER - verified Google address on the ops allow-list →
OPS
The web app never talks to the API directly: the browser calls /backend/* on the Next.js server, which forwards to the API. In local development that proxy signs requests as test users, so nothing sensitive reaches the browser and the whole flow works without a Firebase project.
7. Background work on a serverless runtime
Problem. Offers expire on a timer, unpaid bookings expire, photographers go offline when they stop reporting. Cloud Run throttles CPU between requests, so a plain @Scheduled task simply stops running when the service is idle.
Solution. The timer moved out of the app. Cloud Scheduler calls /api/v1/internal/tick every minute and /api/v1/internal/daily at night; the same code runs either way, and locally a plain in-app timer drives it. The endpoints sit behind Cloud Run IAM and a shared secret, and every step claims its work with a conditional update, so a retry or an overlap is harmless.
That one change means no instance has to stay awake, which is most of the hosting bill at pilot size. The API is still capped at one instance for now, because the booking assistant keeps chat history in memory. Moving that to the database is what unlocks scaling out.
8. Delivering the photographs
Problem. The product ends at the photographs, and for a long time it did not: the photographer pasted a Google Photos or Drive link and the customer chased it. That link can be changed, revoked or shared with anyone, we cannot tell whether the delivery actually happened, and a customer who is not signed in to the right Google account simply sees a permission wall. The one part of the shoot people will keep for years was the part we did not control.
Solution. Shootlo hosts the files itself, in a third private bucket kept apart from portfolios and identity documents.
- The photographer's browser uploads straight to Cloud Storage with a signed URL, one file at a time. Nothing large passes through the API, and a dropped connection on mobile data costs one file rather than the whole batch.
- Every upload link is written down first as a pending ticket. When the browser reports the file has landed, the API checks the object is really in the bucket before it records a row, so a row never promises a file that is not there. Tickets nobody used are swept nightly, which is what stops orphaned objects accumulating.
- Customers read the gallery through links that are signed for a few hours, so nothing is public and no link outlives the page it was opened on. Downloads are signed with a content disposition, so files save under their own names.
- There is no server-side zip. "Download all" fetches the files one after another from the browser, which costs us no CPU and no temporary storage.
- Galleries are deleted 90 days after delivery, and the customer's screen says the date. A bucket lifecycle rule repeats the same deadline, so the files go even if the nightly task stops running.
Pasting a link still works, and stays the fallback for a photographer with a hundred RAW files or a flaky connection. Previews are optional: a photographer can finish a shoot and send everything at delivery.
Delivered is not the end. The customer is asked "Got all your photos?", with Yes, I got everything or Something's missing and a note. Until they confirm, the photographer can still add or change files. A confirmation makes the payout due at once; a reported problem emails ops and the photographer and holds the payout; silence releases it after 48 hours. Ops sees previews, final files and the customer's answer in the booking tracker.
9. Growing past one city
Problem. The first version hard-coded one city: a centre and three rings. Opening a second city, pricing by state, and giving a local manager access to only their bookings all needed the city to be data, not code.
Solution.
- Cities are rows: a centre, three rings and a switch for "now" bookings. Ops adds them from a map. Every booking stores its city and state when it's created.
- Prices can be overridden per state, and applied in the one place quotes are calculated, so the charge, the photographer's payout, the fee and GST all agree.
- Popular spots have a limit on shoots at the same time. When one is full, the customer is offered the next free slot. The check runs under a per-place lock, and again at accept.
- Staff get a role (manager, customer support, marketing) and optionally a state or city. Permissions are checked on every endpoint, and a booking outside someone's area answers 404, as if it doesn't exist. Every change to money, prices, cities or people goes into an append-only audit log.
10. Telling people what happened
Problem. A marketplace is mostly waiting: for review, for payment, for files. Silence reads as "nothing is happening".
Solution.
- Emails go out through an email provider's HTTPS API (never SMTP on Cloud Run; see Lessons from production): application decisions with the reason, staff invites, payout details checked, transfers sent, delivery confirmed or a problem reported.
- Every message is logged with its status (sent, failed, not sent), so "did they get it?" is a lookup, not a guess. Without an API key nothing breaks; messages are logged as not sent.
- Ops can send announcements to photographers or customers, all India or one state, sent in small batches with no duplicates, so a refresh halfway can't email anyone twice.
- A photographer can be paused with a reason (they're emailed, and see it when they sign in), rejected with a reason, or removed by the owner.
11. What broke in production
A few failures taught us more than the features did. The full stories are in Building Shootlo: what broke.
| Symptom | Cause | Fix |
|---|---|---|
| Random 500s right after a migration | The connection pooler kept prepared-statement plans across deploys; SELECT * changed shape | prepareThreshold=0 in the driver |
| Scheduler calls answered 401 | The scheduler's Google token was read as a user sign-in token | Internal paths skip the bearer resolver; IAM plus a shared secret protect them |
| AI application reviewer killed for memory | Full-size phone photos sent to the model as base64 | Decode subsampled, shrink to 1024 px, cap the JVM's share of memory |
| Announcements to photographers failed | PHOTOGRAPHERS is 13 characters in a 12-character column | Widened the column; ops screens show the real error |
| New photographers showed ★ 5.0 and 80% | Ranking defaults were displayed as real numbers | "New" until 3 ratings and 5 offers; the photographer sees their progress |
12. Still open
| Gap | Why it matters | Plan |
|---|---|---|
| Live notifications | Emails cover decisions and money, but a photographer only sees an offer while the page is open | Web push, plus chat between customer and photographer during an active booking; WhatsApp is built and waiting for approval |
| Masked calling | Customer and photographer can't call each other, by design, so bookings don't move off the platform | Number masking once chat is in; a support number meanwhile |
| Automatic payouts | Ops sends weekly bank transfers and records each reference | Payment-gateway payouts once volume justifies it |
| Chat memory | Keeps the API at one instance | Move to the database, then scale out |
System design
Customer / photographer / ops browser
│ HTTPS, managed certificate
▼
Cloud Run: web (Next.js) public
│ /backend/* proxy, adds a Google identity token
▼
Cloud Run: api (Spring Boot) no public access: IAM invoker only
│ │ │
▼ ▼ ▼
Postgres Cloud Storage Vertex AI · Routes API · Razorpay · Firebase Auth
(3 private buckets)
Cloud Scheduler ──▶ /internal/tick every minutePrinciples we keep coming back to
- Nothing reaches the API without a Google identity token: only the web app and the scheduler have one. Even Razorpay's webhook arrives through the web app's proxy, and the API still checks its signature.
- Deterministic code decides; AI agents only suggest. Dispatch ranking, pricing and refunds are plain Java with unit tests. The Gemini agents draft messages, explain a ranking and pre-read applications, but a person still presses the button.
- Boring beats clever at pilot scale: two containers and one Postgres, no queue, no cache tier, no load balancer, no Kubernetes. Everything that must survive a restart is a row, and the whole thing runs for a low four-figure rupee bill each month. Each piece of infrastructure we might add later (a load balancer with a WAF, Pub/Sub, Redis for live positions, a read replica) has a number written down that has to be crossed first.
- Settings that differ between laptop and production are one switch each (
auth,payments,storage), and production refuses to start if any of them is still on the local option or a secret is missing.
Data, roughly. bookings (+ booking_events for the audit trail), payments and refunds, dispatch_offers, gallery_items with pending_uploads, photographers with photographer_documents and portfolio_items, applications, location_pings, agent_runs, cities and state_prices, payout_accounts and payouts, staff, messages and announcements, audit_log.
Testing. The rules that cost money or trust are plain classes with unit tests: pricing, dispatch ranking, cancellation policy, Razorpay signatures, upload rules, location filters, role derivation, production-readiness checks, service areas and the dispatch point, payout rules (when a shoot is due, held or paid), bank-detail validation, image shrinking and spot rules. Everything else is verified by a full click-through: book, pay, accept, travel, start, upload previews, deliver the gallery, download, rate, cancel, refund.
Changelog
- 27 Sep 2026: customers confirm delivery (or report something missing); photographers can keep adding files until they do, and the payout follows the answer. Ops tracker shows previews and final files, and every status is clickable.
- 27 Sep 2026: photographers are paid by weekly bank transfer; email for applications, payouts and deliveries; announcements; staff roles by state and city; pause, reject with a reason, remove. Fixed prepared-statement errors behind the connection pooler.
- 27 Sep 2026: bookings open across India; photographers choose where they're working.
- 26 Sep 2026: cities, state prices, shoot limits per spot, booking tracker and audit log; customers can book at their own location.
- 20 Sep 2026: hosted galleries: photographers upload the photographs to Shootlo and customers download them without an account, kept for 90 days.
- 20 Sep 2026: background timer moved to Cloud Scheduler; deployment simplified to Cloud Run with domain mapping, no load balancer.
- 20 Sep 2026: first version of this document.
- 16 Sep 2026: sign-in, payments, offer waves, tracking, uploads and the ops panel landed; deployment runbook written.