Juniro API Architecture
Version: 0.1 (Draft) Last Updated: January 2026 Status: Pre-implementation
Out of Scope for v0.1
The following features are explicitly not included in this version:
- Cross-border bookings (US user booking India activity)
- Multi-country providers (single provider operating in both regions)
- Shared user accounts across regions
- Mobile push notifications
- Native mobile apps (iOS/Android)
- Multi-language support (Hindi, etc.)
- Provider payouts and settlements
- Waitlist functionality
- Recurring/subscription bookings
- Gift cards or third-party bookings
Table of Contents
- Overview
- Requirements
- Assumptions
- Architecture
- Tech Stack
- Data Residency
- API Design
- Authentication
- Payments
- Production Safety
- Roadmap
- Engineering Rules
1. Overview
Juniro is a marketplace platform connecting parents with children's activity providers (sports, arts, STEM, camps). The platform operates in two independent regions: United States and India.
Core Principle
Juniro is built as a multi-region marketplace with strict data separation between the US and India, to comply with data protection and payment regulations.
- Indian user data stays in India
- US user data stays in the US
- Only de-identified aggregates are shared globally
2. Requirements
2.1 Functional Requirements
| Category | Requirements |
|---|---|
| Marketplace | Parents discover, browse, and book children's activities |
| Providers | Create business profiles, list classes, manage schedules, receive bookings |
| Bookings | Real-time availability, booking confirmation, cancellation, rescheduling |
| Payments | Accept payments in USD (US) and INR (India), provider payouts |
| Reviews | Parents rate and review activities after attendance |
| Search | Filter by location, category, age, price, schedule |
| Notifications | Booking confirmations, reminders, provider updates |
2.2 Non-Functional Requirements
| Requirement | Target |
|---|---|
| Availability | 99.9% uptime |
| Latency | API response < 200ms (p95) |
| Data Residency | India data stays in India, US data stays in US |
| Scalability | Handle 10x traffic spikes (seasonal, back-to-school) |
| Security | SOC 2 compliance path, PCI DSS for payments |
| GDPR/DPDP | Right to deletion, data export, consent management |
2.3 Primary Product Flows
Flow 1: Discovery → Booking
────────────────────────────
Parent lands on homepage
→ Selects country (US/India)
→ Browses/searches activities
→ Views activity details
→ Selects session/schedule
→ Creates account (if new)
→ Enters child details
→ Completes payment
→ Receives confirmation
Flow 2: Provider Onboarding
────────────────────────────
Provider signs up
→ Completes business profile
→ Adds location(s)
→ Creates first activity
→ Sets pricing & schedule
→ Submits for verification
→ Goes live
Flow 3: Post-Booking
────────────────────────────
Booking confirmed
→ Parent receives reminder (D-1)
→ Child attends session
→ Parent receives review prompt
→ Parent submits review
→ Provider responds (optional)
3. Assumptions
3.1 Business Assumptions
| Assumption | Implication |
|---|---|
| US and India are independent markets | No cross-border bookings in v1 |
| Providers operate in one country only | Single home_region per provider |
| Parents book for their own children | No gifting or third-party bookings in v1 |
| English is primary language | Localization (Hindi, etc.) is post-MVP |
3.2 Technical Assumptions
| Assumption | Implication |
|---|---|
| Auth provider supports regional projects | Supabase Auth (confirmed) |
| Vercel has global edge network | Edge PoPs for static/CDN; data residency enforced by in-region DB/storage (GCP) |
| GCP has India region | Yes, asia-south1 (Mumbai) for Cloud SQL, GCS, Cloud Run |
| Same codebase, different deployments | No region-specific code branches |
| Mobile apps are future scope | API designed to support mobile clients |
3.3 Compliance Assumptions
| Assumption | Implication |
|---|---|
| India's DPDP Act | Treated as policy posture (data localization), not guaranteed blanket law |
| Child data is sensitive | Enhanced protection, parental consent required |
| Payment data handled by processors | Stripe/Razorpay are PCI compliant, we don't store card data |
4. Architecture
4.1 High-Level Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENTS │
├─────────────────────────────────────────────────────────────────────────────┤
│ Web (Next.js) Mobile (Future) Third-party (Future) │
│ • juniro.com • iOS App • Partner APIs │
│ • juniro.in • Android App • Webhooks │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ GLOBAL LOAD BALANCER │
│ (Geo-routing to regional stacks) │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌─────────────────┴─────────────────┐
▼ ▼
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ US REGION │ │ INDIA REGION │
│ (us-central1) │ │ (asia-south1) │
├─────────────────────────────────┤ ├─────────────────────────────────┤
│ ┌───────────┐ ┌───────────┐ │ │ ┌─ ──────────┐ ┌───────────┐ │
│ │ CDN │ │ WAF │ │ │ │ CDN │ │ WAF │ │
│ └─────┬─────┘ └─────┬─────┘ │ │ └─────┬─────┘ └─────┬─────┘ │
│ ▼ ▼ │ │ ▼ ▼ │
│ ┌───────────────────────────┐ │ │ ┌───────────────────────────┐ │
│ │ Regional LB │ │ │ │ Regional LB │ │
│ └─────────────┬─────────────┘ │ │ └─────────────┬─────────────┘ │
│ ┌─────────┴─────────┐ │ │ ┌─────────┴─────────┐ │
│ ▼ ▼ │ │ ▼ ▼ │
│ ┌────────┐ ┌─────────┐ │ │ ┌────────┐ ┌─────────┐ │
│ │Web Apps│ │ API │ │ │ │Web Apps│ │ API │ │
│ │(Next.js│ │ (Hono) │ │ │ │(Next.js│ │ (Hono) │ │
│ └────────┘ └────┬────┘ │ │ └────────┘ └────┬────┘ │
│ ┌──────────────────┼───┐ │ │ ┌──────────────────┼───┐ │
│ ▼ ▼ ▼ │ │ │ ▼ ▼ ▼ │ │
│ ┌──────┐ ┌───────┐ ┌───┐ │ │ │ ┌──────┐ ┌───────┐ ┌───┐ │ │
│ │Postgres│ │ Redis │ │GCS│ │ │ │ │Postgres│ │ Redis │ │GCS│ │ │
│ └──────┘ └───────┘ └───┘ │ │ │ └──────┘ └───────┘ └───┘ │ │
│ ┌──────────────────────┐ │ │ │ ┌──────────────────────┐ │ │
│ │ Supabase Auth │ │ │ │ │ Supabase Auth │ │ │
│ │ (US Project) │ │ │ │ │ (India Project) │ │ │
│ └──────────────────────┘ │ │ │ └──────────────────────┘ │ │
│ ┌──────────────────────┐ │ │ │ ┌──────────────────────┐ │ │
│ │ Stripe US │ │ │ │ │ Razorpay (primary) │ │ │
│ └──────────────────────┘ │ │ │ └──────────────────────┘ │ │
└─────────────────────────────┘ └─────────────────────────────────┘
│ │
│ ❌ NO REPLICATION │
└───────────────────────────────────────┘
4.2 Domain Model
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User │ │ Provider │ │ Activity │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ id │ │ id │ │ id │
│ auth_id │──────►│ user_id │◄──────│ provider_id │
│ email │ │ business_ │ │ title │
│ user_type │ │ name │ │ description │
│ home_region │ │ slug │ │ category │
│ created_at │ │ status │ │ age_min/max │
└─────────────┘ │ verified_at │ │ price │
│ └─────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Parent │ │ Location │ │ Session │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ id │ │ id │ │ id │
│ user_id │ │ provider_id │ │ activity_id │
│ first_name │ │ name │ │ location_id │
│ last_name │ │ address │ │ starts_at │
│ phone │ │ city │ │ capacity │
└─────────────┘ │ lat/lng │ │ enrolled │
│ └─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Child │ │ Review │◄──────│ Booking │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ id │ │ id │ │ id │
│ parent_id │ │ booking_id │ │ session_id │
│ first_name │ │ rating │ │ parent_id │
│ birth_date │ │ comment │ │ child_id │
└─────────────┘ └─────────────┘ │ status │
└──────┬──────┘
│
▼
┌─────────────┐
│ Payment │
├─────────────┤
│ id │
│ booking_id │
│ amount │
│ currency │
│ processor_id│
│ status │
└─────────────┘
4.3 Global Coordination Layer
A small global layer exists for non-PII coordination:
┌─────────────────────────────────────────┐
│ GLOBAL LAYER (non-PII only) │
├───────── ────────────────────────────────┤
│ Global Postgres │
│ ├── slug_reservations (uniqueness) │
│ └── rating_aggregates (counts/avgs) │
├─────────────────────────────────────────┤
│ Global Redis │
│ ├── flag:* (feature flags) │
│ └── config:* (app config) │
└─────────────────────────────────────────┘
│
│ Both regions read/write
┌────┴────┐
▼ ▼
US Regional India Regional
(PII data) (PII data)
What Goes Where:
| Data | Location | Reason |
|---|---|---|
| User profiles | Regional | PII |
| Children | Regional | Sensitive PII |
| Bookings | Regional | PII + payments |
| Payments | Regional | Financial data |
| Reviews (text) | Regional | May contain PII |
| Provider details | Regional | Business PII |
| Slug reservations | Global | Cross-region uniqueness |
| Rating aggregates | Global | Non-PII, improves UX |
| Feature flags | Global | Operational config |
| App config | Global | Operational config |
Critical Rule: The global layer stores non-PII only and must never contain identifiers that can link back to individuals (no emails, phones,
auth_user_id,user_id,child_id, orbooking_id). Slug reservations are for provider/organization slugs only, not user slugs.
5. Tech Stack
5.1 Final Tech Stack
| Layer | Technology | Rationale |
|---|---|---|
| Frontend | Next.js 14 (App Router) | SSR, RSC, great DX |
| Styling | Tailwind CSS + shadcn/ui | Consistent design system |
| State | TanStack Query | Server state management |
| Validation | Zod | Shared schemas (frontend + backend) |
| API Framework | Hono | Lightweight, portable (Vercel → GKE) |
| ORM | Prisma | Type-safe, great migrations |
| Database | PostgreSQL 15 | Reliable, full-featured |
| Cache | Redis (Upstash → Memorystore) | Sessions, rate limiting, cache |
| Auth | Supabase Auth | Regional projects, good DX |
| Payments (US) | Stripe | Industry standard |
| Payments (India) | Razorpay (primary) | UPI support, local compliance |
| Storage | GCS | Regional buckets |
| Hosting (MVP) | Vercel | Fast iteration |
| Hosting (Prod) | GCP (Cloud Run → GKE) | Scale, compliance |
| CDN | Cloud CDN | Edge caching |
| WAF | Cloud Armor | DDoS, security rules |
| Monitoring | Cloud Monitoring + Sentry | Metrics, errors |
| Analytics | PostHog | Product analytics |
| CI/CD | GitHub Actions + Cloud Build | Automated deployments |
5.2 Why Hono for API
The API is built as a standard Node.js server that can deploy to multiple targets:
juniro-api/
├── src/
│ ├── index.ts ← Standard HTTP server entry
│ ├── app.ts ← Hono app (framework-agnostic)
│ ├── routes/
│ ├── services/
│ └── db/
├── Dockerfile ← Ready for containers
├── vercel.json ← Thin adapter for Vercel
└── package.json
Migration Path:
- Now:
vercel deploy(uses vercel.ts adapter) - Later:
docker build+kubectl apply(uses Dockerfile)
No code changes required—just different deployment targets.
6. Data Residency
6.1 Country Selection vs Home Region
Critical Distinction:
| Concept | Purpose | Mutable? | Stored Where |
|---|---|---|---|
| Country Selection | Which catalog to browse | Yes (UI toggle) | Client state / cookie |
| home_region | Where user data lives | No (set at signup) | User record + JWT claim |
6.2 Routing Logic
READ PATHS (browse, search, view)
─────────────────────────────────
Route by: Country Selection (UI toggle)
User in US viewing India catalog:
→ Request goes to api.juniro.in
→ Returns India activities
→ User can browse, but CANNOT book
WRITE PATHS (profile, children, bookings, payments)
───────────────────────────────────────────────────
Route by: home_region (from JWT)
User with home_region=us viewing India catalog:
→ POST /me/children → api.juniro.com (US)
→ POST /me/bookings → BLOCKED (can't book India activities)
→ All PII stays in US regardless of UI toggle
6.3 Signup Flow
1. User selects country (US / India) on signup page
2. User completes signup form
3. Backend assigns home_region based on selection
4. home_region is written to:
- users.home_region column (database)
- JWT custom claim (auth token)
5. home_region is IMMUTABLE after signup
6.4 Region Migration Policy (MVP)
┌─────────────────────────────────────────────────────────────────┐
│ MVP RULE │
├─────────────────────────────────────────────────────────────────┤
│ home_region is PERMANENT │
│ │
│ User wants to switch regions? │
│ → Create new account in the new region │
│ → Old account remains (can be deleted on request) │
│ → No data migration between regions │
└─────────────────────────────────────────────────────────────────┘
6.5 Data Classification
| Data Type | Classification | Residency | Replication |
|---|---|---|---|
| User PII (name, email, phone) | Sensitive | Regional | None |
| Child profiles | Highly Sensitive | Regional | None |
| Auth credentials | Sensitive | Regional | None |
| Booking records | Sensitive | Regional | None |
| Payment data | Highly Sensitive | Regional | None |
| Reviews (raw text) | Standard | Regional | None |
| Ratings (aggregated) | Non-PII | Global | Allowed |
| Analytics (de-identified) | Non-PII | Global | Allowed |
7. API Design
7.1 Base URLs
US: https://api.juniro.com/v1
India: https://api.juniro.in/v1
7.2 Endpoints
Public Endpoints (no auth)
GET /health Health check
GET /activities List activities (paginated, filterable)
GET /activities/:slug Get activity details
GET /activities/:id/sessions Get available sessions
GET /providers/:slug Get provider public profile
GET /categories List categories
Auth Endpoints (minimal)
POST /auth/sync Sync Supabase user to Juniro DB
DELETE /auth/account Delete account (GDPR)
Note: All other auth operations (signup, login, logout, password reset) happen via Supabase SDK directly on the frontend.
Parent Endpoints (auth required)
GET /me Get current user profile
PATCH /me Update profile fields
GET /me/children List children
POST /me/children Add child
PATCH /me/children/:id Update child fields
DELETE /me/children/:id Remove child
GET /me/bookings List my bookings
POST /me/bookings Create booking (idempotency key required)
GET /me/bookings/:id Get booking details
POST /me/bookings/:id/cancel Cancel booking (state transition)
POST /me/bookings/:id/reschedule Reschedule booking (state transition)
POST /reviews Submit review
Provider Endpoints (auth required)
GET /provider/profile Get my provider profile
PATCH /provider/profile Update profile
GET /provider/locations List my locations
POST /provider/locations Add location
PATCH /provider/locations/:id Update location
DELETE /provider/locations/:id Remove location
GET /provider/activities List my activities
POST /provider/activities Create activity
PATCH /provider/activities/:id Update activity
DELETE /provider/activities/:id Delete activity
POST /provider/activities/:id/publish Publish (state transition)
POST /provider/activities/:id/unpublish Unpublish (state transition)
GET /provider/activities/:id/sessions List sessions
POST /provider/activities/:id/sessions Create session
PATCH /provider/sessions/:id Update session
POST /provider/sessions/:id/cancel Cancel session (state transition)
GET /provider/bookings List bookings for my activities
Payment Endpoints
POST /payments/checkout Create checkout session (idempotency key required)
POST /payments/webhook/stripe Stripe webhook (US)
POST /payments/webhook/razorpay Razorpay webhook (India)
7.3 HTTP Method Guidelines
| Method | Use For | Example |
|---|---|---|
| GET | Read resource | GET /me/bookings/:id |
| POST | Create resource | POST /me/bookings |
| POST | State transition / action | POST /me/bookings/:id/cancel |
| PATCH | Partial update (fields) | PATCH /me/children/:id |
| DELETE | Remove resource | DELETE /me/children/:id |
7.4 Response Format
// Success
{
"success": true,
"data": { ... },
"meta": { "page": 1, "limit": 20, "total": 100 }
}
// Error
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Human readable message",
"details": [...]
}
}
8. Authentication
8.1 Auth Flow
Supabase handles authentication directly. The Juniro API validates tokens locally and syncs users to our database.
User Frontend Supabase Juniro API
│ │ │ │
│ Click "Sign Up" │ │ │
│─────────────────►│ │ │
│ │ signUp(email, │ │
│ │ password, meta: │ │
│ │ {home_region}) │ │
│ │──────────────────►│ │
│ │ │ │
│ │ JWT + user │ │
│ │◄──────────────────│ │
│ │ │ │
│ │ POST /auth/sync │ │
│ │ Authorization: │ │
│ │ Bearer <jwt> │ │
│ │──────────────────────────────────────►│
│ │ │ │
│ │ │ Validate JWT │
│ │ │ locally (JWKS) │
│ │ │ Upsert user │
│ │ │ │
│ │ { user profile } │ │
│ │◄──────────────────────────────────────│
8.2 Auth Middleware
Security Note: We validate JWTs locally using Supabase's JWKS/JWT secret instead of calling
supabase.auth.getUser()on every request. This avoids a network call per request (latency + scaling bottleneck). We then fetch the user from our database to get authoritativehome_regionandrole.
Security Note:
home_regionis stored in our database as source of truth, not in Supabaseuser_metadata(which is user-writable). This prevents users from bypassing region enforcement.
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(
new URL(`${process.env.SUPABASE_URL}/auth/v1/keys`)
)
// JWT issuer/audience can vary by Supabase project/environment
// Configure via env vars (set in Phase 0)
const JWT_ISSUER = process.env.SUPABASE_JWT_ISSUER
?? `${process.env.SUPABASE_URL}/auth/v1`
const JWT_AUDIENCE = process.env.SUPABASE_JWT_AUDIENCE
?? 'authenticated'
export async function authMiddleware(c: Context, next: Next) {
const authHeader = c.req.header('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ success: false, error: { code: 'UNAUTHORIZED' } }, 401)
}
const token = authHeader.slice(7)
try {
// 1. Validate JWT locally (no network call to Supabase)
const { payload } = await jwtVerify(token, JWKS, {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
})
const authUserId = payload.sub as string
// 2. Fetch user from OUR database (source of truth for home_region, role)
const user = await db.user.findUnique({
where: { authId: authUserId },
})
if (!user) {
return c.json({ success: false, error: { code: 'USER_NOT_FOUND' } }, 401)
}
c.set('authUser', {
id: user.id,
authId: authUserId,
email: user.email,
homeRegion: user.homeRegion, // From DB, not JWT
role: user.role, // From DB, not JWT
})
return next()
} catch (err) {
return c.json({ success: false, error: { code: 'INVALID_TOKEN' } }, 401)
}
}
8.3 /auth/sync Endpoint
The sync endpoint is idempotent and creates/updates the user in our database.
Important: Do not rely on
user_metadatafrom the JWT. Accepthome_regionas an explicit body parameter and validate it server-side.
// POST /auth/sync
// Body: { home_region: "us" | "in" }
const syncBodySchema = z.object({
home_region: z.enum(['us', 'in']),
})
app.post('/auth/sync', zValidator('json', syncBodySchema), async (c) => {
const authHeader = c.req.header('Authorization')
const token = authHeader?.slice(7)
const body = c.req.valid('json')
// Validate JWT and extract claims
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.SUPABASE_JWT_ISSUER,
audience: process.env.SUPABASE_JWT_AUDIENCE,
})
const authUserId = payload.sub as string
const email = payload.email as string
// Check if user already exists
const existingUser = await db.user.findUnique({
where: { authId: authUserId },
})
if (existingUser) {
// User exists: update lastSeenAt only, ignore body.home_region
const user = await db.user.update({
where: { authId: authUserId },
data: {
email, // Sync email if changed
lastSeenAt: new Date(),
// NOTE: homeRegion is NOT updated (immutable)
},
})
return c.json({ success: true, data: user })
}
// New user: set home_region from body (validated)
const user = await db.user.create({
data: {
authId: authUserId,
email,
homeRegion: body.home_region, // From validated body, set once
role: 'parent', // Default role
createdAt: new Date(),
lastSeenAt: new Date(),
},
})
return c.json({ success: true, data: user })
})
Key behaviors:
home_regioncomes from request body (not JWT), validated with Zod- If user exists: ignore body
home_region(immutable after first write) - If new user: set
home_regionfrom body roledefaults toparent, can be upgraded toprovidervia separate flow- Idempotent: safe to call multiple times
8.4 Region Enforcement Middleware
// Helper: map region code to API domain
function getRegionApiDomain(region: 'us' | 'in'): string {
const domains = {
us: 'api.juniro.com',
in: 'api.juniro.in',
}
return domains[region]
}
function enforceHomeRegion(c: Context, next: Next) {
const user = c.get('authUser')
const serverRegion = process.env.REGION as 'us' | 'in'
if (isWriteMethod(c.req.method)) {
if (user.homeRegion !== serverRegion) {
const correctDomain = getRegionApiDomain(user.homeRegion)
return c.json({
success: false,
error: {
code: 'REGION_MISMATCH',
message: `Your account is registered in ${user.homeRegion.toUpperCase()}. ` +
`Write operations must go to ${correctDomain}`
}
}, 403)
}
}
return next()
}
8.5 Role Enforcement Middleware
type Role = 'parent' | 'provider' | 'admin'
function requireRole(...allowedRoles: Role[]) {
return (c: Context, next: Next) => {
const user = c.get('authUser')
if (!allowedRoles.includes(user.role)) {
return c.json({
success: false,
error: {
code: 'FORBIDDEN',
message: `This action requires one of: ${allowedRoles.join(', ')}`
}
}, 403)
}
return next()
}
}
// Usage:
app.get('/provider/profile', authMiddleware, requireRole('provider', 'admin'), ...)
app.post('/admin/users', authMiddleware, requireRole('admin'), ...)
9. Payments
9.1 Payment Processors
| Region | Primary | Fallback | Notes |
|---|---|---|---|
| US | Stripe | — | Industry standard |
| India | Razorpay | Stripe India | Razorpay has better UPI support; Stripe India is invite-only |
Data Residency Rule: India payment transaction records, webhook payloads, and logs must remain in India. Do not export India payment data to US-based analytics tools, logging services, or data warehouses. Use regional observability tools or de-identify before any global aggregation.
9.2 Payment State Machine
┌──────────────┐
│ CREATED │
└──────┬───────┘
│ checkout created
▼
┌──────────────┐
┌─────────│ PENDING │─────────┐
│ └──────────────┘ │
│ │
webhook: │ │ webhook:
payment_failed │ │ payment_success
▼ ▼
┌──────────────┐ ┌──────────────┐
│ FAILED │ │ PAID │
└──────────────┘ └──────┬───────┘
│ refund
▼
┌──────────────┐
│ REFUNDED │
└──────────────┘
Critical Rules:
- Only webhooks can transition payment to "paid". Never trust frontend redirect success.
- Webhook signature verification is mandatory before processing any webhook event.
9.3 Webhook Signature Verification
// POST /payments/webhook/stripe
app.post('/payments/webhook/stripe', async (c) => {
const sig = c.req.header('stripe-signature')
const body = await c.req.text()
let event: Stripe.Event
try {
// MUST verify signature before processing
event = stripe.webhooks.constructEvent(
body,
sig!,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return c.json({ error: 'Invalid signature' }, 400)
}
// Process verified event...
})
// POST /payments/webhook/razorpay
app.post('/payments/webhook/razorpay', async (c) => {
const sig = c.req.header('x-razorpay-signature')
const body = await c.req.text()
const expectedSig = crypto
.createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET!)
.update(body)
.digest('hex')
if (sig !== expectedSig) {
return c.json({ error: 'Invalid signature' }, 400)
}
// Process verified event...
})
9.4 Payment Table Schema
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID NOT NULL REFERENCES bookings(id),
amount_cents INT NOT NULL,
currency VARCHAR(3) NOT NULL, -- 'USD' or 'INR'
processor VARCHAR(20) NOT NULL, -- 'stripe' or 'razorpay'
processor_payment_id VARCHAR(255),
processor_checkout_id VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'created',
status_reason TEXT,
created_at TIMESTAMP DEFAULT NOW(),
pending_at TIMESTAMP,
paid_at TIMESTAMP,
failed_at TIMESTAMP,
refunded_at TIMESTAMP,
idempotency_key VARCHAR(255) UNIQUE,
CONSTRAINT valid_status CHECK (
status IN ('created', 'pending', 'paid', 'failed', 'refunded')
)
);
9.5 Future: Ledger & Payouts (Post-MVP)
Note: The
paymentstable handles customer payments. For provider payouts and financial reconciliation, we will need additional tables in a future phase:
-- Future: Ledger for double-entry accounting
CREATE TABLE transactions (
id UUID PRIMARY KEY,
type VARCHAR(20) NOT NULL, -- 'credit' | 'debit'
amount_cents INT NOT NULL,
currency VARCHAR(3) NOT NULL,
account_type VARCHAR(20) NOT NULL, -- 'provider' | 'platform' | 'customer'
account_id UUID NOT NULL,
reference_type VARCHAR(20), -- 'payment' | 'payout' | 'refund'
reference_id UUID,
created_at TIMESTAMP DEFAULT NOW()
);
-- Future: Provider payouts
CREATE TABLE payouts (
id UUID PRIMARY KEY,
provider_id UUID NOT NULL REFERENCES providers(id),
amount_cents INT NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL, -- 'pending' | 'processing' | 'completed' | 'failed'
processor_payout_id VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP
);
Do not cram payout logic into the payments table. Keep them separate.
10. Production Safety
10.1 Idempotency Keys
Problem: User double-clicks "Book Now" → two bookings, two charges
Solution: Client generates idempotency key, server dedupes
Required on:
POST /me/bookingsPOST /payments/checkout
// Frontend
const idempotencyKey = `${userId}-${sessionId}-${Date.now()}`
await fetch('/v1/me/bookings', {
headers: {
'Idempotency-Key': idempotencyKey,
},
})
10.2 Transactional Capacity Locking
Problem: 10 spots left, 15 people click "Book" simultaneously
Solution: Check + increment in single transaction with row lock + database constraint as defense-in-depth.
Database Constraint (defense-in-depth):
CREATE TABLE sessions (
id UUID PRIMARY KEY,
activity_id UUID NOT NULL REFERENCES activities(id),
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
capacity INT NOT NULL,
enrolled INT NOT NULL DEFAULT 0,
-- Defense-in-depth: DB will reject if enrolled > capacity
CONSTRAINT enrolled_within_capacity CHECK (enrolled >= 0 AND enrolled <= capacity)
);
Application Code:
// Type for the row lock query result
type SessionLock = { enrolled: number; capacity: number }
return await db.$transaction(async (tx) => {
// Lock the session row (prevents concurrent modifications)
// Only select needed columns to avoid schema drift issues
const rows = await tx.$queryRaw<SessionLock[]>`
SELECT enrolled, capacity FROM sessions
WHERE id = ${sessionId}::uuid
FOR UPDATE
`
const session = rows[0]
if (!session) {
throw new NotFoundError('Session not found')
}
if (session.enrolled >= session.capacity) {
throw new ConflictError('Session is full')
}
// Increment enrolled count
// Even if app logic fails, DB constraint prevents overbooking
await tx.session.update({
where: { id: sessionId },
data: { enrolled: { increment: 1 } },
})
// Create booking
return await tx.booking.create({ ... })
}, {
timeout: 5000,
})
Isolation Level Notes:
- Default
Read Committed+FOR UPDATErow lock is usually sufficient Serializableprovides stronger guarantees but reduces throughput- Start with
Read Committed; upgrade toSerializableonly if you observe anomalies - The
CHECKconstraint is the ultimate safety net regardless of isolation level
Why both row lock + constraint?
FOR UPDATElock prevents race conditions at the application levelCHECKconstraint is a safety net if app logic has bugs
10.3 Webhook-Driven Payment State
Problem: Frontend redirect says "success" but payment actually failed
Solution: Only webhooks can transition payment to "paid"
// POST /payments/webhook/stripe
app.post('/payments/webhook/stripe', async (c) => {
const event = stripe.webhooks.constructEvent(...)
switch (event.type) {
case 'checkout.session.completed':
await handlePaymentSuccess(event.data.object)
break
case 'payment_intent.payment_failed':
await handlePaymentFailed(event.data.object)
break
}
return c.json({ received: true })
})
10.4 Safety Summary
| Mechanism | Prevents |
|---|---|
| Idempotency Keys | Double bookings, double charges |
| Capacity Lock | Overbooking |
| Webhook State Machine | False success, missed failures |
11. Roadmap
11.1 Development Sequence
Phase 0: Foundation
───────────────────
□ Finalize domain model
□ Create database schema (Prisma)
□ Set up juniro-api repo (Hono + TypeScript)
□ Configure Supabase Auth (US project first)
□ Set up CI/CD (GitHub Actions → Vercel)
□ Deploy /health endpoint
Phase 1: Read Paths (Browse)
────────────────────────────
□ GET /activities (list, filter, paginate)
□ GET /activities/:slug (details)
□ GET /activities/:id/sessions (availability)
□ GET /providers/:slug (provider profile)
□ Search functionality
Phase 2: Auth & Profiles
────────────────────────
□ Auth middleware (verify Supabase JWT)
□ GET/PATCH /me (user profile)
□ Child management endpoints
□ Role-based access control
Phase 3: Write Paths (Booking)
──────────────────────────────
□ POST /me/bookings (with idempotency + locking)
□ Booking status state machine
□ Cancel/reschedule flows
□ Booking expiration job
Phase 4: Payments (US)
──────────────────────
□ Stripe integration
□ Webhook handling
□ Payment state machine
Phase 5: Provider Operations
────────────────────────────
□ Activity CRUD
□ Session/schedule management
□ Provider dashboard
Phase 6: Reviews & Ratings
──────────────────────────
□ Review submission
□ Rating aggregation (global layer)
Phase 7: India Region
─────────────────────
□ Supabase Auth (India project)
□ Deploy to India region
□ Razorpay integration
□ Test regional isolation
Phase 8: Admin Portal
─────────────────────
□ User management
□ Provider verification
□ Review moderation
Phase 9: Production Hardening
─────────────────────────────
□ Rate limiting
□ Security review
□ Load testing
□ Monitoring & alerting
Phase 10: GCP Migration
───────────────────────
□ Set up GCP projects
□ Cloud SQL + Cloud Run
□ GLB + Cloud CDN
□ DNS cutover
11.2 Infrastructure Phases
| Phase | Environment | Hosting | Database |
|---|---|---|---|
| MVP | Development | Vercel | Neon |
| Growth | Staging | Vercel | Neon |
| Scale | Production | Cloud Run | Cloud SQL |
| Enterprise | Production | GKE | Cloud SQL HA |
12. Engineering Rules
12.1 Must Do
- Same schema in both regional databases
- Same codebase, different env vars per region
- Validate all inputs with Zod
- Use parameterized queries (Prisma handles this)
- Log with request IDs for tracing
- Return consistent API response format
- Write integration tests for critical paths
- Use idempotency keys for booking/payment creation
- Use transactions with row locks for capacity management
- Drive payment status from webhooks only
12.2 Must Not Do
- Never replicate PII across regions
- Never store India payment data in US
- Never move child data across regions
- Never log secrets, tokens, or PII
- Never trust client-side data without validation
- Never skip auth middleware on protected routes
- Never use string concatenation for SQL
- Never trust payment redirect success (only webhooks)
Appendix A: Open Decisions
| Decision | Options | Status |
|---|---|---|
| ✅ Supabase | ||
| ✅ Hono | ||
| Background jobs | Cloud Tasks / Trigger.dev / BullMQ | Pending |
| Search | Postgres FTS / Algolia / Typesense | Start with Postgres |
Appendix B: Search Upgrade Path
Search is intentionally simple in v0.1. Here's the planned evolution:
Phase 1: Basic Filters (v0.1)
─────────────────────────────
- Indexed columns: category, city, age_min, age_max, price
- Basic WHERE clauses with AND/OR
- Pagination with LIMIT/OFFSET
- Good enough for < 10k activities per region
Phase 2: Full-Text Search (v0.2)
────────────────────────────────
- Postgres FTS with tsvector/tsquery
- Search across title, description
- Weighted ranking (title > description)
- Denormalized search table or materialized view for performance
Phase 3: Dedicated Search (if needed)
─────────────────────────────────────
- Migrate to Algolia, Meilisearch, or Typesense
- Triggers: > 100k activities, or need for:
- Typo tolerance
- Faceted search
- Geo-radius search
- Instant search (< 50ms)
Do not over-engineer search in v0.1. Start with indexed filters. Add FTS when users complain. Add Algolia when Postgres can't keep up.