A scalable Pan-India Bus Booking REST API
Built with Django, Django REST Framework, PostgreSQL, Redis, Celery, Stripe, Twilio SendGrid, JWT Authentication, Docker, GitHub Actions, and Render.
- Overview
- Live Deployment
- Features
- Tech Stack
- Architecture
- Project Structure
- Authentication Flow
- Booking Flow
- Concurrency & Seat Locking
- API Documentation
- Environment Variables
- Installation
- Docker
- Testing
- CI/CD
- Monitoring
- Known Limitations
- Roadmap
- Author
Halt is a scalable Pan-India Bus Booking REST API built with Django and Django REST Framework.
The project follows a service-oriented architecture that separates business logic into services, serializers, and views for better maintainability, scalability, and testability.
Halt covers the full booking lifecycle end to end: authentication, trip search, deck-aware seat selection, boarding/drop point selection, per-passenger booking, Stripe-powered payments with Redis-backed seat holds to prevent overbooking, PDF ticket generation, transactional email, per-passenger cancellation with partial refunds, background job processing, and automated production monitoring — all containerized and deployed through a CI/CD pipeline.
https://halt-api.onrender.com/api/docs/
Note: The backend runs on Render's free tier, which spins down after periods of inactivity. The first request after idle time may take 30–60 seconds to respond while the service cold-starts. See Known Limitations for details.
- User registration
- Email OTP verification
- JWT authentication (access + refresh tokens)
- Login and logout
- JWT token blacklisting on logout
- Forgot password / reset password flow
- Login attempt rate limiting
- Custom user model
- Cities, operators, routes, and buses
- Route stops with ordered boarding/drop points
- Trip search by source, destination, and travel date
- Deck-aware seat maps (single-deck seater and double-deck sleeper layouts)
- Real-time seat availability
- Multi-step booking flow: seat selection → boarding/drop point → passenger details → payment
- Per-passenger booking (name, age, gender, seat assignment)
- Seat validation against real bus layout (seater/sleeper aware)
- Redis-backed seat holds to prevent two users booking the same seat simultaneously
- Booking reference generation
- Per-passenger cancellation with partial Stripe refunds
- Automatic booking status transition once all passengers on a booking are cancelled
- Booking history with payment summary (total paid, refunded, net paid)
- Stripe Checkout Sessions for payment collection
- Stripe webhook-driven booking confirmation (
checkout.session.completed,checkout.session.expired) - Idempotent webhook handling, safe against duplicate Stripe event delivery
- Automatic refund if a seat is lost to a race condition after payment succeeds
- Partial refunds on per-passenger cancellation
- Individual PDF ticket generation per passenger (WeasyPrint)
- Twilio SendGrid email integration for OTP, booking confirmation (with ticket attachments), and cancellation emails
- HTML email templates
- Celery for asynchronous email delivery and PDF generation
- Celery Beat scheduled job that automatically seeds upcoming trips daily, so trip inventory never runs dry without manual intervention
- Redis-based caching for trip search, trip details, and route stop lookups
select_related()/prefetch_related()for optimized database queries- Atomic, concurrency-safe seat booking using
select_for_update()
- Custom user model with hashed passwords
- Django password validators
- JWT authentication with token blacklisting
- Environment-based configuration, split by environment (
development/production) - Login attempt rate limiting
- Secrets managed entirely through environment variables, never committed
- Gunicorn production application server
- Supervisord process management (gunicorn + Celery worker + Celery Beat in one container)
- Dockerfile,
.dockerignore, entrypoint script - Managed PostgreSQL (Neon)
- Managed Redis (Upstash, TLS)
- Render deployment via prebuilt Docker image
- GitHub Actions CI/CD (test-gated deploys)
- Sentry error monitoring (Django + Celery)
| Category | Technology |
|---|---|
| Language | Python 3.14 |
| Framework | Django 5.x |
| API | Django REST Framework |
| Frontend | React, Vite, TanStack Query |
| Database | PostgreSQL (Neon) |
| Cache / Broker | Redis (Upstash) |
| Task Queue | Celery + Celery Beat |
| Authentication | SimpleJWT |
| Twilio SendGrid | |
| Payments | Stripe |
| PDF Generation | WeasyPrint |
| Production Server | Gunicorn + Supervisord |
| Containerization | Docker |
| CI/CD | GitHub Actions |
| Backend Deployment | Render |
| Frontend Deployment | Vercel |
| Monitoring | Sentry |
| API Testing | Bruno / Postman |
The backend and Celery worker/beat processes run together in a single Render web service (managed by Supervisord), sharing one Docker image built and pushed by CI/CD. Neon provides managed PostgreSQL, Upstash provides managed Redis (used for caching, the Celery broker/backend, and seat-hold locks), and Stripe/Twilio SendGrid handle payments and transactional email respectively.
halt-api/
├── src/
│ ├── authentication/
│ ├── bookings/
│ │ ├── holds.py # Redis seat-hold logic
│ │ ├── stripe_client.py # Stripe Checkout Session creation
│ │ ├── webhooks.py # Stripe webhook handling
│ │ ├── tickets.py # PDF ticket generation
│ │ └── tasks.py # Celery email tasks
│ ├── buses/
│ ├── cities/
│ ├── operators/
│ ├── routes/
│ ├── trips/
│ │ └── management/commands/
│ │ └── sync_available_seats.py
│ ├── core/
│ │ ├── cache/
│ │ ├── constants/
│ │ ├── email/
│ │ ├── settings/
│ │ │ ├── base.py
│ │ │ ├── development.py
│ │ │ └── production.py
│ │ ├── celery.py
│ │ └── urls.py
│ │
│ └── manage.py
│
├── docs/
├── Dockerfile
├── .dockerignore
├── entrypoint.sh
├── supervisord.conf
├── requirements.txt
├── .env.example
├── .gitignore
└── README.md
The application is organized into Django apps based on domain responsibility, not technical layer. Business logic lives in services.py files, kept separate from views and serializers, for testability and maintainability.
Register
│
▼
Generate OTP
│
▼
Send OTP via Twilio SendGrid (Celery task)
│
▼
Verify OTP
│
▼
Activate Account
│
▼
Login
│
▼
Generate JWT Tokens
│
▼
Protected APIs
Forgot Password
│
▼
Generate Reset Token
│
▼
Send Reset Email
│
▼
Validate Token
│
▼
Set New Password
Search Trip
│
▼
Select Seats (deck-aware seat map)
│
▼
Select Boarding / Drop Point
│
▼
Enter Passenger Details (per seat)
│
▼
Initiate Booking
│
▼
Validate Seats + Place Redis Hold (10 min TTL)
│
▼
Create Stripe Checkout Session
│
▼
Stripe Payment
│
▼
Stripe Webhook (checkout.session.completed)
│
▼
Re-validate Seats Still Available
│
▼
Confirm Booking + Release Hold
│
▼
Send Confirmation Email with PDF Tickets (Celery)
If a webhook confirms payment but the held seats were lost to a race condition, the booking is automatically marked EXPIRED and the payment is refunded — a paying customer is never left charged without a seat.
Two mechanisms work together to prevent two users from booking the same seat:
- Redis atomic holds (
SET NX EX) — the moment a booking is initiated, each selected seat is atomically locked in Redis with a 10-minute TTL. A second user attempting to select an already-held seat is rejected immediately, before ever reaching Stripe. - Database-level locking (
select_for_update()) inside an atomic transaction — used when the booking is finally confirmed via webhook, ensuring the seat count andSeatBookingrows are updated consistently even under concurrent load.
This was verified with a real two-browser concurrency test: one user holds a seat mid-checkout, a second user attempting to book the same seat is correctly rejected with a clear error.
Interactive API documentation is available through Swagger/OpenAPI.
https://halt-api.onrender.com/api/docs/
Documentation is also maintained in the docs/ directory, covering:
- Authentication
- Cities, Operators, Routes, Buses
- Trips
- Bookings (initiate, confirm, cancel)
- Payments & Webhooks
Create a .env file in the project root based on .env.example.
# Django
SECRET_KEY=
DEBUG=True
DJANGO_SETTINGS_MODULE=core.settings.development
ALLOWED_HOSTS=127.0.0.1,localhost
# Database
DB_NAME=
DB_USER=
DB_PASSWORD=
DB_HOST=
DB_PORT=
# Redis (cache, Celery broker/backend, seat holds)
REDIS_URL=
# JWT
ACCESS_TOKEN_LIFETIME=15
REFRESH_TOKEN_LIFETIME=1440
# Email (Twilio SendGrid)
SENDGRID_API_KEY=
DEFAULT_FROM_EMAIL=
# Stripe
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# CORS / CSRF
CORS_ALLOWED_ORIGINS=http://localhost:5173
CSRF_TRUSTED_ORIGINS=http://localhost:5173
# Frontend (used for Stripe redirect URLs)
FRONTEND_URL=http://localhost:5173
# Monitoring (production only)
SENTRY_DSN=Never commit
.envor production secrets to Git. In production, all secrets are managed through Render's environment variable dashboard.
git clone <repository-url>
cd halt-apipython -m venv .venvsource .venv/bin/activate.venv\Scripts\activatepip install -r requirements.txtcp .env.example .envFill in the required Django, PostgreSQL, Redis, Twilio SendGrid, and Stripe values.
cd src
python manage.py migratepython manage.py runservercelery -A core worker --loglevel=infocelery -A core beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseSchedulerThe development server will be available at:
http://127.0.0.1:8000/
Halt includes Docker configuration for containerized deployment, running gunicorn, the Celery worker, and Celery Beat together under Supervisord.
Dockerfile
.dockerignore
entrypoint.sh
supervisord.conf
docker build -t halt-api .docker run --env-file .env -p 8000:8000 halt-apiThe entrypoint runs database migrations and collectstatic before handing off to Supervisord, which starts and supervises gunicorn, the Celery worker, and Celery Beat — automatically restarting any of the three if they crash.
API testing can be performed using:
- Bruno
- Postman
- Insomnia
- cURL
Run Django tests with:
cd src
python manage.py testThe project also runs automated tests through GitHub Actions on every push, using ephemeral Postgres and Redis service containers.
GitHub Actions automates testing and deployment. The deploy pipeline only runs after tests pass on main, enforced via a workflow_run dependency (not just a race between two independent triggers).
Push to main
│
▼
Test Workflow
(ephemeral Postgres + Redis, migrations, test suite)
│
▼ (on success)
Deploy Workflow
│
▼
Build Docker Image
│
▼
Push to Docker Hub
│
▼
Trigger Render Deploy Hook
│
▼
Render pulls image and redeploys
The frontend (separate repository) deploys independently via Vercel's native Git integration — every push to main triggers an automatic build and deploy, with preview deployments for pull requests.
Halt uses Sentry for production error monitoring and application observability.
Sentry captures unhandled exceptions from both the Django API and Celery background tasks, providing:
- Production error tracking with full stack traces
- Request and transaction context
- Environment tagging (production only — not active in local development)
- Celery task failure monitoring
These are conscious, documented trade-offs rather than oversights — each is explained below.
-
Render free-tier cold starts. The backend spins down after periods of inactivity. The first request after idle time can take 30–60 seconds while the service wakes up. Subsequent requests are fast. This is a hosting-tier constraint, not an application performance issue.
-
Email deliverability without domain authentication. Transactional email (via SendGrid) uses Single Sender Verification rather than full Domain Authentication, since this demo project does not have a custom domain provisioned. Single Sender Verification allows sending to any recipient (unlike sandbox-only modes some providers default to), but without domain-level SPF/DKIM alignment, deliverability is weaker than a fully authenticated domain — emails may occasionally land in spam/junk folders depending on the recipient's mail provider. In a production deployment with a verified domain, Domain Authentication would be configured instead, improving inbox placement with no code changes required — only a provider-side configuration change.
-
Celery worker and Celery Beat run alongside gunicorn in a single container, managed by Supervisord, rather than as an independently scaled service. This is a deliberate choice to stay within Render's free-tier resource limits; Render's paid Background Worker tier would be the natural next step for a service under real production load, and would require only a small configuration change (splitting the Supervisord entries into separate services), not an architectural rewrite.
-
No pagination yet on list endpoints (trips, routes, bookings). Not a problem at current data volumes; a straightforward addition once dataset size warrants it.
-
Search filters by city name rather than city ID. Functionally correct, but not the most index-efficient approach at large scale — a documented, low-priority optimization.
-
No cache invalidation on admin-side edits to trips, routes, or buses — changes become visible once the relevant Redis cache TTL expires, rather than instantly.
- Authentication with email OTP verification
- Redis integration (cache, Celery broker, seat holds)
- Celery integration
- Celery Beat scheduled trip seeding
- Twilio SendGrid email integration with attachments
- Deck-aware seat selection (seater + sleeper)
- Boarding/drop point selection
- Per-passenger booking
- Stripe Checkout + webhook-confirmed payments
- Redis-backed seat holds (concurrency-safe booking)
- PDF ticket generation
- Per-passenger cancellation with partial refunds
- Gunicorn + Supervisord production process management
- Docker configuration
- CI/CD (test-gated deploys)
- Production deployment (Render + Vercel)
- Sentry error monitoring
- Pagination
- Filtering
- Rate limiting on public search endpoints
- Automated test coverage for booking/payment flow
- Cancel-by-ticket-reference (no login required)
Arun
Software Engineer
Full-stack developer working with Python, Django, React, PostgreSQL, Redis, Docker, CI/CD, and cloud deployment.
Built with Django & Django REST Framework
