docs
/
Walkthroughs

Set a logo and favicon

Upload an image, generate favicon.ico and favicon.png from it, and wire both into the site record — the smallest complete task that touches files, a generator and a write.

A site record carries its own branding. Setting it is three calls: upload the image, generate the favicons from it, patch the record. It is a small job, and a good one to do first because it exercises multipart upload, a generator endpoint and an optimistic-concurrency write in one sitting.

1. Prepare a square source

The favicon generator resizes whatever you give it. It does not crop, so a wide wordmark becomes an unreadable smear at 16×16.

Give it a square image, at least 512×512, with the mark filling the frame:

# a square badge — fine as-is
magick logo-badge.png -resize 512x512 -background white -gravity center \
  -extent 512x512 icon.png

# a wide wordmark — crop to the emblem first, don't letterbox it
magick logo-wide.png -crop 270x263+5+0 +repage \
  -background white -alpha remove -gravity center \
  -resize 512x512 -extent 512x512 icon.png

If the brand has no square mark at all, build one from the site's own CSS — its brand colour and its initial or glyph. That is defensible; inventing a logo is not.

2. Upload it

POST /repository/file/upload takes multipart/form-data with two fields: file, and an optional location giving a folder inside your org's bucket.

curl -s -X POST "$APPMINT_HOST/repository/file/upload" \
  -H "orgid: $APPMINT_ORG" \
  -H "Authorization: Bearer $TOKEN" \
  -F "location=brand" \
  -F "[email protected]"
{
  "path": "your-org/brand/icon.png",
  "signedUrl": "https://…digitaloceanspaces.com/your-org/brand/icon.png?AWSAccessKeyId=…&Expires=2104756157&Signature=…",
  "xs": "https://…/thumbnails/brand/icon_xs.png?…",
  "sm": "https://…/thumbnails/brand/icon_sm.png?…",
  "md": "https://…/thumbnails/brand/icon_md.png?…"
}

Three things to notice:

  • path is org-prefixed. your-org/brand/icon.png. Other endpoints want the path without that prefix — see the next step.
  • signedUrl is the public-facing URL. It carries a signature and an expiry, and the expiry is far out (2036 at the time of writing), so it is safe to embed.
  • Thumbnails are generated automatically at three sizes. You did not ask for them and you do not have to use them.

An uploaded PNG is served with Content-Type: application/octet-stream. Browsers sniff image bytes, so it still renders correctly in <img> and CSS background-image. SVG does not survive this — the browser refuses to render an SVG served as octet-stream. Inline SVG markup into the page instead of linking to an uploaded file.

Doing it in Python, because you will want this in a script:

import mimetypes, os, uuid, urllib.request

def upload(path, dest="brand", token=None):
    b = "----bm" + uuid.uuid4().hex
    fn = os.path.basename(path)
    ct = mimetypes.guess_type(fn)[0] or "application/octet-stream"
    body  = f'--{b}\r\nContent-Disposition: form-data; name="location"\r\n\r\n{dest}\r\n'.encode()
    body += (f'--{b}\r\nContent-Disposition: form-data; name="file"; filename="{fn}"\r\n'
             f'Content-Type: {ct}\r\n\r\n').encode() + open(path, "rb").read() + b"\r\n"
    body += f"--{b}--\r\n".encode()
    r = urllib.request.Request(API + "/repository/file/upload", method="POST", data=body,
        headers={"Content-Type": "multipart/form-data; boundary=" + b,
                 "orgid": ORG, "Authorization": "Bearer " + token, "User-Agent": UA})
    with urllib.request.urlopen(r, timeout=120) as x:
        return json.loads(x.read())

3. Generate the favicons

POST /repository/file/create_favicon reads a source image from your bucket and writes favicon.ico and favicon.png at the bucket root.

curl -s -X POST "$APPMINT_HOST/repository/file/create_favicon" \
  -H "orgid: $APPMINT_ORG" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "location": "brand/icon.png" }'

location is the path without the org prefix. The upload returned your-org/brand/icon.png; pass brand/icon.png. Passing the full path scopes it twice and the file is not found.

{
  "ico": {
    "signedUrl": "https://…/your-org/favicon.ico?AWSAccessKeyId=…&Signature=…",
    "path": "your-org/favicon.ico"
  },
  "png": {
    "signedUrl": "https://…/your-org/favicon.png?AWSAccessKeyId=…&Signature=…",
    "path": "your-org/favicon.png"
  }
}

