A robust and scalable backend template built with FastAPI, SQLAlchemy, and PostgreSQL. This template provides a solid foundation for building modern web applications with features like authentication, database integration, and API documentation.
- FastAPI: High-performance web framework
- SQLAlchemy: SQL toolkit and ORM
- PostgreSQL: Robust relational database
- Alembic: Database migration tool
- JWT Authentication: Secure authentication system with bcrypt password hashing
- Docker: Containerization for easy deployment
- Poetry: Dependency management
- Pre-commit hooks: Code quality tools
This application uses passlib with bcrypt as the hashing algorithm for secure password storage.
passlib- High-level password hashing library that provides a clean, consistent APIbcrypt- The actual cryptographic backend that performs the hashing
Think of passlib as the interface and bcrypt as the engine doing the work.
In app/core/security.py:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")This setup provides:
- Algorithm flexibility: Easy to upgrade to stronger algorithms without code changes
- Auto-rehashing: Automatically upgrades weak hashes to stronger ones
- Clean API: Simple
hash()andverify()methods - Future-proof: Can support multiple algorithms simultaneously
Important: We pin bcrypt to version 4.0.1 to maintain compatibility with passlib 1.7.4:
passlib = {extras = ["bcrypt"], version = "^1.7.4"}
bcrypt = "4.0.1"Newer versions of bcrypt (4.1+) have breaking changes that cause issues with passlib. This pinning ensures stability.
from app.core.security import get_password_hash, verify_password
# Hashing a password
hashed = get_password_hash("my_secure_password")
# Verifying a password
is_valid = verify_password("my_secure_password", hashed)Access tokens are created using python-jose with HS256 algorithm:
from app.core.security import create_access_token
token = create_access_token(subject=user.email)Token expiration is configured via ACCESS_TOKEN_EXPIRE_MINUTES in your .env file (default: 8 days).
backend/
├── alembic/ # Database migrations
├── app/
│ ├── api/ # API endpoints
│ │ └── api_v1/ # API version 1
│ │ └── endpoints/# API endpoint modules
│ ├── core/ # Core functionality and config
│ ├── db/ # Database models and sessions
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic models
│ ├── services/ # Business logic services
│ └── main.py # FastAPI application
├── .env # Environment variables (create from .env.example)
├── .env.example # Example environment variables
├── docker-compose.yml # Docker Compose configuration
├── Dockerfile # Docker configuration
├── pyproject.toml # Project dependencies
├── setup.sh # Setup script
└── README.md # This file
- Clone the repository
git clone https://github.com/yourusername/backend-template.git
cd backend-template- Run the setup script
chmod +x setup.sh
./setup.shThis will:
- Create a virtual environment and install dependencies
- Create necessary directories
- Create a
.envfile from.env.example - Run database migrations
- Start the application
poetry run uvicorn app.main:app --reload The API will be available at http://localhost:8000
API documentation will be available at http://localhost:8000/docs
To build and run the application using Docker:
docker compose up -d --buildTo shutdown the application using Docker:
docker compose downTo add a new package
poetry add <package_name>To remove a package
poetry remove <package_name>poetry run pytestCreate a new migration:
poetry run alembic revision --autogenerate -m "Description of changes"Apply migrations:
poetry run alembic upgrade headIf curious about working of alembic:
Great question! Let me explain how Alembic is configured and works in your codebase:
Alembic is a database migration tool that tracks and applies changes to your database schema over time. Think of it as "version control for your database."
alembic.ini - Main configuration file
- Points to the
alembic/directory - Sets up logging and other basic settings
alembic/env.py - The environment/runtime configuration
Let me show you the key parts:
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
# Import the model registry to ensure all models are loaded
from app.db.base_class import Base # Correctly import from base_class
import app.models.base # Import the base model aggregatorThis imports your settings and models.
target_metadata = Base.metadata
# Override sqlalchemy.url using discrete settings
database_url = (
f"postgresql://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}"
f"@{settings.POSTGRES_SERVER}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
)
config.set_main_option("sqlalchemy.url", database_url)Key points here:
target_metadata = Base.metadata- This tells Alembic to look at your SQLAlchemy models- The database URL is built from your
.envvariables (this is why changing the.envchanges which database it connects to)
from app.db.base_class import Base
from .user import User
from .conversation import Conversation
from .message import Message
from .file import FileThis file imports all your models. When alembic/env.py imports app.models.base, it loads all your model classes.
Each model defines a database table:
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(Text, unique=True, nullable=False)
name = Column(Text, nullable=True)
hashed_password = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationships
conversations = relationship("Conversation", back_populates="user", cascade="all, delete-orphan")
files = relationship("File", back_populates="uploaded_by_user", cascade="all, delete-orphan")poetry run alembic revision --autogenerate -m "Description of change"What happens:
- Alembic connects to your database (using
.envcredentials) - Reads the current database schema
- Compares it to your SQLAlchemy models (
app/models/*.py) - Detects differences (new tables, new columns, removed columns, etc.)
- Generates a migration file in
alembic/versions/
Migration file structure:
revision = 'abc123' # Unique ID
down_revision = 'xyz789' # Previous migration (creates a chain)
def upgrade() -> None:
# SQL commands to apply changes
op.create_table('users', ...)
def downgrade() -> None:
# SQL commands to revert changes
op.drop_table('users')poetry run alembic upgrade headWhat happens:
- Checks which migrations have been applied (stored in
alembic_versiontable in your DB) - Runs the
upgrade()function of each pending migration in order - Updates the
alembic_versiontable
poetry run alembic currentShows which migration is currently applied.
poetry run alembic historyShows all migrations and their chain.
poetry run alembic downgrade -1Reverts the last migration by running its downgrade() function.
alembic/versions/
└── [NEW_ID]_create_initial_tables.py # Just generated
This migration will create 4 tables:
users- User accountsconversations- Chat conversationsmessages- Messages within conversationsfiles- Uploaded PDF files
Relationships:
users (1) ─── (many) conversations
conversations (1) ─── (many) messages
conversations (1) ─── (many) files
users (1) ─── (many) files
| Command | What it does |
|---|---|
alembic revision --autogenerate -m "msg" |
Generate new migration |
alembic upgrade head |
Apply all pending migrations |
alembic current |
Show current migration version |
alembic history |
Show migration history |
alembic downgrade -1 |
Rollback last migration |
alembic downgrade base |
Rollback all migrations |
- Always change models first - Modify
app/models/*.py - Then generate migration - Run
alembic revision --autogenerate - Review the migration - Check the generated file is correct
- Apply to database - Run
alembic upgrade head - Commit both - Model changes + migration file to git
Without Alembic, you'd have to:
- Manually write SQL CREATE/ALTER statements
- Track which changes were applied to which database
- Coordinate schema changes across team members
- Risk database inconsistencies
With Alembic:
- Schema changes are versioned
- Can deploy to production safely
- Can rollback if needed
- Team stays in sync
Now you can run:
poetry run alembic upgrade headThis will create all your tables in Supabase! 🚀
Install pre-commit hooks:
poetry run pre-commit installThe application provides a comprehensive set of APIs for building a chat interface. All endpoints require authentication via the access_token header.
POST /api/v1/chat/
Send a message in a conversation. Creates a new conversation if conversation_id is not provided.
Headers:
access_token: JWT authentication token
Form Data:
user_message(required): The user's message textconversation_id(optional): UUID of existing conversationpdfs(optional): List of PDF files to uploadimages(optional): List of image files to upload
Response:
{
"conversation_id": "uuid",
"assistant_response": {
"reply_text_message": "string",
"should_trigger_workflow": false,
"workflow": []
},
"response_metadata": {},
"usage_metadata": {}
}POST /api/v1/chat/new-conversation
Create a new empty conversation.
Headers:
access_token: JWT authentication token
Request Body:
{
"title": "My New Chat",
"mode": "workflow"
}Both fields are optional. If not provided, defaults to title: "New Chat" and mode: null.
Response:
{
"id": "uuid",
"user_id": "uuid",
"title": "My New Chat",
"mode": "workflow",
"created_at": "datetime",
"updated_at": "datetime"
}GET /api/v1/chat/conversations
Retrieve all conversations for the authenticated user, sorted by most recent first.
Headers:
access_token: JWT authentication token
Query Parameters:
skip(optional, default: 0): Number of conversations to skip (pagination)limit(optional, default: 100, max: 100): Maximum conversations to return
Response:
[
{
"id": "uuid",
"user_id": "uuid",
"title": "string",
"mode": "string",
"created_at": "datetime",
"updated_at": "datetime",
"messages": [],
"files": []
}
]GET /api/v1/chat/conversations/{conversation_id}
Retrieve a specific conversation with all messages and files.
Headers:
access_token: JWT authentication token
Path Parameters:
conversation_id: UUID of the conversation
Response:
{
"id": "uuid",
"user_id": "uuid",
"title": "string",
"mode": "string",
"created_at": "datetime",
"updated_at": "datetime",
"messages": [
{
"id": "uuid",
"conversation_id": "uuid",
"sender": "user|assistant",
"message": "string or object",
"created_at": "datetime"
}
],
"files": []
}GET /api/v1/chat/conversations/{conversation_id}/messages
Get paginated messages for a specific conversation.
Headers:
access_token: JWT authentication token
Path Parameters:
conversation_id: UUID of the conversation
Query Parameters:
skip(optional, default: 0): Number of messages to skiplimit(optional, default: 50, max: 100): Maximum messages to return
Response:
{
"messages": [
{
"id": "uuid",
"conversation_id": "uuid",
"sender": "user|assistant",
"message": "string or object",
"response_metadata": {},
"usage_metadata": {},
"created_at": "datetime"
}
],
"total": 100,
"skip": 0,
"limit": 50
}PATCH /api/v1/chat/conversations/{conversation_id}
Update conversation details (e.g., title or mode).
Headers:
access_token: JWT authentication token
Path Parameters:
conversation_id: UUID of the conversation
Request Body:
{
"title": "New Title",
"mode": "workflow"
}Response:
{
"id": "uuid",
"user_id": "uuid",
"title": "New Title",
"mode": "workflow",
"created_at": "datetime",
"updated_at": "datetime",
"messages": [],
"files": []
}DELETE /api/v1/chat/conversations/{conversation_id}
Delete a conversation and all associated messages and files.
Headers:
access_token: JWT authentication token
Path Parameters:
conversation_id: UUID of the conversation
Response:
{
"message": "Conversation deleted successfully",
"conversation_id": "uuid"
}DELETE /api/v1/chat/conversations/{conversation_id}/messages/{message_id}
Delete a specific message from a conversation.
Headers:
access_token: JWT authentication token
Path Parameters:
conversation_id: UUID of the conversationmessage_id: UUID of the message to delete
Response:
{
"message": "Message deleted successfully",
"conversation_id": "uuid",
"message_id": "uuid"
}GET /api/v1/chat/conversations/{conversation_id}/files
Get all files uploaded in a specific conversation.
Headers:
access_token: JWT authentication token
Path Parameters:
conversation_id: UUID of the conversation
Query Parameters:
skip(optional, default: 0): Number of files to skiplimit(optional, default: 100, max: 100): Maximum files to return
Response:
[
{
"id": "uuid",
"conversation_id": "uuid",
"uploaded_by": "uuid",
"filename": "document.pdf",
"mime_type": "application/pdf",
"file_size": 1024000,
"page_count": 10,
"created_at": "datetime"
}
]DELETE /api/v1/chat/conversations/{conversation_id}/files/{file_id}
Delete a specific file from a conversation.
Headers:
access_token: JWT authentication token
Path Parameters:
conversation_id: UUID of the conversationfile_id: UUID of the file
Response:
{
"message": "File deleted successfully",
"conversation_id": "uuid",
"file_id": "uuid"
}GET /api/v1/chat/files/{file_id}/download
Download a file by its ID. Returns the file with appropriate headers for browser download.
Headers:
access_token: JWT authentication token
Path Parameters:
file_id: UUID of the file to download
Response:
- Returns binary file data with headers:
Content-Type: The file's MIME type (e.g.,application/pdf)Content-Disposition:attachment; filename="filename.pdf"Content-Length: File size in bytes
Usage Example (JavaScript):
// Fetch and trigger download
fetch(`/api/v1/chat/files/${fileId}/download`, {
headers: { 'access_token': token }
})
.then(response => response.blob())
.then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'filename.pdf';
a.click();
});All endpoints may return the following error responses:
- 401 Unauthorized: Invalid or missing access token
- 403 Forbidden: User not authorized to access the resource
- 404 Not Found: Resource not found
- 400 Bad Request: Invalid request parameters
- 500 Internal Server Error: Server error
Example error response:
{
"detail": "Invalid access token"
}This application includes a complete Razorpay subscription system for handling recurring payments.
The payment system is designed to be modular and reusable:
app/
├── services/
│ ├── razorpay_service.py # All Razorpay API calls (reusable)
│ └── subscription_service.py # Local DB operations & limits
├── schemas/
│ ├── payment.py # Payment request/response schemas
│ └── subscription.py # Plan & subscription schemas
└── api/v1/
├── subscriptions.py # Subscription endpoints
└── webhooks.py # Razorpay webhook handler
RAZORPAY_KEY_ID=rzp_test_xxxxx
RAZORPAY_KEY_SECRET=xxxxx
RAZORPAY_WEBHOOK_SECRET=xxxxx| Endpoint | Method | Description |
|---|---|---|
/api/v1/subscriptions/plans |
GET | List available plans |
/api/v1/subscriptions/create |
POST | Create Razorpay subscription |
/api/v1/subscriptions/verify |
POST | Verify payment signature |
/api/v1/subscriptions/current |
GET | Get user's subscription |
/api/v1/subscriptions/cancel |
POST | Cancel subscription |
/api/v1/webhooks/razorpay |
POST | Razorpay webhook handler |
┌─────────────────────────────────────────────────────────────────┐
│ Step 1: User clicks "Upgrade to Pro" │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ POST /api/v1/subscriptions/create │
│ • Validates plan exists │
│ • Creates subscription in Razorpay │
│ • Creates UserSubscription (status: "created") │
│ • Returns: { subscription_id, key_id, amount } │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Frontend: Razorpay Checkout │
│ • Opens checkout with subscription_id │
│ • User enters payment details (card/UPI) │
│ • User completes payment │
│ • Returns: { payment_id, subscription_id, signature } │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ POST /api/v1/subscriptions/verify │
│ • Verifies payment signature │
│ • Creates Payment entry (with user_id & subscription_id) │
│ • Sets subscription status to "active" │
│ • Unlocks Pro features immediately │
│ • Returns: { success: true, message: "..." } │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────┐
│ Webhooks Fire │
│ (May arrive in any │
│ order, async) │
└───────────────────────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ payment.authorized│ │ payment.captured │
│ (Optional) │ │ │
│ • Logging only │ │ • If exists: │
│ │ │ - Update status│
│ │ │ - Store entity │
│ │ │ • If not exists: │
│ │ │ - Create entry │
│ │ │ - No user/sub │
└──────────────────┘ └──────────────────┘
│ │
└───────────┬───────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ subscription. │ │ subscription. │
│ authenticated │ │ activated │
│ │ │ │
│ • Status: │ │ • Status: │
│ "authenticated"│ │ "active" │
│ │ │ • Update period │
│ │ │ dates │
│ │ │ • Idempotent │
└──────────────────┘ └──────────────────┘
│ │
└───────────┬───────────┘
│
▼
┌───────────────────────────┐
│ subscription.charged │
│ │
│ • Status: "active" │
│ • Update period dates │
│ • Link payment to sub │
│ (if no subscription_id) │
│ • Add to payments list │
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ invoice.paid │
│ │
│ • Store invoice entity in │
│ payment.invoice_details │
└───────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Razorpay automatically charges user at billing cycle │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────┐
│ Webhooks Fire │
│ (Async, may arrive in │
│ any order) │
└───────────────────────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ payment.captured │ │ subscription. │
│ │ │ charged │
│ • If exists: │ │ │
│ - Update status │ │ • Status: │
│ - Store entity │ │ "active" │
│ • If not exists: │ │ • Update period │
│ - Create entry │ │ dates │
│ - No user/sub │ │ • Link payment │
│ - Store entity │ │ to subscription │
│ │ │ • Add to payments │
└──────────────────┘ │ list │
│ └──────────────────┘
│ │
└───────────┬───────────┘
│
▼
┌───────────────────────────┐
│ invoice.paid │
│ │
│ • Store invoice entity in │
│ payment.invoice_details │
└───────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Payment fails during checkout or recurring charge │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────┐
│ payment.failed │
│ │
│ • If payment exists: │
│ - Update status: │
│ "failed" │
│ - Store payment entity │
│ with error details │
│ • If not exists: │
│ - Create entry │
│ - Status: "failed" │
│ - No user/sub linking │
│ - Store entity + errors │
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ subscription.pending │
│ (if retrying) │
│ │
│ • Status: "past_due" │
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ subscription.halted │
│ (if all retries fail) │
│ │
│ • Status: "halted" │
└───────────────────────────┘
First Payment:
- Created in
/verifyAPI withuser_idandsubscription_idlinked - Status:
"success" - Linked to subscription immediately
Recurring Payments:
- Created in
payment.capturedwebhook (withoutuser_id/subscription_id) - Status:
"success" - Linked to subscription in
subscription.chargedwebhook:- Finds payment by
razorpay_payment_id - Links if
subscription_idanduser_idareNone - Adds to
subscription.paymentscollection
- Finds payment by
Failed Payments:
- Created/updated in
payment.failedwebhook - Status:
"failed" - Stores entire payment entity with error details in
notes - No
user_id/subscription_idlinking
-
User initiates subscription (
POST /api/v1/subscriptions/create)- Creates subscription in Razorpay
- Creates local
UserSubscriptionentry with status"created" - Returns
subscription_idfor frontend
-
User completes Razorpay checkout
- Frontend opens Razorpay Checkout
- User enters payment details and completes payment
- Frontend receives:
razorpay_payment_id,razorpay_subscription_id,razorpay_signature
-
Verify payment (
POST /api/v1/subscriptions/verify)- Verifies payment signature
- Creates
Paymententry if it doesn't exist:- Links to user (
user_id) - Links to subscription (
subscription_id) - Status:
"success"
- Links to user (
- Sets subscription status to
"active"immediately - Unlocks Pro features right away
-
Webhooks arrive (async, may arrive in any order):
payment.authorized(optional): Only logging, no DB operationspayment.captured:- If payment exists: Updates status to
"success", stores payment entity innotes - If payment doesn't exist: Creates payment entry (without
user_id/subscription_id), stores payment entity innotes
- If payment exists: Updates status to
subscription.authenticated: Updates subscription status to"authenticated"subscription.activated: Updates subscription status to"active"(idempotent), updates period datessubscription.charged:- Updates subscription status to
"active" - Updates period dates
- Links payment to subscription (if payment has no
subscription_id/user_id) - Adds payment to
subscription.paymentscollection
- Updates subscription status to
invoice.paid: Stores entire invoice entity inpayment.invoice_details
-
Razorpay auto-charges user at billing cycle
- No frontend interaction needed
- Razorpay automatically processes payment
-
Webhooks arrive (async):
payment.captured:- If payment exists: Updates status to
"success", stores payment entity innotes - If payment doesn't exist: Creates payment entry (without
user_id/subscription_id), stores payment entity innotes
- If payment exists: Updates status to
subscription.charged:- Updates subscription status to
"active" - Updates period dates
- Links payment to subscription (if payment has no
subscription_id/user_id) - Adds payment to
subscription.paymentscollection
- Updates subscription status to
invoice.paid: Stores entire invoice entity inpayment.invoice_details
-
Payment fails (during checkout or recurring charge)
payment.failedwebhook fires:- If payment exists: Updates status to
"failed", stores payment entity with error details innotes - If payment doesn't exist: Creates payment entry with status
"failed"(withoutuser_id/subscription_id), stores payment entity with error details innotes
- If payment exists: Updates status to
-
Subscription status updates:
subscription.pending: Status →"past_due"(payment retrying)subscription.halted: Status →"halted"(all retries exhausted)
-
Payment Creation:
- First payment: Created in
/verifyAPI with full linking - Recurring payments: Created in
payment.capturedwebhook, then linked insubscription.charged - Failed payments: Created/updated in
payment.failedwebhook
- First payment: Created in
-
Subscription Activation:
- First payment: Activated immediately in
/verifyAPI (instant unlock) - Webhooks ensure eventual consistency
- First payment: Activated immediately in
-
Payment Linking:
subscription.chargedlinks payments that don't havesubscription_id/user_id- Payments are added to
subscription.paymentscollection
-
Data Storage:
- Payment entity stored in
payment.notes(frompayment.captured) - Invoice entity stored in
payment.invoice_details(frominvoice.paid)
- Payment entity stored in
| Event | Status | Description |
|---|---|---|
subscription.authenticated |
authenticated |
First payment authorized |
subscription.activated |
active |
Subscription is now live |
subscription.charged |
active |
Recurring payment successful |
subscription.pending |
past_due |
Payment failed, retrying |
subscription.halted |
halted |
All retries exhausted |
subscription.paused |
paused |
Subscription paused |
subscription.resumed |
active |
Resumed from pause |
subscription.cancelled |
cancelled |
Subscription cancelled |
subscription.completed |
completed |
All billing cycles done |
payment.authorized |
- | Payment authorized (logging only) |
payment.captured |
- | Payment captured successfully |
payment.failed |
- | Payment failed |
invoice.paid |
- | Invoice paid (stores invoice details) |
async function upgradeToPro() {
// 1. Create subscription
const res = await fetch('/api/v1/subscriptions/create', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ plan_name: 'pro' })
});
const { subscription_id, key_id } = await res.json();
// 2. Open Razorpay Checkout
const options = {
key: key_id,
subscription_id: subscription_id,
name: 'OnePDF',
handler: async function(response) {
// 3. Verify payment
await fetch('/api/v1/subscriptions/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
razorpay_payment_id: response.razorpay_payment_id,
razorpay_subscription_id: response.razorpay_subscription_id,
razorpay_signature: response.razorpay_signature
})
});
// Success! Refresh user state
alert('Welcome to Pro!');
}
};
new Razorpay(options).open();
}-
Create plan in Razorpay Dashboard
- Go to Subscriptions → Plans → Create Plan
- Set name, amount (₹499), period (monthly)
- Copy the
plan_id
-
Update database with plan ID
poetry run python seed_plans.py pro plan_xxxxx
-
Configure webhook in Razorpay Dashboard
- Settings → Webhooks → Add webhook
- URL:
https://your-domain.com/api/v1/webhooks/razorpay - Events: Select all
subscription.*events - Copy webhook secret to
.env
-
Run migrations
poetry run alembic upgrade head
- subscription_plans: Plan definitions (free, pro, enterprise)
- user_subscriptions: User's active subscription
- payments: Payment history
MIT