Skip to content

HTTP endpoints

BrandKit exposes five routes. There is no versioned API surface and no stability guarantee — these are the endpoints the built-in UI talks to, documented so you can script against them.

MethodPathCSRFRate limitPurpose
GET/defaultThe application UI; also issues the CSRF token
POST/uploadrequired5/minGenerate a brand kit
POST/analyzerequireddefaultColour and white-area analysis only
GET/format-infodefaultFormat catalogue, categories and presets
GET/download-zip/<filename>defaultDownload a generated archive

The default rate limit is 200 per day and 50 per hour, per client IP, held in process memory.

Authentication and CSRF

There is no authentication. Any client that can reach the port can use every endpoint. Put an authenticating proxy in front of a deployment — see Deployment.

Both POST endpoints are protected by Flask-WTF's CSRFProtect. A token is minted per session and rendered into the page at GET /; a scripted client has to fetch it and carry the session cookie.

bash
COOKIES=$(mktemp)

# 1. Fetch the page, keep the session cookie, scrape the token
TOKEN=$(curl -s -c "$COOKIES" http://localhost:8000/ \
  | grep -o "csrf_token', '[^']*'" | head -1 | cut -d"'" -f3)

# 2. Use both on the POST
curl -s -b "$COOKIES" \
  -F "csrf_token=$TOKEN" \
  -F "file=@logo.png" \
  -F "selected_formats=website" \
  -F "output_formats=png" \
  http://localhost:8000/upload

Without a valid token you get 400 Bad Request — The CSRF token is missing.

Scripting this properly

See the Python client at the bottom of this page for a version that handles the session, retries and the ZIP download.


POST /upload

Uploads a source image and generates every selected format in every selected output type.

Content type: multipart/form-dataRate limit: 5 per minute per IP

File

FieldRequiredNotes
fileyespng, jpg, jpeg, gif or webp; max BRANDKIT_MAX_UPLOAD_MB (16 by default)

Selection

FieldRepeatableDefaultNotes
selected_formatsyesall 45 formatsone field per format key
output_formatsyespngpng, jpg, webp, ico
variations_modenofalse"true" renders 10 variations per format
fill_white_with_prominentnofalse"true" replaces detected white areas with the prominent colour

Omitting selected_formats is not a shortcut

It does not mean "none" — it means all 45, multiplied by every output type. Always send the formats you actually want.

Preprocessing

All optional. Booleans are the literal string "true"; anything else is false.

FieldTypeDefault
grayscaleboolfalse
bwboolfalse
invertboolfalse
hue_shiftint (−180…180)0
temperatureint (−100…100)0
enhance_contrastboolfalse
saturationfloat1.0
brightnessfloat1.0
sharpenboolfalse
sharpen_radiusfloat1.0
apply_blurboolfalse
blur_radiusfloat2.0
noise_reductionboolfalse
noise_strengthint1
vignetteboolfalse
vignette_strengthfloat0.5
add_watermarkboolfalse
watermark_textstring© BrandKit
watermark_opacityfloat0.3
auto_cropboolfalse
crop_paddingint10
remove_backgroundboolfalse
background_removal_methodauto|object|person|animeauto
background_colorhex or transparenttransparent
edge_smoothboolfalse
smooth_radiusfloat2.0
shadow_effectboolfalse
shadow_opacityfloat0.3
shadow_blurint4
shadow_offset_xint5
shadow_offset_yint5
enhance_qualityboolfalse
qualityint (1–100)95
strip_metadataboolfalse

Defaults for most of these come from preprocessing_options in config.json; the last seven are hard-coded in app.py.

Response — 200 OK

json
{
  "success": true,
  "message": "File processed successfully",
  "results": {
    "original": {
      "path": "static/uploads/8f3c…-acme.png",
      "url": "/static/uploads/8f3c…-acme.png"
    },
    "analysis": {
      "prominent_color": [37, 99, 235],
      "has_white_area": true,
      "white_area_ratio": 0.42
    },
    "website": {
      "dimensions": "1200x630",
      "description": "Standard website banner",
      "outputs": {
        "png": {
          "path": "static/uploads/acme_website.png",
          "url": "/static/uploads/acme_website.png"
        }
      }
    },
    "favicon_ico": {
      "path": "static/uploads/acme_favicon.ico",
      "url": "/static/uploads/acme_favicon.ico"
    },
    "zip": {
      "path": "static/uploads/acme_brandkit_20260905143012.zip",
      "url": "/static/uploads/acme_brandkit_20260905143012.zip",
      "filename": "acme_brandkit_20260905143012.zip"
    }
  }
}