The .ico is multi-resolution — 64, 48, 32 and 16px in one container — which is why every generated favicon.ico is the same byte length regardless of the image inside it. That is not a bug and not a sign your source was ignored; compare hashes if you want to be sure.

The generated favicons are private objects: fetching them without the signature returns 403. The signed URLs work and do not expire for years, so this only matters if you were planning to hard-code https://…/your-org/favicon.ico somewhere. Use the URL the endpoint returns, verbatim.

4. Patch the site record

Read the record to get its current version, then write the fields:

s, rec = req("GET", f"/repository/get/site/{SK}", token=t)
version = rec["version"]

s, _ = req("POST", f"/repository/update-partial/site/{SK}", {
    "sk": SK,
    "version": version,
    "data.logo":    {"path": up["path"], "url": up["signedUrl"]},
    "data.favicon": fav["png"]["signedUrl"],          # a plain string
    "data.faviconAssets": {                            # keep the detail elsewhere
        "ico": fav["ico"]["signedUrl"],
        "png": fav["png"]["signedUrl"],
        "path": fav["ico"]["path"],
    },
}, token=t)

201 means it took.

data.favicon must be a string URL. data.logo may be an object. They are not symmetrical, and the difference is silent.

The renderer normalises the logo — typeof rawLogo === 'string' ? rawLogo : rawLogo?.url — so {path, url} works there. The favicon is passed straight through to the framework's icons field with no such unwrapping. Give it an object and no icon link is emitted at all: the site quietly keeps the platform default, the record looks correctly set, and nothing anywhere reports a problem.

Store the URL as a string and park the rest under a key of your own, as above.

Check whether data.logo is already set before overwriting it. A site may have a designed logo already, in which case you want the favicon only. Reading the record first costs one call.

If you deploy full-document HTML — see Build and deploy a whole site — you might expect your own <head> links to win. They do not. The renderer deliberately removes every <link rel="icon">, apple-touch-icon and mask-icon from page HTML, so that a template with a hard-coded /favicon.ico cannot shadow the real one:

// html-renderer: favicons are owned by the metadata system, not the page
const headHTML = stripIconLinks(getHeadHTML(screenHtml));

So this is not a choice between two mechanisms. The site record is the only one that works. Leave the links in your source if you want the page to look right opened from disk — they are harmless — but do not expect them to have any effect once deployed.

A relative path would not have resolved anyway: href="logo.png" on the route /pricing asks for /logo.png, which does not exist.

6. Logos in CSS are a different story

CSS is processed — <style> and <link rel="stylesheet"> survive and apply. But background: url('logo.png') resolves against the route, not your source folder, so on /pricing it looks for /logo.png and finds nothing:

.brand-mark {
  width: 28px; height: 28px; border-radius: 7px;
  background: url('https://…/your-org/brand/icon.png?…') center/cover no-repeat;
}

When you HTML-escape a signed URL into an attribute, the & separators must become &amp;. A raw & inside an HTML attribute is tolerated by browsers but not by every parser in the chain, and the signature breaks when one of them splits it.

Verify

A 201 only proves the record changed. There are three things to check, and the last one is the one that actually matters to a visitor.

The record reads back:

s, rec = req("GET", f"/repository/get/site/{SK}", token=t)
print(type(rec["data"]["favicon"]).__name__)   # must be 'str', not 'dict'

The file is fetchable:

with urllib.request.urlopen(
        urllib.request.Request(rec["data"]["favicon"], headers={"User-Agent": UA})) as x:
    print(x.status, x.headers.get("Content-Type"), len(x.read()), "bytes")
# 200 image/png 122831 bytes

The rendered page emits it. This is the check that catches the object-vs-string mistake:

curl -s "https://your-site.example/?cb=$RANDOM" \
  | grep -oE '<link rel="icon" href="[^"]{0,80}'
<link rel="icon" href="https://…digitaloceanspaces.com/your-org/favicon.png?AWSAccessKeyId=…

If you instead see href="/favicon.ico", the page is falling back to the platform default and your value was not applied — check the string-vs-object rule above before anything else.

Doing several orgs in one run? Hash the results. Identical hashes mean you uploaded the same source image more than once:

md5 -q /tmp/fav_*.ico | sort -u | wc -l     # should equal the number of orgs

Every generated .ico is the same byte length, so length alone tells you nothing.

Next: Collect form submissions, where permissions start to matter.