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.pngIf 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:
pathis org-prefixed.your-org/brand/icon.png. Other endpoints want the path without that prefix — see the next step.signedUrlis 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.
5. Page-level icon links are stripped
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 &. 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 bytesThe 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 orgsEvery generated .ico is the same byte length, so length alone tells you nothing.
Next: Collect form submissions, where permissions start to matter.