Vendor Application Flow: Public Form to Organizer Review
Vendor Application Flow
The Feature
A vendor visits the public market page, fills out an application form, and submits it. The organizer receives an email notification. The organizer reviews the application in the dashboard and accepts or rejects it. The vendor receives an email with the decision.
The Decision
The vendor application form is public. No account required to submit. This reduces friction: a vendor at a farmers market gets handed a QR code to the application page and fills it out on their phone. Account creation happens after acceptance, when the vendor needs to manage their bookings.
Email notifications use Resend (Chapter 10 covers the full setup). For this chapter, the email service is abstracted behind an interface that the notification code calls.
The Implementation
Public Application API Endpoint
# backend/app/routers/public.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.application import Application, ApplicationStatus
from app.models.market import Market
from app.schemas.application import PublicApplicationCreate, ApplicationRead
from app.services.notifications import notify_organizer_new_application
router = APIRouter(prefix="/api/public", tags=["public"])
@router.get("/markets/{market_id}")
async def get_public_market(
market_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> dict:
result = await db.execute(
select(Market).where(Market.id == market_id, Market.is_active.is_(True))
)
market = result.scalar_one_or_none()
if not market:
raise HTTPException(status_code=404, detail="Market not found")
return {
"id": str(market.id),
"name": market.name,
"description": market.description,
"location": market.location,
}
@router.post("/markets/{market_id}/apply", status_code=201)
async def submit_application(
market_id: uuid.UUID,
body: PublicApplicationCreate,
db: AsyncSession = Depends(get_db),
) -> dict[str, str]:
# Verify market exists and is active
result = await db.execute(
select(Market).where(Market.id == market_id, Market.is_active.is_(True))
)
market = result.scalar_one_or_none()
if not market:
raise HTTPException(status_code=404, detail="Market not found")
# Check for duplicate application by email
result = await db.execute(
select(Application).where(
Application.market_id == market_id,
Application.applicant_email == body.email,
)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=409,
detail="An application with this email already exists for this market",
)
application = Application(
market_id=market_id,
applicant_name=body.business_name,
applicant_email=body.email,
applicant_phone=body.phone,
product_categories=body.product_categories,
message=body.message,
status=ApplicationStatus.PENDING,
)
db.add(application)
await db.commit()
# Notify organizer (fire and forget for now)
await notify_organizer_new_application(market, application)
return {"status": "submitted", "message": "Your application has been received."}
Public Application Schema
# backend/app/schemas/application.py
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class PublicApplicationCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
business_name: str = Field(min_length=1, max_length=200)
email: EmailStr
phone: str | None = Field(default=None, max_length=20)
product_categories: list[str] = Field(min_length=1, max_length=5)
message: str | None = Field(default=None, max_length=2000)
Public Application Form (Frontend)
// frontend/src/pages/public/ApplyPage.tsx
import { useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { customInstance } from "@/api/client";
interface ApplicationFormData {
business_name: string;
email: string;
phone: string;
product_categories: string;
message: string;
}
export function ApplyPage() {
const { marketId } = useParams<{ marketId: string }>();
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ApplicationFormData>();
const onSubmit = async (data: ApplicationFormData) => {
setError(null);
try {
await customInstance({
url: `/api/public/markets/${marketId}/apply`,
method: "POST",
data: {
...data,
product_categories: data.product_categories
.split(",")
.map((c) => c.trim())
.filter(Boolean),
},
});
setSubmitted(true);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Something went wrong";
setError(message);
}
};
if (submitted) {
return (
<div className="max-w-lg mx-auto py-12 text-center">
<h2 className="text-2xl font-bold mb-2">Application Submitted</h2>
<p className="text-muted-foreground">
The market organizer will review your application and get back to you
via email.
</p>
</div>
);
}
return (
<div className="max-w-lg mx-auto py-12">
<Card>
<CardHeader>
<CardTitle>Apply to join this market</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor="business_name">Business Name</Label>
<Input
id="business_name"
{...register("business_name", {
required: "Business name is required",
maxLength: 200,
})}
/>
{errors.business_name && (
<p className="text-sm text-destructive mt-1">
{errors.business_name.message}
</p>
)}
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...register("email", { required: "Email is required" })}
/>
</div>
<div>
<Label htmlFor="phone">Phone (optional)</Label>
<Input id="phone" type="tel" {...register("phone")} />
</div>
<div>
<Label htmlFor="product_categories">
Product Categories (comma-separated)
</Label>
<Input
id="product_categories"
placeholder="produce, baked goods, crafts"
{...register("product_categories", {
required: "At least one category is required",
})}
/>
</div>
<div>
<Label htmlFor="message">Message to organizer (optional)</Label>
<Textarea
id="message"
{...register("message", { maxLength: 2000 })}
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit Application"}
</Button>
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
</form>
</CardContent>
</Card>
</div>
);
}
The Trap
# TRAP: No rate limiting on the public application endpoint
@router.post("/markets/{market_id}/apply")
async def submit_application(body: PublicApplicationCreate, ...):
# An attacker can submit thousands of applications
# filling the organizer's inbox and the database
The public application endpoint is included in the rate limiting middleware from Chapter 6 (30 requests per minute per IP for public endpoints). Additionally, the duplicate check on email prevents the same person from submitting multiple applications. For more aggressive abuse, Cloudflare’s bot management (free tier) blocks automated form submissions.
The Cost
| Component | Cost |
|---|---|
| Public pages | $0 (same frontend/backend stack) |
| Email notifications | $0 (Resend free tier, 100/day) |
| Rate limiting | $0 (Redis already running) |
A market that receives 10 vendor applications per day uses 10 of the 100 daily free email sends. At 50 markets averaging 10 applications each, the daily email volume is 500, which exceeds the free tier. Resend’s first paid tier at $20/month allows 50,000 emails/month, which handles this comfortably.