Skip to main content
Two things can be uploaded: a user’s avatar and an organization’s logo. Both work the same way, and neither sends bytes through the API.
When the server has no storage configured, both routes answer 501 uploads_unavailable and GET /v1/session reports uploads_available: false — read that flag before showing an upload button, rather than hitting the 501 mid-flow.

The flow

1

Ask for a signed URL

2

PUT the file straight to the bucket

Send exactly the headers that came back — they are part of the signature.
3

Save the public URL

Saving is a separate call on purpose. The upload can fail halfway, and a profile pointing at a half-written object would be worse than one still pointing at the old image. Identical, with two differences: it needs organization:update, and the URL is saved with PATCH /v1/organization.

What is allowed

An allowlist, not a blocklist — deliberately. These files are served back from a public host, and text/html among them would be a stored-XSS vector on that host. Content type and size are part of the signature, so the bucket enforces them. The check on the API side is there to give you a readable error, not to be the enforcement.

Replacing an image

Every upload gets a random object key, so replacing an image never overwrites the old object. A URL cached in a CDN or an email cannot start serving somebody else’s picture. The old object is left behind. Pruning is a housekeeping job, not part of the request.

Doing it from the browser

The three steps work the same from client code. A presigned URL is short-lived and bound to one object, one content type and one size, so handing it to a browser gives away nothing reusable.
A session token in browser code is only as safe as where you keep it. If your app has a server, the shape worth copying is to sign the first and third calls there and let the browser do only the PUT — the bucket upload is the only one of the three that needs to happen from the device holding the file.