← All posts

Build a screenshot gallery with Flask, Tailwind and APULODI

September 25, 202614 min readTutorialsPythonFlask

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:

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

Step 1 — Create the project

bash
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:

python
# 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)
bash
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:

python
# 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:

python
# 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
# .env
APULODI_API_KEY=apk_test_your_key_here

Then create one shared client, imported everywhere:

python
# 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:

python
# 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:

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).

html
<!-- 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:

html
<!-- 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):

python
# 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:

html
<!-- 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 %}
python
# 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:

bash
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
# .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:

python
# 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:

A production checklist

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.