A payment platform without a feedback mechanism is a platform building blind. You can guess what developers want. You can read industry reports. But until you give developers a way to tell you "I need X" and vote on each other's requests, you are making product decisions in a vacuum.
In Session 035, we built a complete feature request module for 0fee.dev. Four database tables, seven statuses, four categories, twenty API routes, upvoting, commenting, subscribing, duplicate merging, and priority levels. It took one session to build what most teams would scope as a multi-sprint project.
The Data Model: Four Tables
The feature request system is backed by four tables that capture the complete lifecycle of a request from submission to resolution:
python# models/feature_request.py
class FeatureRequest(Base):
__tablename__ = "feature_requests"
id = Column(String, primary_key=True, default=generate_id)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
category = Column(String(50), nullable=False) # bug, feature-request, documentation, performance
status = Column(String(50), default="open")
priority = Column(String(10), nullable=True) # P0, P1, P2, P3
merged_into_id = Column(String, ForeignKey("feature_requests.id"), nullable=True)
admin_response = Column(Text, nullable=True)
upvote_count = Column(Integer, default=0) # Denormalized for fast sorting
comment_count = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
resolved_at = Column(DateTime, nullable=True)
# Relationships
user = relationship("User", back_populates="feature_requests")
upvotes = relationship("FeatureUpvote", back_populates="request", cascade="all, delete-orphan")
comments = relationship("FeatureComment", back_populates="request", cascade="all, delete-orphan")
subscribers = relationship("FeatureSubscriber", back_populates="request", cascade="all, delete-orphan")
class FeatureUpvote(Base): __tablename__ = "feature_upvotes" BLANK id = Column(String, primary_key=True, default=generate_id) request_id = Column(String, ForeignKey("feature_requests.id"), nullable=False) user_id = Column(String, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime, server_default=func.now()) BLANK # Unique constraint: one upvote per user per request __table_args__ = (UniqueConstraint("request_id", "user_id"),) BLANK
class FeatureComment(Base): __tablename__ = "feature_comments" BLANK id = Column(String, primary_key=True, default=generate_id) request_id = Column(String, ForeignKey("feature_requests.id"), nullable=False) user_id = Column(String, ForeignKey("users.id"), nullable=False) body = Column(Text, nullable=False) is_admin = Column(Boolean, default=False) created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, onupdate=func.now()) BLANK
class FeatureSubscriber(Base): __tablename__ = "feature_subscribers" BLANK id = Column(String, primary_key=True, default=generate_id) request_id = Column(String, ForeignKey("feature_requests.id"), nullable=False) user_id = Column(String, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime, server_default=func.now()) BLANK __table_args__ = (UniqueConstraint("request_id", "user_id"),) ```
The denormalized upvote_count and comment_count on the main table deserve explanation. We could compute these with a JOIN or subquery on every list request, but feature request lists are sorted by vote count. Denormalizing means we can ORDER BY upvote_count DESC without touching the upvotes table on every query.
Seven Statuses
Feature requests move through a defined lifecycle:
| Status | Meaning | Who Sets It |
|---|---|---|
open | Newly submitted, awaiting review | System (default) |
planned | Accepted and scheduled for development | Admin |
in-progress | Currently being built | Admin |
completed | Shipped and available | Admin |
backlog | Acknowledged but not prioritized | Admin |
duplicate | Merged into another request | Admin |
declined | Not planned, with explanation | Admin |
The state transitions are validated:
python# services/feature_request.py
VALID_TRANSITIONS = {
"open": ["planned", "in-progress", "backlog", "duplicate", "declined"],
"planned": ["in-progress", "backlog", "declined"],
"in-progress": ["completed", "planned", "backlog"],
"backlog": ["planned", "in-progress", "declined"],
"duplicate": [], # Terminal state
"completed": [], # Terminal state
"declined": ["open"], # Can be reopened
}
async def update_request_status(
request_id: str,
new_status: str,
admin_response: str = None,
) -> FeatureRequest:
request = await get_request_or_404(request_id)
valid_next = VALID_TRANSITIONS.get(request.status, [])
if new_status not in valid_next:
raise HTTPException(
status_code=400,
detail=f"Cannot transition from '{request.status}' to '{new_status}'. "
f"Valid transitions: {valid_next}"
)
request.status = new_status
if admin_response:
request.admin_response = admin_response
if new_status == "completed":
request.resolved_at = datetime.utcnow()
await db.commit()
# Notify subscribers
await notify_subscribers(request, new_status)
return requestduplicate and completed are terminal states -- once a request is marked as shipped or merged, it cannot transition further. declined can be reopened back to open, because sometimes a request that was premature becomes relevant later.
Four Categories
Every feature request must be categorized on submission:
pythonCATEGORIES = {
"bug": "Something is broken or not working as expected",
"feature-request": "A new capability or enhancement",
"documentation": "Missing, incorrect, or unclear documentation",
"performance": "Speed, latency, or resource usage issues",
}
class FeatureRequestCreate(BaseModel):
title: str = Field(min_length=10, max_length=200)
description: str = Field(min_length=30)
category: str = Field(pattern="^(bug|feature-request|documentation|performance)$")We considered more categories (security, integration, ui) but decided that four covers the vast majority of requests. A developer reporting a security issue should use the bug category with a "security" tag. Keeping categories narrow prevents decision paralysis during submission.
The 20 API Routes
The feature request system exposes 20 routes, split between public (authenticated user) and admin endpoints:
Public Routes (12)
python# Public feature request routes
@router.post("/feature-requests") # Create a request
@router.get("/feature-requests") # List all (with filters)
@router.get("/feature-requests/{id}") # Get single request
@router.patch("/feature-requests/{id}") # Edit own request (if open)
@router.delete("/feature-requests/{id}") # Delete own request (if open)
# Upvoting
@router.post("/feature-requests/{id}/upvote") # Upvote a request
@router.delete("/feature-requests/{id}/upvote") # Remove upvote
# Comments
@router.get("/feature-requests/{id}/comments") # List comments
@router.post("/feature-requests/{id}/comments") # Add comment
@router.patch("/feature-requests/{id}/comments/{cid}") # Edit own comment
@router.delete("/feature-requests/{id}/comments/{cid}") # Delete own comment
# Subscribing
@router.post("/feature-requests/{id}/subscribe") # Subscribe to updates
@router.delete("/feature-requests/{id}/subscribe") # UnsubscribeAdmin Routes (8)
python# Admin feature request routes
@router.patch("/admin/feature-requests/{id}/status") # Change status
@router.patch("/admin/feature-requests/{id}/priority") # Set priority
@router.post("/admin/feature-requests/{id}/merge") # Merge duplicate
@router.post("/admin/feature-requests/{id}/respond") # Official response
@router.get("/admin/feature-requests/stats") # Aggregate stats
@router.get("/admin/feature-requests/by-category") # Breakdown by category
@router.get("/admin/feature-requests/by-status") # Breakdown by status
@router.patch("/admin/feature-requests/{id}/category") # RecategorizeThe separation matters. Public routes let developers interact with the system. Admin routes let us manage and triage.
Listing With Filters and Sorting
The list endpoint supports comprehensive filtering:
python@router.get("/feature-requests")
async def list_feature_requests(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
category: str = Query(None),
status: str = Query(None),
sort: str = Query("newest"), # newest, oldest, most-voted, most-commented
search: str = Query(None),
my_requests: bool = Query(False),
current_user: User = Depends(get_current_user),
):
query = select(FeatureRequest).where(
FeatureRequest.merged_into_id.is_(None) # Exclude merged requests
)
if category:
query = query.where(FeatureRequest.category == category)
if status:
query = query.where(FeatureRequest.status == status)
if my_requests:
query = query.where(FeatureRequest.user_id == current_user.id)
if search:
query = query.where(
or_(
FeatureRequest.title.ilike(f"%{search}%"),
FeatureRequest.description.ilike(f"%{search}%"),
)
)
# Sorting
sort_map = {
"newest": FeatureRequest.created_at.desc(),
"oldest": FeatureRequest.created_at.asc(),
"most-voted": FeatureRequest.upvote_count.desc(),
"most-commented": FeatureRequest.comment_count.desc(),
}
query = query.order_by(sort_map.get(sort, FeatureRequest.created_at.desc()))
# Pagination
total = await db.scalar(select(func.count()).select_from(query.subquery()))
results = await db.scalars(
query.offset((page - 1) * per_page).limit(per_page)
)
return {
"items": results.all(),
"total": total,
"page": page,
"per_page": per_page,
"pages": ceil(total / per_page),
}Merged requests are excluded from the default listing. If request #42 was merged into request #17, only #17 appears. The merge target's upvote count includes votes from merged duplicates.
Duplicate Merging
One of the most useful admin operations is merging duplicate requests. When two developers independently request the same feature, we merge them:
python@router.post("/admin/feature-requests/{id}/merge")
async def merge_request(
id: str,
data: MergeRequest, # { "target_id": "..." }
admin: User = Depends(require_admin_role),
):
source = await get_request_or_404(id)
target = await get_request_or_404(data.target_id)
if source.id == target.id:
raise HTTPException(400, "Cannot merge a request into itself")
if source.status == "duplicate":
raise HTTPException(400, "Request is already merged")
if target.status == "duplicate":
raise HTTPException(400, "Cannot merge into an already-merged request")
# Transfer upvotes (skip duplicates)
existing_voters = {u.user_id for u in target.upvotes}
for upvote in source.upvotes:
if upvote.user_id not in existing_voters:
new_upvote = FeatureUpvote(
request_id=target.id,
user_id=upvote.user_id,
)
db.add(new_upvote)
# Transfer subscribers
existing_subs = {s.user_id for s in target.subscribers}
for sub in source.subscribers:
if sub.user_id not in existing_subs:
new_sub = FeatureSubscriber(
request_id=target.id,
user_id=sub.user_id,
)
db.add(new_sub)
# Mark source as duplicate
source.status = "duplicate"
source.merged_into_id = target.id
# Update target counts
target.upvote_count = len(target.upvotes) + len(
[u for u in source.upvotes if u.user_id not in existing_voters]
)
await db.commit()
# Notify both sets of subscribers
await notify_merge(source, target)
return {"merged": source.id, "into": target.id}Merging transfers upvotes and subscribers from the source to the target, skipping users who already upvoted or subscribed to the target. This ensures that the merged request accurately reflects total community interest.
Priority Levels: P0 Through P3
Priorities are set by admins during triage:
| Priority | Meaning | Response Time Target |
|---|---|---|
| P0 | Critical -- blocking production usage | Same day |
| P1 | High -- significant impact on developers | This week |
| P2 | Medium -- improves experience notably | This month |
| P3 | Low -- nice to have, no urgency | When available |
python@router.patch("/admin/feature-requests/{id}/priority")
async def set_priority(
id: str,
data: PriorityUpdate, # { "priority": "P1" }
admin: User = Depends(require_admin_role),
):
request = await get_request_or_404(id)
if data.priority not in ("P0", "P1", "P2", "P3"):
raise HTTPException(400, "Priority must be P0, P1, P2, or P3")
request.priority = data.priority
await db.commit()
if data.priority == "P0":
# P0 triggers immediate notification to all admins
await notify_admins_p0(request)
return requestP0 requests trigger immediate notification because they represent blocking issues. If a developer reports that payouts are failing for a specific provider, that is a P0 -- and it needs attention within hours, not days.
Subscriber Notifications
When a request changes status, all subscribers receive a notification. The request creator is automatically subscribed. Anyone who upvotes or comments is also automatically subscribed (though they can unsubscribe):
python# services/notifications.py
async def notify_subscribers(request: FeatureRequest, new_status: str):
subscribers = await db.scalars(
select(FeatureSubscriber).where(
FeatureSubscriber.request_id == request.id
)
)
status_messages = {
"planned": f"'{request.title}' has been planned for development.",
"in-progress": f"'{request.title}' is now being built.",
"completed": f"'{request.title}' has been shipped!",
"declined": f"'{request.title}' has been declined. See the admin response for details.",
}
message = status_messages.get(new_status)
if not message:
return
for sub in subscribers.all():
await create_notification(
user_id=sub.user_id,
type="feature_request_update",
message=message,
link=f"/feature-requests/{request.id}",
)What We Learned
Denormalized counts are essential for sorting. Counting upvotes via JOIN on every list request would be unacceptable at scale. The denormalized upvote_count makes "most voted" sorting a simple ORDER BY.
Duplicate merging is the most valuable admin feature. Developers describe the same need in different ways. Without merging, you get a fragmented view of what the community actually wants. With merging, the most-requested features naturally rise to the top.
Four categories are enough. We resisted the urge to add more. Every additional category is a decision the submitter must make, and too many choices lead to miscategorization. Bug, feature request, documentation, performance -- these cover everything.
Auto-subscribing on interaction is the right default. If someone cares enough to upvote or comment, they want to know when something changes. Opt-out is better than opt-in for engagement features.
The feature request module was built in a single session but it provides ongoing value. It is the primary way we understand what 0fee.dev developers need, and it directly feeds the product roadmap.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.