Real-Life Example: Expense Receipt Upload API
Instead of a toy uploader, this tutorial builds a backend flow teams actually use: employees upload receipts, finance validates them, and status moves from pending_review to approved or rejected.
- Uploads via
multipart/form-data - Validation by content type, extension, and max size
- Chunked file write + SHA-256 checksum
- Metadata stored in SQLite for finance reporting
- Simple auth with
X-API-Keyfor internal APIs
Business Scenario and Requirements
The finance team needs one API for receipt intake. Every upload needs a file, amount, merchant, employee id, expense date, and category so reviewers can audit and approve quickly.
| Requirement | Why it matters | Implementation |
|---|---|---|
| Accept JPG/PNG/PDF | Common receipt formats | Validate content type + extension |
| Block oversized files | Avoid storage abuse | Reject over 5MB (HTTP 413) |
| Keep file hash | Audit and duplicate checks | SHA-256 during streaming write |
| Track review state | Finance workflow | pending_review / approved / rejected |
| Query by employee | Monthly reimbursements | GET /receipts?employee_id=... |
Project Location in This Repo
Runnable sample created at examples/fastapi-file-upload. This is the same implementation described in this article.
examples/fastapi-file-upload/
├── .env.example
├── README.md
├── requirements.txt
├── app/
│ ├── __init__.py
│ ├── database.py
│ ├── main.py
│ └── settings.py
└── storage/
└── receipts/Run Locally
cd examples/fastapi-file-upload
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn app.main:app --reloadINFO: Uvicorn running on http://127.0.0.1:8000 INFO: Application startup complete. Open docs: http://127.0.0.1:8000/docs
Configuration (settings.py)
Keep limits and auth in environment variables so the same code runs on local, staging, and prod with different values.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Expense Receipt Upload API"
api_key: str = "local-dev-key"
max_upload_bytes: int = 5 * 1024 * 1024
upload_dir: str = "storage/receipts"
database_url: str = "sqlite:///./receipts.db"
allowed_content_types: str = "image/jpeg,image/png,application/pdf"
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@property
def allowed_content_type_set(self) -> set[str]:
return {item.strip() for item in self.allowed_content_types.split(",") if item.strip()}
settings = Settings()Upload Endpoint with Streaming + Validation
This endpoint accepts file + form fields in one request, writes the file in chunks, enforces max size, and stores hash and metadata.
CHUNK_SIZE = 1024 * 1024
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".pdf"}
@app.post("/receipts", response_model=ReceiptResponse, status_code=201, dependencies=[Depends(require_api_key)])
async def create_receipt(
file: UploadFile = File(...),
employee_id: str = Form(...),
merchant: str = Form(...),
amount: Decimal = Form(...),
expense_date: date = Form(...),
category: str = Form(default="other"),
currency: str = Form(default="USD"),
):
if file.content_type not in settings.allowed_content_type_set:
raise HTTPException(status_code=400, detail="Unsupported content type")
extension = Path(file.filename or "").suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="Unsupported file extension")
receipt_id = uuid4().hex
year_month = datetime.now(UTC).strftime("%Y/%m")
target_dir = upload_root / year_month
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / f"{receipt_id}{extension}"
size = 0
digest = hashlib.sha256()
with target_path.open("wb") as out:
while True:
chunk = await file.read(CHUNK_SIZE)
if not chunk:
break
size += len(chunk)
if size > settings.max_upload_bytes:
raise HTTPException(status_code=413, detail="File too large")
digest.update(chunk)
out.write(chunk)Persistence Layer (SQLite)
Finance needs searchable records. We store receipt metadata in a table, while actual files stay on disk understorage/receipts/YYYY/MM.
def init_db() -> None:
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS receipts (
id TEXT PRIMARY KEY,
employee_id TEXT NOT NULL,
merchant TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL,
expense_date TEXT NOT NULL,
category TEXT NOT NULL,
original_filename TEXT NOT NULL,
stored_path TEXT NOT NULL,
content_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
checksum_sha256 TEXT NOT NULL,
status TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.commit()Review Workflow Endpoints
The API supports both retrieval and review actions:
| Endpoint | Purpose | Example |
|---|---|---|
POST /receipts | Create expense receipt upload | Used by employee submit form |
GET /receipts | List receipts with filters | ?employee_id=emp-104 |
GET /receipts/{id} | Fetch single receipt | Open one receipt detail |
PATCH /receipts/{id}/status | Finance updates status | approved / rejected |
curl -X PATCH "http://127.0.0.1:8000/receipts/<receipt_id>/status" -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d '{"status":"approved"}'End-to-End cURL Flow
curl -X POST "http://127.0.0.1:8000/receipts" -H "X-API-Key: local-dev-key" -F "file=@/absolute/path/to/receipt.jpg" -F "employee_id=emp-104" -F "merchant=Grab Transport" -F "amount=18.45" -F "expense_date=2026-02-27" -F "category=travel" -F "currency=PHP"{
"id": "8fbe524f8c7a4dbcbf0fd0cbf7d68e2a",
"employee_id": "emp-104",
"merchant": "Grab Transport",
"amount_cents": 1845,
"currency": "PHP",
"expense_date": "2026-02-27",
"category": "travel",
"status": "pending_review",
"stored_path": "2026/02/8fbe524f8c7a4dbcbf0fd0cbf7d68e2a.jpg"
}Production Notes Before Shipping
| Area | Demo approach | Production upgrade |
|---|---|---|
| Storage | Local disk | S3/GCS + signed URLs |
| Auth | Static API key | JWT/session with user identity |
| Security | Type + size validation | Malware scanning and MIME sniffing |
| Database | SQLite | PostgreSQL + indexes + audit fields |
| Observability | App logs | Structured logs + metrics + alerts |