Building a Virtual Closet App with Python and the FitInView API
This tutorial shows how to build a simple virtual closet app in Python. Users upload garment photos, label each item by category, pick a full outfit, and submit a virtual try-on job through FitInView. The app then polls for completion, shows the result, and keeps a basic history of past looks.
What we're building
The app has four core parts. First, a wardrobe page where users add clothing photos and choose a category such as top, bottom, or outerwear. Second, an outfit builder that lets them combine garments into one look. Third, a try-on flow that sends the person image plus selected garment URLs to FitInView. Fourth, a results page that shows the final image and recent jobs.
This is intentionally small and practical. You can run it as a Flask app with any small DB for storage. The point is to keep the product shape clear, not to overbuild the first version.
Stack: Flask, any small DB, FitInView API
Use Flask for the web app, a small database for garments and job history, and FitInView for the virtual try-on call. For storage, keep garment image URLs in your own system or on your CDN, then store those URLs in the database. The FitInView API only needs the person photo URL and the garment image URLs.
| Part | Why it is used | Notes |
|---|---|---|
| Flask | Simple Python web app | Good fit for a tutorial and a small wardrobe tool |
| Any small DB | Store garments and job history | Use the schema you are comfortable with |
| FitInView API | Virtual try-on jobs | Use POST /api/v1/tryon and GET /api/v1/jobs/{id} |
| Image hosting | Serve person and garment images | Use your own hosted URLs or CDN URLs |
Before writing code, note a few public FitInView details. The API uses Bearer authentication, test keys begin with fiv_test_ and return mock responses, and duplicate try-on requests with the same Idempotency-Key within 24 hours return a cached response. Output resolution options are 1K, 2K, and 4K, and higher resolutions scale with resolution.
Step 1: Project setup
Create a small Flask project with one app file, one database file, and a templates folder. Install the only external library we need for the API call, requests.
mkdir virtual-closet
cd virtual-closet
python -m venv .venv
source .venv/bin/activate
pip install flask requests
Set two environment variables, one for your API key and one for the base URL.
export FITINVIEW_API_KEY='fiv_test_your_test_key'
export FITINVIEW_BASE_URL='https://api.fitinview.com'
Step 2: Database schema for garments
You only need a few fields to get started: an id, a user_id, an image_url, and a category. The category is chosen by the user during upload, which keeps the first version simple and avoids any need for automated item detection.
{
"garments": {
"id": "integer primary key",
"user_id": "integer",
"image_url": "text",
"category": "text"
},
"tryon_jobs": {
"id": "integer primary key",
"user_id": "integer",
"fitinview_job_id": "text",
"status": "text",
"result_url": "text",
"created_at": "text"
}
}
If you already have an accounts table, link garments to the current user. For a tutorial, you can treat user_id as a placeholder value and focus on the workflow.
Step 3: Upload and categorize garments
When the user uploads a photo, your app should save the image, create a public URL for it, and store that URL with the chosen category. The user selects the category in the form, for example top, bottom, or outerwear. This is enough for the outfit builder to assemble a valid try-on request later.
import os
import json
import sqlite3
from datetime import datetime
from flask import Flask, request, redirect, url_for, render_template_string, abort
import requests
app = Flask(__name__)
DB_PATH = "app.db"
FITINVIEW_BASE_URL = os.environ.get("FITINVIEW_BASE_URL", "https://api.fitinview.com")
FITINVIEW_API_KEY = os.environ.get("FITINVIEW_API_KEY", "")
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = db()
conn.executescript("""
create table if not exists garments (
id integer primary key autoincrement,
user_id integer not null,
image_url text not null,
category text not null
);
create table if not exists tryon_jobs (
id integer primary key autoincrement,
user_id integer not null,
fitinview_job_id text not null,
status text not null,
result_url text,
created_at text not null
);
""")
conn.commit()
conn.close()
@app.before_request
def _setup():
init_db()
Step 4: Wardrobe gallery view
The wardrobe page should show all saved garments as cards. Group them by category so users can scan tops, bottoms, and outerwear quickly. For a first pass, a simple list or grid is enough.
@app.route("/")
def wardrobe():
conn = db()
garments = conn.execute(
"select id, image_url, category from garments where user_id = ? order by id desc",
(1,),
).fetchall()
jobs = conn.execute(
"select fitinview_job_id, status, result_url, created_at from tryon_jobs where user_id = ? order by id desc limit 20",
(1,),
).fetchall()
conn.close()
return render_template_string("""
<h1>Virtual Closet</h1>
<p><a href='/upload'>Upload garment</a> | <a href='/builder'>Build outfit</a></p>
<h2>Wardrobe</h2>
{% for g in garments %}
<div style='margin-bottom:12px'>
<img src='{{ g.image_url }}' width='120'>
<div>{{ g.category }} #{{ g.id }}</div>
</div>
{% endfor %}
<h2>Recent try-ons</h2>
{% for j in jobs %}
<div>{{ j.created_at }} | {{ j.status }} | {% if j.result_url %}<a href='{{ j.result_url }}'>result</a>{% endif %}</div>
{% endfor %}
""", garments=garments, jobs=jobs)
Step 5: Outfit builder
The outfit builder can be a form with three selectors, one each for top, bottom, and optional outerwear. A user might choose only top and bottom, or add an extra layer when available. Store the selected garment ids in the browser form, then resolve them to image URLs on submit.
This is also a good place to let the user upload or choose the person photo used for the try-on. The person photo must be a URL that FitInView can access.
Step 6: Submit try-on and poll job status
FitInView accepts multiple garment_urls, so you can send a top, bottom, and optional outerwear together. The request returns an id and status. After that, poll GET /api/v1/jobs/{id} until the status becomes complete and a result_url is available.
Honest timing matters here. A typical experience is about 5 to 30 seconds depending on output size.
def garments_by_ids(ids):
if not ids:
return []
conn = db()
placeholders = ",".join(["?"] * len(ids))
rows = conn.execute(
f"select id, image_url, category from garments where id in ({placeholders}) and user_id = ?",
(*ids, 1),
).fetchall()
conn.close()
by_id = {str(r["id"]): r for r in rows}
return [by_id[i] for i in ids if i in by_id]
def submit_tryon(person_url, garment_urls, output_size="1K", prompt=""):
url = f"{FITINVIEW_BASE_URL}/api/v1/tryon"
headers = {
"Authorization": f"Bearer {FITINVIEW_API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": f"wardrobe-{datetime.utcnow().timestamp()}"
}
payload = {
"person_url": person_url,
"garment_urls": garment_urls,
"output_size": output_size,
"prompt": prompt,
}
r = requests.post(url, headers=headers, json=payload, timeout=30)
r.raise_for_status()
return r.json()
def poll_job(job_id):
url = f"{FITINVIEW_BASE_URL}/api/v1/jobs/{job_id}"
headers = {"Authorization": f"Bearer {FITINVIEW_API_KEY}"}
r = requests.get(url, headers=headers, timeout=30)
r.raise_for_status()
return r.json()
@app.route("/builder", methods=["GET", "POST"])
def builder():
conn = db()
garments = conn.execute("select id, image_url, category from garments where user_id = ?", (1,)).fetchall()
conn.close()
if request.method == "POST":
person_url = request.form["person_url"]
selected = [request.form.get("top"), request.form.get("bottom"), request.form.get("outerwear")]
garment_rows = garments_by_ids([x for x in selected if x])
garment_urls = [g["image_url"] for g in garment_rows]
fit = submit_tryon(person_url, garment_urls, request.form.get("output_size", "1K"), request.form.get("prompt", ""))
conn = db()
conn.execute(
"insert into tryon_jobs (user_id, fitinview_job_id, status, result_url, created_at) values (?, ?, ?, ?, ?)",
(1, fit["id"], fit["status"], None, datetime.utcnow().isoformat()),
)
conn.commit()
conn.close()
return redirect(url_for("job_status", job_id=fit["id"]))
return render_template_string("""
<h1>Build outfit</h1>
<form method='post'>
<p>Person image URL <input name='person_url' required style='width:420px'></p>
<p>Top <input name='top'></p>
<p>Bottom <input name='bottom'></p>
<p>Outerwear <input name='outerwear'></p>
<p>Output size
<select name='output_size'>
<option>1K</option><option>2K</option><option>4K</option>
</select>
</p>
<p>Prompt <input name='prompt' style='width:420px'></p>
<button type='submit'>Try on</button>
</form>
<h2>Your garments</h2>
{% for g in garments %}
<div>#{{ g.id }} {{ g.category }} - {{ g.image_url }}</div>
{% endfor %}
""", garments=garments)
@app.route('/jobs/<job_id>')
def job_status(job_id):
fit = poll_job(job_id)
conn = db()
conn.execute(
"update tryon_jobs set status = ?, result_url = ? where fitinview_job_id = ? and user_id = ?",
(fit.get('status', ''), fit.get('result_url'), job_id, 1),
)
conn.commit()
conn.close()
return render_template_string("""
<h1>Job {{ job.id }}</h1>
<p>Status: {{ job.status }}</p>
{% if job.result_url %}
<p><img src='{{ job.result_url }}' width='360'></p>
<p><a href='{{ job.result_url }}'>Open result</a></p>
{% else %}
<p>Refresh this page in a few seconds.</p>
{% endif %}
<p><a href='/'>Back to wardrobe</a></p>
""", job=fit)
Step 7: Display result and history
Once the job is complete, show the result image at the top of the page and keep the older jobs below it. That gives users a basic history of their looks and makes the app feel more like a wardrobe tool than a one-off demo.
You can also add filters later, for example by category, date, or output size. For now, showing the last 20 jobs is enough.
Production: switch to webhooks and host garment images on your CDN
Polling is fine for a tutorial, but production apps are usually smoother with webhooks. FitInView can send a completion callback to your HTTPS webhook_url. The callback includes X-FitInView-Signature, which is an HMAC-SHA256 of the raw request body. Verify it with hmac.compare_digest before accepting the payload.
import hmac
import hashlib
def verify_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header_value or "")
For production, host garment images on your CDN or another stable public image host so FitInView can fetch them reliably. If you want a better user experience, submit the try-on request with webhook_url and update the job record as soon as the callback lands.
Comparison notes
| Service | Public pricing shape | Webhooks | Multi-garment support | Resolution notes |
|---|---|---|---|---|
| FitInView | Public dev packs shown on the developers page | Yes | Yes | 1K, 2K, 4K, scales with resolution |
| FASHN.ai | Subscription with monthly credits and top-ups | Not publicly listed in the facts provided | Not publicly listed | Up to 4K image generation |
| FitRoom | Pay-as-you-go plus subscription tiers, dollar pricing gated | Not mentioned on public pricing page | Yes | Up to 2048px |
| Segmind | PAYG entry and subscription, varies by model | Not publicly listed | Not publicly listed | Per GPU-second on serverless models |
| tryon-api.com | Free Starter and paid tiers, session allowance based | Yes | Not publicly listed | Not publicly listed |
For teams comparing options, the main questions are simple. Can you send multiple garments in one request, can you receive completion callbacks, and is pricing public enough to plan ahead. FitInView makes those basics clear on its developer page, while some competitors gate parts of pricing behind purchase or do not publicly break out per-call costs.
Practical next step
Start with the polling version first, because it is easiest to understand and debug. Then add webhooks once the core wardrobe flow works end to end. After that, move garment images to a stable public host, add better filtering in the gallery, and let users save favorite looks from their history.