Build a screenshot gallery 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 Snaps — a small but complete team screenshot gallery:
- Drag screenshots onto a page; they upload directly to storage (Flask never buffers the bytes for long, never writes them to disk)
- Every screenshot gets an automatic WebP thumbnail, generated by the platform — no Pillow, no queue, no image code in your app
- A Tailwind-styled gallery grid, a detail page, and downloads through short-lived signed URLs with a live counter
- Webhooks keep SQLite in sync when files are deleted elsewhere
The stack is deliberately boring: Flask, the standard-library sqlite3
module, Tailwind from the CDN, and the apulodi package. No ORM, no build
step, one Python file plus three templates. Every block is copy-paste ready.
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 snaps && cd snaps
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 gallery():
return "Snaps — 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 gallery's memory
SQLite ships with Python. One snaps table records which APULODI file each
screenshot points at, plus a download counter:
# db.py
import sqlite3
from pathlib import Path
DB_PATH = Path("snaps.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS snaps (
id TEXT PRIMARY KEY,
apulodi_file_id TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size INTEGER NOT NULL,
downloads INTEGER NOT NULL DEFAULT 0,
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["filename"]
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 gallery():
return "Snaps — coming together in the next steps."
if __name__ == "__main__":
app.run(debug=True, port=5000)
Run it once and snaps.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 upload 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()
init_db()
ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}
@app.post("/upload")
def upload():
file = request.files.get("screenshot")
if file is None or file.filename == "":
flash("Choose a screenshot first.")
return redirect(url_for("gallery"))
if file.mimetype not in ALLOWED_TYPES:
flash("Only PNG, JPEG, WebP and GIF screenshots are supported.")
return redirect(url_for("gallery"))
# The SDK handles presigning, the direct PUT and completion.
uploaded = apulodi.files.upload(
file.stream, # Werkzeug streams are binary file objects
file_name=file.filename,
content_type=file.mimetype,
path="snaps", # logical folder, created automatically
metadata={"source": "snaps-tutorial"},
)
db = get_db()
with db:
db.execute(
"INSERT INTO snaps (id, apulodi_file_id, filename, content_type, size) "
"VALUES (?, ?, ?, ?, ?)",
(str(uuid.uuid4()), uploaded["id"], uploaded["filename"],
uploaded["contentType"], uploaded["size"]),
)
return redirect(url_for("gallery"))
Two details worth noticing:
file.stream— Werkzeug gives you a binary file object, and the SDK accepts those directly. Nothing is written to your disk.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>Snaps — team screenshot gallery</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">
<span class="text-lg font-bold tracking-tight">📸 Snaps</span>
</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 gallery grid with the upload dropzone:
<!-- templates/gallery.html -->
{% extends "base.html" %}
{% block content %}
<h1 class="text-2xl font-bold tracking-tight">Team screenshots</h1>
<form
action="{{ url_for('upload') }}"
method="post"
enctype="multipart/form-data"
class="mt-4 rounded-2xl border-2 border-dashed border-gray-300 bg-white p-10 text-center transition-colors hover:border-gray-400 hover:bg-gray-100/50"
>
<p class="text-3xl">📎</p>
<label for="screenshot" class="mt-2 block cursor-pointer text-sm font-medium text-gray-700 underline">
Choose screenshots to add
</label>
<input id="screenshot" type="file" name="screenshot" accept="image/*" required class="sr-only" />
<p class="mt-1 text-xs text-gray-500">PNG, JPEG, WebP or GIF — thumbnails are automatic</p>
<button
type="submit"
class="mt-4 rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-800"
>
Upload
</button>
</form>
<div class="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{% for snap in snaps %}
<a
href="{{ url_for('snap_detail', snap_id=snap['id']) }}"
class="group overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-md"
>
{% if snap['thumbnail'] %}
<img src="{{ snap['thumbnail'] }}" alt="{{ snap['filename'] }}" class="aspect-video w-full object-cover" />
{% else %}
<div class="flex aspect-video w-full items-center justify-center bg-gray-100 text-3xl">🖼️</div>
{% endif %}
<div class="p-3">
<p class="truncate text-sm font-medium text-gray-900">{{ snap['filename'] }}</p>
<p class="mt-0.5 text-xs text-gray-500">{{ snap['downloads'] }} downloads</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">
Nothing here yet — upload your first screenshot.
</p>
{% endfor %}
</div>
{% endblock %}
And wire up the gallery route to feed it (replacing the placeholder):
# app.py
@app.get("/")
def gallery():
db = get_db()
snaps = db.execute(
"SELECT * FROM snaps ORDER BY created_at DESC, id DESC LIMIT 60"
).fetchall()
# Thumbnails: transform() is idempotent — identical params always return
# the same variant, so we can ask on every render. Ready variants get a
# short-lived signed URL; first renders fall back to the emoji card.
enriched = []
for snap in snaps:
item = dict(snap)
item["thumbnail"] = None
try:
variant = apulodi.files.transform(
snap["apulodi_file_id"], width=640, format="webp", quality=80
)
if variant["status"] == "ready":
signed = apulodi.files.variant_download_url(
snap["apulodi_file_id"], variant["id"], expires_in_seconds=3600
)
item["thumbnail"] = signed["url"]
except Exception:
pass # deleted file pending webhook cleanup — show the card anyway
enriched.append(item)
return render_template("gallery.html", snaps=enriched)
Step 6 — Detail page and downloads
Clicking a card opens a detail view with a full download button:
<!-- templates/detail.html -->
{% extends "base.html" %}
{% block content %}
<div class="mx-auto max-w-2xl">
<a href="{{ url_for('gallery') }}" class="text-sm text-gray-500 underline">← Back to gallery</a>
<div class="mt-4 rounded-2xl border border-gray-200 bg-white p-8 text-center shadow-sm">
{% if thumbnail %}
<img src="{{ thumbnail }}" alt="{{ snap['filename'] }}" class="mx-auto max-h-96 rounded-xl" />
{% else %}
<div class="mx-auto flex h-56 w-full items-center justify-center rounded-2xl bg-gray-100 text-6xl">🖼️</div>
{% endif %}
<h1 class="mt-5 break-all text-xl font-semibold text-gray-900">{{ snap['filename'] }}</h1>
<p class="mt-1 text-sm text-gray-600">
{{ (snap['size'] / 1024) | round(1) }} KB · {{ snap['downloads'] }} downloads
</p>
<a
href="{{ url_for('download', snap_id=snap['id']) }}"
class="mt-6 block w-full rounded-lg bg-black px-4 py-2.5 font-medium text-white transition-colors hover:bg-gray-800"
>
Download original
</a>
{% endblock %}
# app.py
import os
from flask import abort
@app.get("/snaps/<snap_id>")
def snap_detail(snap_id):
db = get_db()
snap = db.execute("SELECT * FROM snaps WHERE id = ?", (snap_id,)).fetchone()
if snap is None:
abort(404)
thumbnail = None
try:
variant = apulodi.files.transform(
snap["apulodi_file_id"], width=1024, format="webp", quality=85
)
if variant["status"] == "ready":
signed = apulodi.files.variant_download_url(
snap["apulodi_file_id"], variant["id"], expires_in_seconds=3600
)
thumbnail = signed["url"]
except Exception:
pass
return render_template("detail.html", snap=snap, thumbnail=thumbnail)
@app.get("/snaps/<snap_id>/download")
def download(snap_id):
db = get_db()
snap = db.execute("SELECT * FROM snaps WHERE id = ?", (snap_id,)).fetchone()
if snap is None:
abort(404)
# 5-minute signed URL, served directly from storage — APULODI never
# proxies the bytes and neither does your app.
signed = apulodi.files.download_url(snap["apulodi_file_id"], expires_in_seconds=300)
with db:
db.execute(
"UPDATE snaps SET downloads = downloads + 1 WHERE id = ?", (snap_id,)
)
return redirect(signed["url"])
Restart and try the loop: upload a screenshot, watch the gallery card populate with a real WebP thumbnail (the first view processes it, every view after that is cached by the platform), open the detail page, download. You now have a working gallery in about 150 lines.
Step 7 — Stay in sync with webhooks
Right now your database and APULODI only agree when your code changes something. If a screenshot is deleted through the dashboard, the gallery keeps rendering a ghost. Webhooks close that gap: APULODI POSTs a signed event to your app within seconds of every state change.
Two things must exist before events can flow: a publicly reachable URL for your dev server, and a webhook registration that points at it.
1. Put a tunnel in front of your dev server. APULODI's servers must be
able to POST to your machine, and localhost is not reachable from the
outside. In a second terminal:
ngrok http 5000
# or
cloudflared tunnel --url http://localhost:5000
Note the public URL it prints — with Cloudflare it looks like
https://random-words-1234.trycloudflare.com.
2. Register the webhook in the dashboard (Project → Webhooks). The
endpoint URL is your tunnel URL plus the receiver path — for example
https://random-words-1234.trycloudflare.com/webhooks/apulodi. Tunnel tools
rotate free URLs on restart, so update the webhook if the URL changes. The
signing secret is shown exactly once — it goes in .env:
# .env
APULODI_WEBHOOK_SECRET=whsec_your_secret_here
3. Write the receiver. Verify the signature over the raw body, reject stale events, deduplicate on the event ID, then dispatch:
# app.py
import json
from datetime import datetime, timezone
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. (For production, also record event["id"] in a table 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 snaps WHERE apulodi_file_id = ?", (file_id,))
# 4. Always acknowledge quickly. A 200 tells APULODI to stop retrying.
return {"ok": True}
Remember to import os at the top of app.py (import os alongside
uuid), and add APULODI_WEBHOOK_SECRET=whsec_... to .env. Restart and
test: delete a screenshot from the APULODI dashboard — the card vanishes
from the gallery within seconds. When you deploy for real, the endpoint URL
becomes your production origin (https://your-app.com/webhooks/apulodi) —
no tunnel needed.
Where to take it next
The skeleton is done; the interesting decisions are all yours now:
- Bulk uploads. Swap the file input for
multipleand loop overrequest.files.getlist("screenshot")— the SDK handles the rest. - Async thumbnails. For snappier first renders, fire
transform()for every new snap in a background thread and let thefile.processedwebhook flip the card to its thumbnail. - Expiring gallery. Add an
expires_atcolumn and a scheduled task that callsapulodi.files.delete()for old snaps — the webhook cleans up your rows in turn. - Authentication. Drop Flask-Login in front of the gallery and scope snaps per user — the webhook is already tenant-safe because you own the file-ID mapping.
A production checklist
-
APULODI_API_KEYandAPULODI_WEBHOOK_SECRETfrom the environment, never committed (.envis ignored — keep it that way) - Webhook signature verified over the raw body on every delivery
- Webhook event IDs recorded and deduplicated (deliveries repeat)
- Handlers acknowledge in under a second; slow work goes to a queue
- Upload size and content-type validated before the bytes move (as above)
- Tailwind compiled, not served from the CDN
-
secret_keyset from the environment, not hardcoded
Go deeper
The Python package is pip install apulodi — the
PyPI page carries the full README with
every resource and parameter, and the
files API reference documents the underlying
endpoints. The webhook event catalogue lives in the
webhooks guide. If you build something with
this — or hit a snag on any step — that is genuinely the best feedback we
can get.