Every key in results other than original, analysis, zip, favicon_ico and variations is a format key. With variations_mode=true, the per-format entries move under results.variations.<Label>.<format>.outputs.

Errors

CodeBodyCause
400{"error": "No file part"}no file field
400{"error": "No selected file"}empty filename
400{"error": "File type not allowed"}extension not in the allowlist
400{"error": "Invalid image file"}Pillow could not decode it; the upload is deleted
400CSRF error pagemissing or stale token
413over MAX_CONTENT_LENGTH
429rate limit
500{"error": "An unexpected error occurred during processing."}see server logs

Individual formats that fail are skipped silently — they are simply absent from results. A 200 does not guarantee you got everything you asked for; compare the keys.


POST /analyze

Analyses an image without generating anything. Useful to preview what the smart-fill heuristic will decide.

Content type: multipart/form-data · Field: file · CSRF: required

json
{
  "success": true,
  "analysis": {
    "prominent_color": [37, 99, 235],
    "has_white_area": true,
    "white_area_ratio": 0.42
  }
}

prominent_color is [r, g, b], ignoring near-white pixels. has_white_area is true when more than 15% of the image is near-white. The file is written to a temporary path, read, and deleted in a finally block — nothing is retained.

On error the endpoint returns {"success": false, "error": "…"} with 400 or 500.

Error strings are echoed back

Failure responses interpolate the exception message into error. That can leak filesystem paths to the caller. One more reason not to expose this to strangers.


GET /format-info

No parameters, no CSRF, no side effects. Good liveness probe.

json
{
  "success": true,
  "categories": { "Social Media": ["social", "twitter", "…"] },
  "purposes":   { "social": ["social", "twitter", "…"] },
  "recommendations": {
    "Social Media Pack": ["social", "twitter", "instagram", "facebook", "social_icon_large"],
    "Website Essentials": ["website", "favicon", "hero_desktop", "background_desktop"],
    "Mobile App Pack": ["webapp", "mobile", "square_logo_small", "square_logo_large"],
    "Complete Branding": ["social", "website", "favicon", "webapp", "background_desktop"]
  }
}

categories reflects your live config.json. purposes and recommendations are hard-coded in app.py and are not kept in sync with the catalogue — a few keys they reference no longer exist. Treat them as UI hints, not as a contract.


GET /download-zip/<filename>

Streams an archive from the upload folder as an attachment.

bash
curl -OJ http://localhost:8000/download-zip/acme_brandkit_20260905143012.zip

The filename is validated: it must survive secure_filename() unchanged, end in .zip, and resolve to a path inside the upload folder. Anything else — including traversal attempts — returns 404, as does a file that has already been swept by the retention cleanup.

The route is unauthenticated, and so is /static/uploads/

Validation stops path traversal, not access. This route performs no authorisation check, and every generated file is also reachable directly under /static/uploads/<name>. Filenames are predictable (<basename>_<format>.<ext>), so on a shared instance one user's assets are guessable by another. Authenticate at the proxy. See Privacy.


A Python client

python
import re
import requests

BASE = "http://localhost:8000"

s = requests.Session()
page = s.get(f"{BASE}/").text
token = re.search(r"csrf_token', '([^']+)'", page).group(1)

with open("logo.png", "rb") as fh:
    r = s.post(
        f"{BASE}/upload",
        files={"file": fh},
        data=[
            ("csrf_token", token),
            ("selected_formats", "website"),
            ("selected_formats", "favicon"),
            ("selected_formats", "social"),
            ("output_formats", "png"),
            ("output_formats", "ico"),
            ("auto_crop", "true"),
            ("crop_padding", "16"),
            ("quality", "95"),
        ],
        timeout=300,          # background removal can be slow
    )

r.raise_for_status()
results = r.json()["results"]

zip_url = results["zip"]["url"]
archive = s.get(f"{BASE}{zip_url}", timeout=120)
open(results["zip"]["filename"], "wb").write(archive.content)

print("generated:", [k for k in results if k not in
                     ("original", "analysis", "zip")])

Note the repeated selected_formats and output_formats entries — that is why data is a list of tuples rather than a dict.

The 300-second timeout is not paranoia: a first-run background removal downloads a 180 MB model before it does any work.