Build a real-estate listings board with Flask, Tailwind and APULODI
Our Python SDK is live on PyPI, so this one is for the Flask crowd. We are going to build Openhouse — a small but complete real-estate listings board:
- Publish a listing with title, price, city, description and a cover photo that uploads directly to storage (Flask never buffers the bytes for long, never writes them to disk)
- Every photo gets an automatic WebP thumbnail — plus a 1600-pixel gallery version — generated by the platform. No Pillow, no queue, no image code in your app
- A Tailwind-styled listings grid and a detail page, with the full-size photo served through short-lived signed URLs — visitors never get a permanent link to your storage
- Webhooks keep SQLite in sync when photos are deleted elsewhere, and a one-click "remove listing" shows the delete round-trip
The stack is deliberately boring: Flask, the standard-library sqlite3
module, Tailwind from the CDN, and the apulodi package. No auth, no ORM,
no build step — one Python file plus three templates. Every block is
copy-paste ready, and what you add from there (agents, favourites, map view)
is listed at the end.
What you need
- Python 3.9 or newer
- An APULODI account — sign up at apulodi.com, the free plan needs no card
- About 20 minutes
Step 1 — Create the project
mkdir openhouse && cd openhouse
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install flask python-dotenv apulodi==0.1.0
Four dependencies total. apulodi==0.1.0 is the official SDK — its only
runtime dependency is httpx. python-dotenv loads your .env file.
Create the app skeleton:
# app.py
from flask import Flask
app = Flask(__name__)
@app.get("/")
def index():
return "Openhouse — coming together in the next steps."
if __name__ == "__main__":
app.run(debug=True, port=5000)
flask --app app run --debug # or: python app.py
Open http://localhost:5000 — you should see the placeholder. That is the
whole framework setup.
Step 2 — SQLite: the board's memory
SQLite ships with Python. One listings table records each property and
which APULODI file its cover photo points at:
# db.py
import sqlite3
from pathlib import Path
DB_PATH = Path("openhouse.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS listings (
id TEXT PRIMARY KEY,
apulodi_file_id TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
price INTEGER NOT NULL, -- whole dollars
city TEXT NOT NULL,
description TEXT NOT NULL,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
def get_db() -> sqlite3.Connection:
db = sqlite3.connect(DB_PATH)
db.row_factory = sqlite3.Row # rows behave like dicts: row["title"]
return db
def init_db() -> None:
with get_db() as db:
db.executescript(SCHEMA)
Wire it into app.py:
# app.py
from flask import Flask
from db import init_db
app = Flask(__name__)
init_db()
@app.get("/")
def index():
return "Openhouse — coming together in the next steps."
if __name__ == "__main__":
app.run(debug=True, port=5000)
Run it once and openhouse.db appears. That file is your entire database.
Step 3 — Connect APULODI
In the dashboard, create an organization, a
project, and an API key (Project → API Keys). The raw key is shown
exactly once — put it in .env (git-ignored):
# .env
APULODI_API_KEY=apk_test_your_key_here
Then create one shared client, imported everywhere:
# apulodi_client.py
import os
from dotenv import load_dotenv
from apulodi import Apulodi
load_dotenv()
apulodi = Apulodi(api_key=os.environ["APULODI_API_KEY"])
Server-side only. The SDK sends your API key on every request — never import this module from anywhere that could end up in a browser bundle. In Flask that means: routes and helpers only, never template code.
Step 4 — The publish-listing route
The heart of the app. The SDK's files.upload() performs the full
initiate → direct-to-storage PUT → complete flow in one call — the bytes
pass through your Flask server only in memory on their way to storage:
# app.py (add imports and the route)
import uuid
from flask import Flask, flash, redirect, render_template, request, url_for
from db import get_db, init_db
from apulodi_client import apulodi
app = Flask(__name__)
app.secret_key = "dev-only-change-me" # needed by flash(); from the env in production
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # reject request bodies over 16 MB
init_db()
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
@app.post("/listings")
def create_listing():
title = (request.form.get("title") or "").strip()
city = (request.form.get("city") or "").strip()
description = (request.form.get("description") or "").strip()
try:
price = int(request.form.get("price") or "")
except ValueError:
flash("Price must be a whole number of dollars.")
return redirect(url_for("index"))
if price < 0:
flash("Price can't be negative.")
return redirect(url_for("index"))
if not title or not city:
flash("Give the listing at least a title and a city.")
return redirect(url_for("index"))
photo = request.files.get("photo")
if photo is None or photo.filename == "":
flash("Add a cover photo.")
return redirect(url_for("index"))
if photo.mimetype not in ALLOWED_TYPES:
flash("Cover photo must be a JPEG, PNG, WebP or GIF image.")
return redirect(url_for("index"))
# The SDK performs the full initiate → direct PUT → complete flow; the
# bytes pass through Flask only in memory on their way to storage.
uploaded = apulodi.files.upload(
photo.stream, # Werkzeug streams are binary file objects
file_name=photo.filename,
content_type=photo.mimetype,
path="listings", # logical folder, created automatically
metadata={"source": "openhouse-tutorial"},
)
db = get_db()
with db:
db.execute(
"INSERT INTO listings "
"(id, apulodi_file_id, title, price, city, description, filename, content_type, size) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(str(uuid.uuid4()), uploaded["id"], title, price, city, description,
uploaded["filename"], uploaded["contentType"], uploaded["size"]),
)
flash("Listing published.")
return redirect(url_for("index"))
Three details worth noticing:
file.stream— Werkzeug gives you a binary file object, and the SDK accepts those directly. Nothing is written to your disk.- Validate before the bytes move — mimetype, required fields and price
are all checked before
upload()is called, andMAX_CONTENT_LENGTHcaps the request body at 16 MB (Flask answers 413 beyond it). metadata— attach anything JSON-serializable. APULODI stores it with the file and returns it on every read, which is often enough to skip a database query.
Files larger than 8 MiB are automatically uploaded with the multipart flow (chunked parts, abort-on-failure cleanup). You do not write any of that code.
Step 5 — Tailwind and the templates
For a tutorial, Tailwind's Play CDN is the fastest path (for production you would compile it — see the Tailwind CLI docs).
<!-- templates/base.html -->
<!doctype html>
<html lang="en" class="bg-gray-50 text-gray-900">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Openhouse — real-estate listings board</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen antialiased">
<nav class="border-b border-gray-200 bg-white">
<div class="mx-auto flex h-14 max-w-5xl items-center px-4">
<a href="{{ url_for('index') }}" class="text-lg font-bold tracking-tight">🏡 Openhouse</a>
</div>
</nav>
<main class="mx-auto max-w-5xl px-4 py-8">
{% with messages = get_flashed_messages() %}
{% for message in messages %}
<p class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ message }}
</p>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>
The listings grid with the publish form:
<!-- templates/index.html -->
{% extends "base.html" %}
{% block content %}
<h1 class="text-2xl font-bold tracking-tight">Homes for sale</h1>
<form
action="{{ url_for('create_listing') }}"
method="post"
enctype="multipart/form-data"
class="mt-4 rounded-2xl border-2 border-dashed border-gray-300 bg-white p-8 transition-colors hover:border-gray-400"
>
<p class="text-sm font-medium text-gray-700">List a property</p>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<input name="title" placeholder="3-bed townhouse" required
class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-gray-500 focus:outline-none" />
<input name="city" placeholder="City" required
class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-gray-500 focus:outline-none" />
<input name="price" type="number" min="0" placeholder="Price in $" required
class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-gray-500 focus:outline-none" />
</div>
<textarea name="description" rows="2" placeholder="A few lines about the place…"
class="mt-3 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-gray-500 focus:outline-none"></textarea>
<div class="mt-3 flex flex-wrap items-center gap-3">
<label for="photo"
class="cursor-pointer rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100">
📷 Choose cover photo
</label>
<input id="photo" type="file" name="photo" accept="image/*" required class="sr-only" />
<button type="submit"
class="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-800">
Publish listing
</button>
</div>
<p class="mt-2 text-xs text-gray-500">JPEG, PNG, WebP or GIF — thumbnails are automatic</p>
</form>
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{% for listing in listings %}
<a
href="{{ url_for('listing_detail', listing_id=listing['id']) }}"
class="group overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-md"
>
{% if listing['thumbnail'] %}
<img src="{{ listing['thumbnail'] }}" alt="{{ listing['title'] }}" class="h-48 w-full object-cover" />
{% else %}
<div class="flex h-48 w-full items-center justify-center bg-gray-100 text-4xl">🏠</div>
{% endif %}
<div class="p-4">
<p class="text-lg font-semibold text-gray-900">{{ listing['price_display'] }}</p>
<p class="truncate text-sm text-gray-700">{{ listing['title'] }}</p>
<p class="mt-0.5 text-xs text-gray-500">{{ listing['city'] }}</p>
</div>
</a>
{% else %}
<p class="col-span-full rounded-xl border border-dashed border-gray-300 p-12 text-center text-sm text-gray-500">
No listings yet — publish the first one above.
</p>
{% endfor %}
</div>
{% endblock %}
And wire up the index route to feed it (replacing the placeholder):
# app.py
@app.get("/")
def index():
db = get_db()
rows = db.execute(
"SELECT * FROM listings ORDER BY created_at DESC, id DESC LIMIT 60"
).fetchall()
# Thumbnails: transform() is idempotent — identical params always return
# the same variant, so it is safe to ask on every render. Ready variants
# get a short-lived signed URL; a first-ever render shows the placeholder
# until the platform finishes processing.
enriched = []
for row in rows:
item = dict(row)
item["price_display"] = f"${item['price']:,}"
item["thumbnail"] = None
try:
variant = apulodi.files.transform(
row["apulodi_file_id"], width=640, format="webp", quality=80
)
if variant["status"] == "ready":
signed = apulodi.files.variant_download_url(
row["apulodi_file_id"], variant["id"], expires_in_seconds=3600
)
item["thumbnail"] = signed["url"]
except Exception:
pass # file deleted elsewhere — the webhook removes the row shortly
enriched.append(item)
return render_template("index.html", listings=enriched)
Step 6 — The detail page and signed photo URLs
<!-- templates/detail.html -->
{% extends "base.html" %}
{% block content %}
<div class="mx-auto max-w-2xl">
<a href="{{ url_for('index') }}" class="text-sm text-gray-500 underline">← All listings</a>
<div class="mt-4 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
{% if photo %}
<img src="{{ photo }}" alt="{{ listing['title'] }}" class="max-h-[28rem] w-full object-cover" />
{% else %}
<div class="flex h-72 w-full items-center justify-center bg-gray-100 text-6xl">🏠</div>
{% endif %}
<div class="p-6">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h1 class="text-2xl font-bold tracking-tight text-gray-900">{{ listing['title'] }}</h1>
<p class="text-xl font-semibold text-gray-900">{{ listing['price_display'] }}</p>
</div>
<p class="mt-1 text-sm text-gray-500">{{ listing['city'] }} · listed {{ listing['date_display'] }}</p>
{% if listing['description'] %}
<p class="mt-4 text-sm leading-relaxed text-gray-700">{{ listing['description'] }}</p>
{% endif %}
<div class="mt-6 flex flex-wrap gap-3">
<a
href="{{ url_for('listing_photo', listing_id=listing['id']) }}"
target="_blank" rel="noopener noreferrer"
class="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-800"
>
Open original photo
</a>
<form action="{{ url_for('delete_listing', listing_id=listing['id']) }}" method="post"
onsubmit="return confirm('Remove this listing and its photo from storage?')">
<button type="submit"
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50">
Remove listing
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
Now the three routes behind it — detail, signed photo redirect, and delete:
# app.py
from flask import abort
@app.get("/listings/<listing_id>")
def listing_detail(listing_id):
db = get_db()
row = db.execute("SELECT * FROM listings WHERE id = ?", (listing_id,)).fetchone()
if row is None:
abort(404)
listing = dict(row)
listing["price_display"] = f"${listing['price']:,}"
listing["date_display"] = listing["created_at"][:10] # YYYY-MM-DD
photo = None
try:
variant = apulodi.files.transform(
row["apulodi_file_id"], width=1600, format="webp", quality=85
)
if variant["status"] == "ready":
signed = apulodi.files.variant_download_url(
row["apulodi_file_id"], variant["id"], expires_in_seconds=3600
)
photo = signed["url"]
except Exception:
pass
return render_template("detail.html", listing=listing, photo=photo)
@app.get("/listings/<listing_id>/photo")
def listing_photo(listing_id):
db = get_db()
row = db.execute("SELECT * FROM listings WHERE id = ?", (listing_id,)).fetchone()
if row is None:
abort(404)
# 5-minute signed URL to the original — served straight from storage;
# APULODI never proxies the bytes and neither does the app.
signed = apulodi.files.download_url(row["apulodi_file_id"], expires_in_seconds=300)
return redirect(signed["url"])
@app.post("/listings/<listing_id>/delete")
def delete_listing(listing_id):
db = get_db()
row = db.execute("SELECT * FROM listings WHERE id = ?", (listing_id,)).fetchone()
if row is None:
abort(404)
try:
apulodi.files.delete(row["apulodi_file_id"])
except Exception:
pass # already gone (deleted in the dashboard, say) — drop the row anyway
with db:
db.execute("DELETE FROM listings WHERE id = ?", (listing_id,))
flash("Listing removed.")
return redirect(url_for("index"))
The photo route is deliberately a redirect: the browser asks your app for the image, your app mints a fresh 5-minute signed URL and hands back a 302. The bytes flow browser → storage, never through Flask. If a link is scraped or shared, it dies five minutes later.
Note transform() is idempotent — asking twice with the same parameters
doesn't create two variants; it returns the same one (with its current
status). That makes the "ask on every render" pattern above safe and simple.
Step 7 — Webhooks: keeping SQLite honest
Right now, a photo deleted in the dashboard leaves a ghost row in SQLite. Webhooks fix that: APULODI POSTs an event to your app whenever something happens to a file.
In the dashboard, open Project → Webhooks and register
http://your-tunnel-url/api/webhooks/apulodi (for local dev, expose port
5000 first with ngrok http 5000 or cloudflared tunnel --url http://localhost:5000 — register the webhook after starting the tunnel,
and note that free tunnel URLs rotate on restart). Copy the signing
secret into .env:
APULODI_WEBHOOK_SECRET=whsec_your_secret_here
The receiver, with the three things every webhook handler needs — verify, reject stale, dispatch:
# app.py
import json
import os
from datetime import datetime, timezone
from flask import request
from apulodi import verify_webhook_signature
@app.post("/webhooks/apulodi")
def apulodi_webhook():
# 1. Verify first — over the RAW body, before any parsing. The signature
# covers exact bytes, so request.get_data(), not request.get_json().
raw = request.get_data()
signature = request.headers.get("APULODI-Signature", "")
secret = os.environ.get("APULODI_WEBHOOK_SECRET", "")
if not verify_webhook_signature(secret, raw.decode("utf-8"), signature):
return {"error": "invalid signature"}, 401
event = json.loads(raw)
# 2. Reject replays older than 5 minutes. Parse the timestamp as UTC —
# naive parsing would use the server's local timezone and reject
# perfectly fresh events on any machine not running UTC.
created_at = datetime.fromisoformat(event["createdAt"].replace("Z", "+00:00"))
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - created_at
if age.total_seconds() > 5 * 60:
return {"error": "stale event"}, 400
# 3. Dispatch. (In production, also record event["id"] and skip repeats —
# deliveries are at-least-once.)
if event["type"] == "file.deleted":
file_id = event["data"]["file"]["id"]
with get_db() as db:
db.execute(
"DELETE FROM listings WHERE apulodi_file_id = ?", (file_id,)
)
# 4. Always acknowledge quickly. A 200 tells APULODI to stop retrying.
return {"ok": True}
verify_webhook_signature is built into the SDK: it recomputes the HMAC
over {timestamp}.{raw_body} with your signing secret and compares in
constant time. If anyone who isn't APULODI posts to your endpoint, they get
a 401 before your code touches the payload.
Step 8 — Run it
flask --app app run --debug
Open http://localhost:5000:
- Publish a listing with a photo → the grid card shows the WebP thumbnail (after the platform's first-pass processing — the placeholder 🏠 covers the first seconds)
- Click through to the detail page → 1600-px gallery image, price, city
- Open original photo → a 302 to a 5-minute signed URL
- Remove listing → confirm → row gone, photo deleted from storage
- Delete a file in the dashboard → the webhook removes its listing within seconds
Where to take it next
The board is deliberately minimal; each of these is a natural next PR:
- Multiple photos per listing — the SDK's
uploadsmodule handles multi-part sets; store an ordered list of file ids per listing - Search and filters — SQLite FTS5 for full-text search over
title/description, plus a
citydropdown - Agent accounts — Flask-Login with a
listings.agent_idcolumn, so people manage only their own listings - Expiring listings — a signed-URL lifetime that matches your business rule (30-day listings get 30-day URLs, refreshed per visit)
- Video tours — APULODI transcodes video;
transform(format="mp4")gives you a playable variant with the same call you already know
Production checklist
- Set a real
secret_keyfrom the environment, not the hardcoded dev value - Compile Tailwind instead of the Play CDN
- Deploy with a real server —
gunicorn "app:app"behind nginx, or a container; keep.envout of the image - Webhook dedup — record
event["id"]in a table and skip repeats (deliveries are at-least-once) - Rate-limit
POST /listings— it triggers uploads and transforms, so it is the one route worth throttling - Database migrations — as the schema grows, graduate from
executescriptto Alembic
Wrap-up
About 300 lines of Python and three templates got us: direct-to-storage uploads with automatic multipart for big files, platform-generated WebP thumbnails at two sizes, short-lived signed URLs for every image, typed errors, and webhook-driven cleanup — with no image code, no upload worker, and no storage bills for abandoned bytes.
The full SDK reference lives in the Python SDK docs
— every call used here (upload, transform, variant_download_url,
download_url, delete, verify_webhook_signature) is documented with its
options there. And if you build something with this, show us — we feature community projects.