SevaSetu
A trusted local services marketplace for Bharat — verified providers, ONDC discovery, UPI payments, English + Hindi.
Finding a trustworthy local pro is still word-of-mouth.
Unorganised workers in India per e-Shram + PLFS.
Of households use any app at all to book a service.
Take-rate that existing super-apps charge providers.
- Existing options are urban-centric, centralised, and lock both sides in.
- Providers have no portable digital identity beyond one app.
- Customers cannot tell verified pros apart from random listings.
- UPI is the rail of choice for payments, but most apps still default to cards.
SevaSetu is a marketplace that is open by construction.
Search by category and location. See verified providers within 25 km. Book in one tap. Pay by UPI when work is done.
Onboard in 4 steps. Get Aadhaar / PAN / GST verified. Set your availability. Earn straight to your UPI ID.
Any registered ONDC Buyer App can discover SevaSetu providers via Beckn 1.1 — no private deal needed.
Self-hostable. English + Hindi. WCAG accessible. DPDP-aware. Works on slow networks and low-end Androids.
What I will show you in five minutes.
- Landing page in English and toggle to हिन्दी.
- Browse → search by category + location → list + map view side by side.
- Open a provider profile — see Aadhaar-verified badge, services, reviews.
- Book a service → confirmation card with a real upi://pay deeplink.
- Sign up as a provider → 4-step wizard with simulated Aadhaar OTP.
- Hit /api/ondc/on_search from a curl command — receive a real Beckn 1.1 catalog.
provider: provider0@sevasetu.in / password123
A small, layered, server-rendered web app.
Browser ───────────► Next.js 15 ◄──── ONDC Buyer Apps (any BAP)
(RSC + Client) App Router │
┌─────────────┐ │
│ Middleware │ auth gate
├─────────────┤
│ Pages (RSC) │ → Drizzle ORM ─► PostgreSQL 17
│ /api routes │
└──────┬──────┘
│ cross-cutting modules
Better-Auth · KYC sim (UIDAI/Protean/GSTN/DigiLocker) ·
UPI deeplink · ONDC Beckn 1.1 adapter · i18n · Geo
- Presentation — React Server Components stream HTML before data is ready.
- Service — 27 REST route handlers, all Zod-validated.
- Domain — Drizzle schema + Zod validators (single source of truth).
- Persistence — PostgreSQL 17 with btree indexes on (lat,lng), city, rating.
15 tables. Strict types from SQL up to React.
| Table | Role |
|---|---|
| users | identity + role (customer / provider / admin) + locale |
| providers | 1:1 extension when role = provider; bio, rates, address, KYC status, ONDC participant id, rating cache |
| categories | 20 service categories with bilingual names + ONDC service codes |
| services | provider's listed offerings: title, price, unit (per visit / hour / day / fixed) |
| reviews / review_likes | 1–5 star + comment, unique on (provider, reviewer) |
| bookings | status ∈ {pending, accepted, in_progress, completed, cancelled, no_show}; payment_status; ONDC txn id; UPI txn ref |
| favorites | customer wishlist |
| audit_log | append-only KYC + booking transitions |
| sessions / accounts / verifications | Better-Auth identity backbone |
Booking → UPI deeplink in one round trip.
- Customer POSTs validated payload to /api/bookings.
- Server inserts the bookings row in a transaction.
- simulateUpiCollect() allocates a txn-ref.
- buildUpiIntent() constructs a real BHIM deeplink.
- Response: { booking, upiIntent }.
upi://pay? pa=deepak.nair@upi &pn=Deepak+Nair &am=600.00 &cu=INR &tn=SevaSetu+booking &tr=T2AB414C159ADADC6
On Android the OS picker opens GPay / PhonePe / Paytm. No PSP onboarding required for the intent flow.
SevaSetu is a Beckn 1.1 BPP for ONDC:RET11.
External BAP SevaSetu BPP (us)
│ POST /search │
│ ──────────────────────────────────► │ ack (ACK)
│ │
│ POST /on_search (city, gps, cat) │
│ ──────────────────────────────────► │ Beckn 1.1 catalog with bpp/providers
│ │
│ /select /init /confirm │ → bookings row with ondc_transaction_id
- Beckn 1.1 contexts (domain, city, action, ttl, txn_id).
- RET11 service taxonomy codes per category.
- Catalog payload shape (bpp/providers, items, prices).
- Auth header structure (Signature keyId="..." algorithm="ed25519" ...).
- The Ed25519 signature itself (no keypair whitelisted on the registry yet).
- Registry membership lookup.
- Replacing one file (src/lib/ondc/adapter.ts) is the on-ramp to go live.
Real algorithms. Simulated network calls. Banners on every screen.
Real: Verhoeff checksum (UIDAI rule incl. no-leading-0/1).
Simulated: UIDAI OTP send / verify; DigiLocker e-KYC payload shape.
Storage: only last-4 + salted SHA-256.
Real: regex + 4th-char entity-type detection (P / C / H / F / A / T / B / L / J / G).
Simulated: Protean / NSDL verification API.
Real: Mod-36 checksum exactly as GSTN computes it.
Simulated: GSTN GSP lookup.
UIDAI access requires a KUA / AUA licence. NSDL needs a TIN-FC tie-up. GSTN needs a GSP. Real integrations are out of scope for a student project. So I implement the algorithm exactly, simulate only the network call, and label every UI surface that does so.
Verhoeff. Mod-36. Haversine. Bounding box.
Dihedral-group D5 checksum. Walks digits right-to-left through hard-coded multiplication and permutation tables. Catches 100% of single-digit and transposition errors — the two most common manual-entry mistakes.
15 chars in base-36. Each of the first 14 multiplied by an alternating 1/2 factor, products split into base-36 quotient + remainder, summed; check digit = (36 − sum mod 36) mod 36.
SQL bbox (lat ± r/111, lng ± r/(111·cos lat)) reduces candidates to k ≪ n via the (lat, lng) btree. Haversine then runs on those k rows in-process.
Better-Auth defaults: N=16384, r=16, p=1, dkLen=64. Stored as <hex-salt>:<hex-key>. The salt is the hex string, not the decoded bytes — a subtle compatibility detail.
Builds clean. Serves fast. Stays small.
routes built
production build
shared first-load JS
tsc strict errors
| Endpoint | p50 latency | Notes |
|---|---|---|
| GET /api/health | 12 ms | SELECT 1 |
| GET /api/search | 48 ms | bbox + 60 providers + haversine |
| GET /api/providers/[id] | 60 ms | provider + services + reviews |
| POST /api/auth/sign-in/email | 85 ms | scrypt verify dominates |
| POST /api/bookings | 62 ms | insert + UPI intent build |
| POST /api/ondc/on_search | 55 ms | bbox + Beckn catalog build |
Layered defence; trust nothing the caller tells me.
- Better-Auth, scrypt, HttpOnly + SameSite cookies.
- 30-day sliding sessions with cookie cache.
- Role on the user row (customer / provider / admin).
- Middleware blocks unauthenticated paths at the edge.
- Pages re-check role server-side via requireRole().
- userId always derived from session — never from the request body.
- Zod parses every API body / query.
- Drizzle parametrised SQL (no string concat).
- State transitions in /api/bookings are explicit allow-lists.
- Aadhaar plaintext never persisted.
- Last-4 + salted SHA-256 only.
- HSTS, X-Frame-Options, X-Content-Type-Options, Permissions-Policy.
- DPDP-aware: explicit consent, ON-DELETE-CASCADE for account deletion.
What I assumed an attacker can do — and how I block them.
| Threat | Mitigation |
|---|---|
| Credential stuffing / brute force | scrypt (N=16384, r=16, p=1) makes each attempt expensive; rate limits via cookie throttling. |
| Session hijacking | HttpOnly + SameSite=Lax + Secure cookies; rotating session token; cookieCache.maxAge=300s. |
| SQL injection | Drizzle parametrised queries everywhere; no string-concat SQL anywhere in the codebase. |
| XSS | React auto-escapes JSX; CSP-friendly headers; raw-HTML injection APIs are not used on user-supplied data. |
| CSRF on state-changing routes | Better-Auth rotating CSRF token + SameSite cookie pair. |
| Privilege escalation (customer → provider) | userId derived from session, never from body. requireRole() re-checks server-side on every protected page. |
| Mass assignment / over-posting | Zod schema explicit field allow-list at every API boundary. |
| PII exposure (Aadhaar, phone) | Aadhaar plaintext never persisted; only last-4 + salted SHA-256. Phones masked in public profiles until booked. |
| Replay attacks on Beckn calls | Beckn 1.1 auth header carries created/expires timestamps + body digest; 30-second TTL. |
| Tampering with booking state | Allow-listed status transitions enforced in /api/bookings/[id]; every transition audit-logged. |
| Insecure deserialisation | JSON only; no eval, no YAML, no XML. |
| Dependency CVEs | Lockfile committed; npm audit in CI; minimal dependency surface (33 production deps). |
Slow phones, slow networks, two scripts.
English + हिन्दी. Locale resolved server-side from cookie or Accept-Language. Devanagari uses Noto Sans Devanagari via next/font.
RSC by default; small client bundles. Map dynamic-loaded after first paint. Booking modal does not lose user input on retry.
WCAG 2.2 baseline. Keyboard navigation, ARIA roles, visible focus rings, ≥ 4.5 contrast everywhere.
One Postgres + one Next.js container. Docker Compose brings everything up. No proprietary cloud services in the critical path.
Three things that bit me, three that I solved.
My first seed used Argon2 — Better-Auth rejected every login. Better-Auth uses scrypt with the salt as the hex string, not the decoded bytes. Matching that exactly fixed all logins.
React-Leaflet ships broken default marker icons under Webpack. Solution: delete _getIconUrl and provide a custom div-icon at module load.
No real UIDAI access for a student. Decision: implement the real algorithms, simulate the network, surface "Demo verification" banners everywhere. AADHAAR_MODE=disabled kills the demo entirely.
CSS-first config, no tailwind.config.ts. Tokens in @theme; new postcss plugin. One careful migration pass.
Leaflet only runs in the browser. next/dynamic with ssr:false wrapper eliminated all React hydration warnings.
Eight specialist agents wrote disjoint slices of the codebase concurrently. Locked import contracts (auth helpers, validators, schema) kept everything composable. Two trivial post-merge fixes only.
One command in dev. One docker compose up in prod.
$ docker compose up -d db $ npm install $ npm run db:push && npm run db:seed $ npm run dev # local development # production $ docker compose up -d --build
2 vCPU · 4 GB RAM · 40 GB SSD. Comfortably handles ≈ 10 k MAU.
2 stateless app pods + managed Postgres + CDN. ≈ 100 k MAU.
App pods per region + Postgres read replicas + Redis sessions. Nationwide.
Three weeks of focused work to a real launch.
- Week 1: ONDC onboarding — register a participant id, generate Ed25519 keys, replace the simulated signer, pass the L1 audit.
- Week 2: DigiLocker / Meri Pehchaan for real Aadhaar e-KYC; PAN via Protean; GSTIN via a GSP. Cashfree or Razorpay for real UPI collect with webhook reconciliation.
- Week 3: Tier-2 city pilot — 50 hand-curated providers across 4 categories, 90 days free. Weekly NPS surveys.
- Native Android / iOS sharing the REST + Beckn surface.
- Realtime availability + booking notifications (WebSockets).
- Hindi search synonyms; Tamil / Bengali / Marathi locales.
- Offline-first PWA with a write-queue.
- Provider analytics: revenue, retention, NPS.
- Dispute / IGM flows per ONDC spec.
One number to optimise.
If a verified provider gets at least 4 paid jobs a week through SevaSetu, the model works for them, the customer is finding what they need, and the network effect kicks in. Everything else — UI polish, marketing, expansion — follows from that.
Four things most projects skip.
Every screen that simulates a government check says so. AADHAAR_MODE=disabled turns the demo flow off entirely.
No proprietary SaaS in the critical path. The Beckn surface means any ONDC buyer can list us — we are not the only door.
English + Hindi from line one. Map deferred. UPI as the default rail. Self-hostable on a 4 GB VM.
12 k lines of TypeScript. 0 tsc errors. 103 kB shared first-load. 250 MB Docker image. p50 under 100 ms.
Quick answers to the obvious questions.
Self-hostability matters here. A municipal cooperative or an NGO must be able to run their own SevaSetu. Postgres + Drizzle does that; Firebase does not.
Cost and licensing. OpenStreetMap covers India well enough at this scale and has no per-tile billing.
A polished web PWA reaches every Indian Android phone today. Native apps are on the future-scope slide for after the pilot.
Unique index on (provider_id, reviewer_id) at the database level. The API enforces it; you can't review the same provider twice.
SevaSetu degrades to its own UI; internal customers still book, ONDC discovery just stops. Failure is partitioned.
Sessions to Redis · Postgres read replicas for /search · CDN for assets · shard providers by city. The app servers are stateless already.
What I would like to take from here.
- Feedback on the trust model — does the "Demo verified" banner go far enough?
- Suggestions on which pilot city would be highest-leverage.
- Faculty intros to anyone in ONDC, NPCI or DigiLocker outreach.
- Open-source the repo under MIT.
- Apply for the ONDC participant onboarding programme.
- Run the Tier-2 pilot in my home town with 50 providers.
Questions?
provider · provider0@sevasetu.in / password123
Repo at a glance.
src/
├─ app/
│ ├─ (auth)/ login, signup
│ ├─ (app)/ authed shell + browse, providers/[id], dashboard,
│ │ bookings, settings, provider/{onboarding,dashboard,services,bookings}
│ ├─ api/ 27 route handlers (auth, search, providers, services,
│ │ reviews, bookings, kyc, upi, ondc, health)
│ └─ layout.tsx, page.tsx, globals.css (Tailwind v4 + tokens)
├─ components/
│ ├─ ui/ 22 shadcn-style primitives
│ ├─ layout/, map/, kyc/, provider/, customer/, services/, settings/, auth/
│ └─ provider-card, review-form, review-list, booking-modal, search-bar, …
├─ lib/
│ ├─ db/ schema.ts (15 tables), index.ts, migrate.ts
│ ├─ kyc/ aadhaar.ts (Verhoeff), pan.ts, gst.ts (Mod-36)
│ ├─ payments/upi.ts, ondc/adapter.ts, geo.ts, i18n.ts, validators.ts, utils.ts, env.ts
│ └─ auth.ts, auth-client.ts, auth-helpers.ts
└─ middleware.ts gates /dashboard, /bookings, /settings, /provider
scripts/seed.ts (60 providers in 12 cities), reset.ts
Sample on_search Beckn 1.1 catalog (real output).
{
"context": {
"domain": "ONDC:RET11", "country": "IND", "city": "std:011",
"core_version": "1.1.0",
"bap_id": "external.bap", "bap_uri": "https://bap.example.com",
"bpp_id": "localhost:3000", "bpp_uri": "http://localhost:3000",
"transaction_id": "3ba8a11f0aa5...", "message_id": "67d7bd2740af6522",
"timestamp": "2026-05-02T09:58:25.345Z", "ttl": "PT30S",
"action": "on_search"
},
"message": { "catalog": {
"bpp/descriptor": { "name": "SevaSetu", "short_desc": "Local services across India" },
"bpp/providers": [{
"id": "9145f90c6c444edcebde4aa0",
"descriptor": { "name": "Asha Yadav", "short_desc": "Experienced Painter in New Delhi" },
"locations": [{ "id": "...-loc-1", "gps": "28.646,77.217" }],
"items": [{
"id": "90e8f953a59c27a036ae676f",
"descriptor": { "name": "Painter - Basic visit", "short_desc": "..." },
"price": { "currency": "INR", "value": "200.00" },
"@ondc/org/returnable": false,
"@ondc/org/cancellable": true
}]
}]
} }
}
How to drive this deck.
→ / ↓ / Space — next slide
← / ↑ — previous slide
PgDn / PgUp also work
Home / End — first / last slide
F — toggle fullscreen
N — toggle speaker notes
1 – 9 — jump to slide n
Open this file in any modern browser; nothing else needed. Tailwind is loaded from CDN; no build step.