Live demo:
https://api-gateway-ov0j.onrender.com(Singapore · Render free tier — first request may take ~30 s to cold-start)
Ride-hailing backend in Go. Seven microservices behind an NGINX gateway, event-driven via Aiven Kafka, geospatial driver matching with Upstash Redis GEO, Neon Postgres for persistence. Runs locally with Docker Compose; deployed on Render.
- Features
- Architecture
- Services
- Kafka Topics
- Quick Start
- API Reference
- End-to-End Flow
- Payment Flow
- State Machines
- Testing
Auth
- Registration is two steps:
POST /registersends a 6-digit OTP, account only created afterPOST /verify-registerconfirms it. Login is password-only — no OTP on every signin. - Forgot password goes through OTP too: request a code, verify it, set new password.
- HS256 JWTs: 15-min access tokens, 7-day refresh tokens, separate rider/driver roles.
Matching
POST /trips/requestpublishes a Kafka event. matching-service picks it up, finds the nearest available driver via RedisGEORADIUS, and sends an offer. Driver has 60 seconds to accept viaPOST /drivers/trips/{id}/respond.- Three vehicle tiers:
go(hatchback),x(sedan),xl(SUV). Fare is computed server-side with surge capped at 5×.
Trip lifecycle
- Once driver accepts, trip-service generates a 4-digit ride OTP stored in Redis. Driver can only start the trip after the rider reads them the code — cuts down on ghost rides.
- Live GPS tracking over WebSocket while the trip is in progress.
Payments
- Cash, UPI (QR scan or VPA collect), or Razorpay card. Browser-based checkout page with a WebSocket push on completion.
- Razorpay webhooks are idempotent — duplicate events do nothing.
- Driver earnings at
GET /payments/earnings?period=week|month|all.
Operations
- All inter-service events go through 5 Kafka topics. Low-traffic events (
trip.cancelled,rating.submitted,payment.completed) share theridego.eventstopic via anEventEnvelope{type, payload}wrapper. - No cross-service DB queries — each service owns its own schema.
flowchart TD
Client(["Client"])
subgraph Gateway["API Gateway · :8000"]
Nginx["nginx · reverse proxy + WS upgrade"]
end
subgraph UserSvc["User Service · :8081"]
U["/users"]
end
subgraph DriverSvc["Driver Service · :8082"]
D["/drivers"]
end
subgraph TripSvc["Trip Service · :8083"]
T["/trips"]
WS["/ws/trips/:id"]
end
subgraph MatchSvc["Matching Service"]
M["Kafka consumer · no HTTP"]
end
subgraph NotifSvc["Notification Service · :8084"]
N["/notifications"]
end
subgraph PaySvc["Payment Service · :8085"]
P["/payments"]
end
subgraph Data["Data Layer"]
PG[("PostgreSQL · 5 DBs")]
Redis[("Redis · GEO + locks")]
Kafka[["Aiven Kafka · 5 topics"]]
end
Client -->|HTTP / WS| Nginx
Nginx --> U & D & T & WS & N & P
U & N & P --> PG
D & T --> PG & Redis
U & D --> Redis
T & P -->|publish| Kafka
Kafka -->|consume| M & D & U & N & P & T
M --> Redis
M -->|publish| Kafka
POST /trips/request
└─► ride.requested ──► matching-service (Redis GEO nearest driver)
└─► ride.offered ──► notification-service (push to driver)
│
driver: POST /drivers/trips/{id}/respond {"accept": true}
│
driver-service publishes driver.assigned
├─► trip-service → assigns driver + generates 4-digit ride OTP (stored in Redis)
├─► driver-service → marks driver busy
└─► notification-service
├─ notifies driver: "New Trip Assigned"
└─ notifies rider: "Driver On the Way! Check app for OTP"
Rider: GET /trips/{id} (while status = DRIVER_ASSIGNED)
└─► response includes ride_otp (only visible to the rider)
rider tells the OTP to the driver verbally
Driver: PATCH /trips/{id}/start { "otp": "1234" }
└─► OTP validated against Redis → consumed on success → trip transitions to STARTED
PATCH /trips/:id/end
└─► trip.completed ──► payment-service, driver-service, notification-service (+ email to rider)
POST /trips/:id/rate
└─► ridego.events (rating.submitted) ──► driver-service, user-service, notification-service
payment completed
└─► ridego.events (payment.completed) ──► notification-service (+ email to rider)
| Service | Port | Database |
|---|---|---|
| api-gateway | 8000 | — |
| user-service | 8081 | users_db |
| driver-service | 8082 | drivers_db |
| trip-service | 8083 | trips_db |
| matching-service | — (no HTTP) | — |
| notification-service | 8084 | notifications_db |
| payment-service | 8085 | payments_db |
| PostgreSQL | 5433 | — |
| Redis | 6380 | — |
| Kafka (KRaft) | 9093 | — |
5 topics total (Aiven free tier limit). Low-traffic events share ridego.events through an EventEnvelope{type, payload} wrapper; consumers switch on type.
| Topic | Publisher | Consumers |
|---|---|---|
ride.requested |
trip-service | matching-service |
ride.offered |
matching-service | notification-service |
driver.assigned |
driver-service (accept) | trip-service, driver-service, notification-service |
trip.completed |
trip-service | payment-service, driver-service, notification-service |
ridego.events |
trip-service (trip.cancelled, rating.submitted), payment-service (payment.completed) |
matching-service, driver-service, user-service, notification-service |
Requires Docker and Docker Compose. Go 1.22+ only needed if building outside Docker.
git clone https://github.com/SaiKrishnaMulukutla/uber && cd uber
cp infra/.env.example infra/.env
# Fill in DATABASE_URL, REDIS_ADDR, KAFKA_*, JWT_SECRET, EMAIL_* — see table below
make upServices start in dependency order. Each service runs its own migrations on boot and connects to the cloud infra (Neon, Upstash, Aiven) — no local Postgres or Redis needed.
make logs # tail all service logs
make logs-trip # tail a specific service
make down # stop all containers
make clean # stop + wipe volumes| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | Neon PostgreSQL connection string (postgresql://...?sslmode=require) |
REDIS_ADDR |
yes | Upstash Redis URL (rediss://:<token>@<host>:6379) |
KAFKA_BROKERS |
yes | Aiven broker (<host>:25004) |
KAFKA_USERNAME |
yes | Aiven SASL username |
KAFKA_PASSWORD |
yes | Aiven SASL password |
KAFKA_CA_CERT |
yes | Base64-encoded Aiven project CA PEM (needed for TLS) |
JWT_SECRET |
yes | HS256 signing key — generate with openssl rand -base64 32 |
BREVO_API_KEY |
yes (prod) | Brevo transactional email API key (used on Render — no SMTP port needed) |
EMAIL_USER |
yes | Sender email address (also activates SMTP fallback in local dev) |
EMAIL_PASS |
local only | Gmail app password (SMTP fallback; Render blocks port 465/587) |
EMAIL_HOST |
no | SMTP host (default: smtp.gmail.com) |
EMAIL_PORT |
no | SMTP port (default: 465) |
PAYMENT_PROVIDER |
no | cash (default) or razorpay |
RAZORPAY_KEY_ID |
if razorpay | Razorpay API key ID |
RAZORPAY_KEY_SECRET |
if razorpay | Razorpay API key secret |
RAZORPAY_WEBHOOK_SECRET |
if razorpay | Webhook signing secret from Razorpay Dashboard |
INTERNAL_SECRET |
yes | Shared secret for matching-service → trip-service calls |
ALLOWED_ORIGINS |
no | Comma-separated WebSocket origin allowlist (empty = allow all) |
Render blocks ports 465/587, so Gmail SMTP only works locally. On Render, set
BREVO_API_KEY— Brevo sends over HTTP and has no port issues.
All requests go through http://localhost:8000. Authenticated endpoints need Authorization: Bearer <access_token>.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /users/register |
— | Step 1: validate input, send registration OTP |
| POST | /users/verify-register |
— | Step 2: verify OTP, create account, issue JWT |
| POST | /users/login |
— | Validate password, issue JWT directly |
| POST | /users/forgot-password |
— | Send OTP to email for password reset |
| POST | /users/reset-password |
— | Verify OTP and set new password |
| POST | /users/refresh |
— | Rotate access token using refresh token |
| GET | /users/{id} |
Bearer (rider) | Get own profile (IDOR protected) |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /drivers/register |
— | Step 1: validate input, send registration OTP |
| POST | /drivers/verify-register |
— | Step 2: verify OTP, create account, issue JWT |
| POST | /drivers/login |
— | Validate password, issue JWT directly |
| POST | /drivers/forgot-password |
— | Send OTP to email for password reset |
| POST | /drivers/reset-password |
— | Verify OTP and set new password |
| POST | /drivers/refresh |
— | Rotate access token |
| GET | /drivers/{id} |
Bearer (driver) | Get own profile |
| PATCH | /drivers/{id}/location |
Bearer (driver) | Update GPS location in Redis GEO |
| PATCH | /drivers/{id}/status |
Bearer (driver) | Set available / offline |
| GET | /drivers/nearby |
Bearer | Find available drivers within radius |
| POST | /drivers/trips/{tripId}/respond |
Bearer (driver) | Accept or reject a pending ride offer (60s window) |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /trips/estimate |
Bearer (rider) | Fare + duration estimate; accepts optional vehicle_type |
| POST | /trips/request |
Bearer (rider) | Request a ride; accepts optional vehicle_type (go/x/xl) |
| GET | /trips/history |
Bearer | Paginated trip history |
| GET | /trips/{id} |
Bearer | Trip details (participants only); includes ride_otp for rider when status is DRIVER_ASSIGNED |
| PATCH | /trips/{id}/assign |
X-Internal-Secret |
Internal — matching-service only |
| PATCH | /trips/{id}/start |
Bearer (driver) | Start trip; requires { "otp": "<4-digit code>" } body — OTP shown to rider on GET /trips/{id} |
| PATCH | /trips/{id}/end |
Bearer (driver) | End trip; computes fare by vehicle category |
| PATCH | /trips/{id}/cancel |
Bearer | Cancel trip; restores driver to GEO pool |
| POST | /trips/{id}/rate |
Bearer | Rate counterpart (1–5 stars) |
| POST | /trips/{id}/location |
Bearer (driver) | Push live GPS coordinate |
| WS | /ws/trips/{id}?token=<jwt> |
JWT query param | Real-time driver location stream |
| GET | /trips/surge |
Bearer (admin) | Get current surge multiplier |
| PATCH | /trips/surge |
Bearer (admin) | Set surge multiplier (1.0 – 5.0) |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /notifications/ |
Bearer | Paginated notification list |
| PATCH | /notifications/{id}/read |
Bearer | Mark notification as read |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /payments/history |
Bearer | Paginated payment history |
| GET | /payments/{tripId} |
Bearer | Payment by trip (participants only) |
| GET | /payments/checkout/{id} |
— | HTML checkout page — auto-selects cash/UPI/card tab |
| WS | /payments/ws/{id} |
— | WebSocket — pushes completion signal to checkout page |
| POST | /payments/{id}/confirm-cash |
Bearer (driver) | Driver confirms cash received → completes payment |
| POST | /payments/checkout/{id}/upi |
Checkout token | Send Razorpay UPI collect request to rider's VPA |
| POST | /payments/orders |
Bearer (rider) | Create Razorpay order → returns checkout_url, provider_order_id, key_id |
| POST | /payments/verify |
Checkout token | Submit Razorpay signature → completes payment |
| POST | /payments/webhook |
HMAC | Razorpay async backup confirmation |
| POST | /payments/simulate-success |
Bearer | Complete payment instantly (dev / test only) |
| GET | /payments/earnings |
Bearer (driver) | Earnings summary — ?period=week|month|all with daily breakdown |
BASE=http://localhost:8000
# 1. Register rider (2 steps — OTP verifies email before account is created)
# Step 1 — sends OTP to email
curl -s -X POST $BASE/users/register \
-H "Content-Type: application/json" \
-d '{"name":"Test Rider","email":"rider@test.com","phone":"+911111111111","password":"Pass123!"}'
# → { "message": "OTP sent to rider@test.com" }
# Step 2 — verify OTP (check inbox for 6-digit code)
curl -s -X POST $BASE/users/verify-register \
-H "Content-Type: application/json" \
-d '{"email":"rider@test.com","otp":"123456"}'
# → { "access_token": "...", "refresh_token": "...", "user": { ... } }
RIDER_TOKEN=<access_token from above>
# Register driver (same 2-step flow)
curl -s -X POST $BASE/drivers/register \
-H "Content-Type: application/json" \
-d '{"name":"Test Driver","email":"driver@test.com","phone":"+912222222222","password":"Pass123!","vehicle_type":"x","license_plate":"KA-01-AB-1234"}'
# → { "message": "OTP sent to driver@test.com" }
curl -s -X POST $BASE/drivers/verify-register \
-H "Content-Type: application/json" \
-d '{"email":"driver@test.com","otp":"123456"}'
DRIVER_TOKEN=<access_token from above>
DRIVER_ID=<driver.id from above>
# 2. Login (password-only — no OTP required)
curl -s -X POST $BASE/users/login \
-H "Content-Type: application/json" \
-d '{"email":"rider@test.com","password":"Pass123!"}'
# → { "access_token": "...", "refresh_token": "...", "user": { ... } }
RIDER_TOKEN=<access_token from above>
# 3. Driver goes online and sets location
curl -s -X PATCH $BASE/drivers/$DRIVER_ID/status \
-H "Authorization: Bearer $DRIVER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"available"}'
curl -s -X PATCH $BASE/drivers/$DRIVER_ID/location \
-H "Authorization: Bearer $DRIVER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"lat":12.9716,"lng":77.5946}'
# 4. Rider requests a trip (Kafka matching triggers automatically)
TRIP=$(curl -s -X POST $BASE/trips/request \
-H "Authorization: Bearer $RIDER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"pickup_lat":12.9716,"pickup_lng":77.5946,"drop_lat":12.9352,"drop_lng":77.6245,"payment_method":"upi"}')
TRIP_ID=$(echo $TRIP | jq -r '.trip_id')
# 5. Driver accepts the ride offer (within 60s window)
curl -s -X POST $BASE/drivers/trips/$TRIP_ID/respond \
-H "Authorization: Bearer $DRIVER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"accept":true}' | jq .
TRIP_DETAILS=$(curl -s $BASE/trips/$TRIP_ID \
-H "Authorization: Bearer $RIDER_TOKEN")
echo $TRIP_DETAILS | jq '{status,driver_id,ride_otp}'
# → status should be DRIVER_ASSIGNED; ride_otp is the 4-digit code the rider tells the driver
RIDE_OTP=$(echo $TRIP_DETAILS | jq -r '.ride_otp')
# 6. Driver starts and ends the trip (OTP required to start)
curl -s -X PATCH $BASE/trips/$TRIP_ID/start \
-H "Authorization: Bearer $DRIVER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"otp\":\"$RIDE_OTP\"}" | jq .status
curl -s -X PATCH $BASE/trips/$TRIP_ID/end \
-H "Authorization: Bearer $DRIVER_TOKEN" \
-H "Content-Type: application/json" -d '{}' | jq '{status,fare}'
# 7. Open checkout page in browser
PAYMENT=$(curl -s $BASE/payments/$TRIP_ID -H "Authorization: Bearer $RIDER_TOKEN")
PAYMENT_ID=$(echo $PAYMENT | jq -r '.id')
CHECKOUT_URL=$(curl -s -X POST $BASE/payments/orders \
-H "Authorization: Bearer $RIDER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"payment_id\":\"$PAYMENT_ID\"}" | jq -r '.checkout_url')
echo "Open in browser: $CHECKOUT_URL"
# UPI tab: enter success@razorpay as the VPA
# Card tab: use 4100 2800 0000 1007 (any expiry, any CVV)
# For cash trips — driver confirms after collecting cash:
# curl -s -X POST $BASE/payments/$PAYMENT_ID/confirm-cash \
# -H "Authorization: Bearer $DRIVER_TOKEN"
# Dev shortcut — skip Razorpay entirely:
# curl -s -X POST $BASE/payments/simulate-success \
# -H "Authorization: Bearer $RIDER_TOKEN" \
# -H "Content-Type: application/json" \
# -d "{\"payment_id\":\"$PAYMENT_ID\"}"
# 8. Rate, check notifications and payment
curl -s -X POST $BASE/trips/$TRIP_ID/rate \
-H "Authorization: Bearer $RIDER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"score":5,"comment":"Smooth ride"}' | jq .
sleep 2
curl -s $BASE/notifications/ \
-H "Authorization: Bearer $RIDER_TOKEN" | jq '[.notifications[].message]'
curl -s $BASE/payments/$TRIP_ID \
-H "Authorization: Bearer $RIDER_TOKEN" | jq '{status,amount}'PATCH /trips/{id}/end
└─► trip.completed (Kafka)
└─► payment-service: INSERT status=PENDING → AWAITING_CASH_CONFIRM
Rider opens checkout page → sees waiting state (no action needed)
Driver calls POST /payments/{id}/confirm-cash
└─► status=COMPLETED
└─► ridego.events {type:payment.completed} ──► notification-service
└─► WebSocket hub broadcast ──► checkout page shows success
PATCH /trips/{id}/end
└─► trip.completed (Kafka)
└─► payment-service: INSERT status=PENDING
POST /payments/orders { payment_id }
← { checkout_url, provider_order_id, upi_qr_url, key_id }
payment status → PROCESSING
(Razorpay Order + QR Code created; QR image hosted by Razorpay)
Rider opens checkout_url → UPI tab auto-selected
── Option A: QR Scan ────────────────────────────────────────────
Rider scans upi_qr_url QR with any UPI app and approves payment
└─► Razorpay fires qr_code.credited webhook
└─► POST /payments/webhook (HMAC verified)
└─► FindByProviderQRID → status=COMPLETED
└─► ridego.events {type:payment.completed} + WebSocket push → page shows success
── Option B: VPA Collect ────────────────────────────────────────
Rider enters UPI ID (e.g. name@paytm) → POST /payments/checkout/{id}/upi { vpa }
└─► Razorpay UPI collect push sent to rider's UPI app
Rider approves in app
└─► Razorpay fires payment.captured webhook
└─► POST /payments/webhook (HMAC verified)
└─► FindByProviderOrderID → status=COMPLETED
└─► ridego.events {type:payment.completed} + WebSocket push → page shows success
[1] PATCH /trips/{id}/end
└─► trip.completed (Kafka) → payment created status=PENDING
[2] GET /payments/{tripId}
← { id: payment_id, status: "PENDING", amount }
[3] POST /payments/orders { payment_id }
← { provider_order_id, amount, currency: "INR", key_id }
payment status → PROCESSING
[4] Frontend — Razorpay Checkout
new Razorpay({
key: key_id,
order_id: provider_order_id,
amount: amount * 100, // paise
currency: "INR",
handler(response) {
POST /payments/verify ← razorpay_payment_id + razorpay_order_id + razorpay_signature + payment_id
}
}).open()
[5] POST /payments/verify
← HMAC-SHA256(order_id|payment_id) verified against key_secret
← status=COMPLETED → ridego.events {type:payment.completed} → email + in-app notification
[6] Backup — Razorpay webhook (async, if browser closes before step 5)
POST /payments/webhook X-Razorpay-Signature: <hmac>
← idempotent: no-op if already COMPLETED
PAYMENT_ID=$(curl -s $BASE/payments/$TRIP_ID \
-H "Authorization: Bearer $RIDER_TOKEN" | jq -r '.id')
curl -s -X POST $BASE/payments/simulate-success \
-H "Authorization: Bearer $RIDER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"payment_id\":\"$PAYMENT_ID\"}" | jq '{status,amount}'# infra/.env
PAYMENT_PROVIDER=razorpay
RAZORPAY_KEY_ID=rzp_test_...
RAZORPAY_KEY_SECRET=...
RAZORPAY_WEBHOOK_SECRET=... # from Razorpay Dashboard → WebhooksThe webhook URL registers automatically on startup. To set it manually: Razorpay Dashboard → Webhooks → https://your-domain.com/payments/webhook, events payment.captured and qr_code.credited.
For local testing, ngrok http 8000 gives you a public HTTPS URL Razorpay can reach.
REQUESTED ──► DRIVER_ASSIGNED ──────────────────────────────► STARTED ──► COMPLETED
│ │ driver submits ride OTP │ └─ payment created, rating unlocked
│ │ (4-digit, shown to rider in └── /end
│ │ GET /trips/{id} response)
│ └── /cancel ──► CANCELLED
│ └─ driver restored to GEO pool; ridego.events {trip.cancelled}
└── auto-cancel after 10 min (no driver found) ──► CANCELLED
└─ trip-service poller publishes ridego.events {trip.cancelled}
cash: PENDING ──► AWAITING_CASH_CONFIRM ──► COMPLETED (driver confirms)
upi: PENDING ──► PROCESSING ──► COMPLETED (Razorpay QR scan or VPA collect webhook)
card: PENDING ──► PROCESSING ──► COMPLETED (Razorpay signature verified or webhook)
└────────► FAILED
Every path ends with ridego.events {type:payment.completed} → email + in-app notification to the rider.
fare = base + per_km × distance_km × surge_multiplier (surge capped at 5×)
| Category | Base | Per km | Typical vehicle |
|---|---|---|---|
go |
₹30 | ₹8/km | hatchback / compact |
x |
₹50 | ₹12/km | sedan (default) |
xl |
₹80 | ₹16/km | SUV / MUV |
Driver-supplied distance is trusted only if it's within 1.5× the haversine and under 200 km. Outside that, haversine wins.
# unit tests (no infra needed)
go test uber/shared/... uber/user-service/... uber/driver-service/... \
uber/trip-service/... uber/notification-service/... uber/payment-service/...
make build # build all services
bash test/test_all.sh # e2e (needs the stack running)test/postman_collection.json has the full flow with auto-captured tokens and IDs — import it and run the collection top to bottom.
Created with ❤️ by Mulukutla Sai Krishna