BlogHow to Build an Expense Tracking App with Receipt OCR

How to Build an Expense Tracking App with Receipt OCR

2026-05-23 · 7 min read

The receipt upload feature is table stakes for any expense tracking app. Users photograph or upload a receipt; the app reads it and creates an expense entry automatically — no typing required. Here's how to build that feature from scratch using DocuParseAPI.

1
API endpoint
Auto
receipt parsing
React
+ any backend
Free
20 receipts/month
App architecture overview
📱
User uploads receipt
React/Next.js frontend
⚙️
Backend proxies request
Express / Next.js API route
🔗
DocuParseAPI extracts
Returns structured JSON
📊
Store + display
Database + expense dashboard
Architecture

What We're Building

A backend route that:

  1. Accepts a receipt file uploaded by a user
  2. Sends it to DocuParseAPI
  3. Creates an expense record from the extracted data
  4. Returns the expense to the client

The same pattern works whether you're building a mobile app backend, a web app, or an internal tool.

See what your app receives from the API
Upload a receipt. See the structured data your expense app would display.
Open Live Demo →
Free tier · 20 documents/month — free forever · No credit card · No account needed for the demo
Python · Node.js

Architecture Overview

text · 16 lines
[User's phone/browser]
       │ uploads receipt (PDF/JPG/PNG)
       ↓
[Your backend API]
       │ forwards file to DocuParseAPI
       ↓
[DocuParseAPI]
       │ returns structured JSON
       ↓
[Your backend API]
       │ creates expense record
       ↓
[Your database]
       │ saves expense
       ↓
[Returns expense to client]

The important thing: your API key never reaches the client. It stays on your server.


Node.js · Python
# main.py
import os
import json
from fastapi import FastAPI, UploadFile, Depends, HTTPException
from fastapi.responses import JSONResponse
import httpx
from pydantic import BaseModel
from typing import Optional, List

app = FastAPI()

class LineItem(BaseModel):
    description: Optional[str]
    quantity: Optional[float]
    amount: Optional[str]

class Expense(BaseModel):
    merchant: Optional[str]
    amount: float
    tax: float
    currency: str
    date: Optional[str]
    receipt_id: Optional[str]
    payment_method: Optional[str]
    line_items: List[LineItem] = []
    document_id: Optional[str]


async def call_docuparse(file_content: bytes, filename: str) -> dict:
    """Call DocuParseAPI and return extracted data."""
    api_key = os.environ["DOCUPARSE_API_KEY"]
    
    async with httpx.AsyncClient(timeout=30) as client:
        response = await client.post(
            "https://docuparseapi.com/api/v1/extract",
            headers={"Authorization": f"Bearer {api_key}"},
            files={"file": (filename, file_content)},
        )
    
    data = response.json()
    
    if not data.get("success"):
        code = data.get("error", {}).get("code", "UNKNOWN")
        raise HTTPException(
            status_code=422 if code == "EXTRACTION_FAILED" else 500,
            detail=f"Extraction failed: {code}"
        )
    
    return data


@app.post("/api/expenses/upload", response_model=Expense)
async def upload_receipt(
    file: UploadFile,
    current_user = Depends(get_current_user)  # your auth dependency
):
    # Validate file type
    allowed_types = {"application/pdf", "image/jpeg", "image/png"}
    if file.content_type not in allowed_types:
        raise HTTPException(400, "Only PDF, JPG, and PNG files are supported")
    
    # Read file
    content = await file.read()
    if len(content) > 10 * 1024 * 1024:  # 10MB
        raise HTTPException(400, "File too large (max 10MB)")
    
    # Extract data
    extracted = await call_docuparse(content, file.filename)
    
    # Map to expense model
    expense_data = Expense(
        merchant=extracted.get("merchant"),
        amount=float(extracted.get("total") or 0),
        tax=float(extracted.get("tax") or 0),
        currency=extracted.get("currency", "USD"),
        date=extracted.get("date"),
        receipt_id=extracted.get("receipt_id"),
        payment_method=extracted.get("payment_method"),
        line_items=[
            LineItem(**item) for item in extracted.get("line_items", [])
        ],
        document_id=extracted.get("document_id"),
    )
    
    # Save to database (your ORM call here)
    saved = await db.expenses.create(
        user_id=current_user.id,
        **expense_data.dict()
    )
    
    return saved
Your expense app needs one API call.
20 documents/month — free forever. No credit card. Ships today.
Frontend

Frontend: Receipt Upload Component (React)

jsx · 89 lines
// components/ReceiptUpload.jsx
import { useState, useRef } from "react";

export function ReceiptUpload({ onExpenseCreated }) {
  const [status, setStatus] = useState("idle"); // idle | uploading | success | error
  const [expense, setExpense] = useState(null);
  const [errorMessage, setErrorMessage] = useState("");
  const fileInputRef = useRef(null);

  const handleFileChange = async (event) => {
    const file = event.target.files[0];
    if (!file) return;

    setStatus("uploading");
    setErrorMessage("");

    const formData = new FormData();
    formData.append("receipt", file);

    try {
      // Call YOUR backend — not DocuParseAPI directly
      const response = await fetch("/api/expenses/upload", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${getAuthToken()}`,
        },
        body: formData,
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || "Upload failed");
      }

      setExpense(data.expense);
      setStatus("success");
      onExpenseCreated?.(data.expense);

    } catch (error) {
      setStatus("error");
      setErrorMessage(error.message);
    }
  };

  return (
    <div className="receipt-upload">
      <input
        ref={fileInputRef}
        type="file"
        accept=".pdf,.jpg,.jpeg,.png"
        onChange={handleFileChange}
        style={{ display: "none" }}
      />

      {status === "idle" && (
        <button onClick={() => fileInputRef.current?.click()}>
          Upload Receipt
        </button>
      )}

      {status === "uploading" && (
        <div>Reading your receipt...</div>
      )}

      {status === "success" && expense && (
        <div className="expense-preview">
          <p>✓ Expense created</p>
          <p>{expense.merchant} — {expense.currency} {expense.amount}</p>
          <p>{expense.date}</p>
          {expense.lineItems.length > 0 && (
            <ul>
              {expense.lineItems.map((item, i) => (
                <li key={i}>{item.description}: {item.amount}</li>
              ))}
            </ul>
          )}
        </div>
      )}

      {status === "error" && (
        <div>
          <p>Error: {errorMessage}</p>
          <button onClick={() => setStatus("idle")}>Try again</button>
        </div>
      )}
    </div>
  );
}

Storage

Preventing Duplicate Expenses

A user might accidentally upload the same receipt twice. Use the document_id field from the API response — it's unique per document — to detect and prevent duplicates:

javascript · 11 lines
// Before creating the expense record, check for duplicates
const existing = await db.expenses.findOne({ 
  where: { documentId: extractedData.document_id, userId }
});

if (existing) {
  return res.status(409).json({
    error: "This receipt has already been uploaded",
    expenseId: existing.id
  });
}

Dashboard

What to Show the User During Extraction

Receipt extraction typically completes in under 5 seconds. Use that time well:

  1. Immediately on upload: Show a progress indicator. "Reading your receipt..."
  2. On success: Pre-fill all form fields with extracted data. Let the user correct anything before saving.
  3. On failure: Give a specific, actionable error. "This image is too blurry — try taking the photo in better lighting, or upload a PDF." Not "something went wrong."

Pre-filling the form rather than silently saving is good UX — it builds trust by showing the user what was extracted and letting them verify it.


Pricing

Pricing

The receipt upload feature costs $0.005 per receipt on the Starter plan ($14.99/month for 3,000 receipts). For most expense tracking apps, that's months of receipts covered at a price that rounds to noise.

Start free — 20 receipts/month to test your integration

Ship

Next Steps

React · Express · Next.js · Any backend

Your expense tracker is a POST request away from working.

20 documents/month — free forever. No credit card. Full API access.

More from the blog