Media uploads
Browser uploads never proxy image bytes through a litecommerce API request.
Instead you mint a signed upload URL, PUT the file straight to storage,
then confirm the upload so the API can validate and attach metadata. Three
steps. Examples reuse the
api() helper.
1. Mint a signed URL
POST /merchant/uploads/signed-url — permission: catalog:write. Owner/Admin
retain broad access; Staff needs that group.
const {
imageId,
uploadUrl,
token: uploadToken, // Supabase upload token — pair with storagePath in step 2
bucket,
publicUrl,
storagePath,
} = await api<{
imageId: string;
uploadUrl: string;
token: string;
bucket: string;
publicUrl: string;
storagePath: string;
}>("/merchant/uploads/signed-url", {
method: "POST",
token, // merchant bearer (the api() helper's option)
body: JSON.stringify({
ownerType: "item", // "item" | "collection"
ownerId: itemId, // must belong to your tenant (else 404)
filename: "trail-pack.jpg",
mimeType: "image/jpeg", // jpeg | png | webp | avif
bytes: 482113, // size; capped at 15 MB by default
}),
});The service verifies your org owns the target item/collection before issuing
the URL, and computes publicUrl itself. The signed URL expires after about
two hours; use the returned expiresAt rather than assuming a lifetime.
2. Upload the file to storage
Upload the bytes with the Supabase Storage client's uploadToSignedUrl
helper, passing the storagePath + token from step 1. (A raw PUT against
the signed URL works in some environments but isn't the SDK-blessed path —
use the helper.)
import { createClient } from "@supabase/supabase-js";
// Publishable key only — the signed token from step 1 authorizes the write,
// so this client is just a transport vehicle for uploadToSignedUrl.
const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);
await supabase.storage
.from(bucket)
.uploadToSignedUrl(storagePath, uploadToken, fileBlob);3. Confirm the upload
Attach the image to the item (or collection) so it shows up in reads:
await api(`/merchant/items/${itemId}/images`, {
method: "POST",
token, // merchant bearer
body: JSON.stringify({
imageId, // from step 1
storagePath, // from step 1
alt: "Trail Pack 38L, granite colorway", // required (a11y)
isPrimary: true,
}),
});Don't send a url — the service derives the public URL from storagePath
itself and rejects a client-supplied one (it was a spoofing vector). alt
is required.
Setting isPrimary: true atomically clears the primary flag on the item's
other images, so there's always exactly one hero. Collections use the same
flow against /merchant/collections/:id/images.
Managing images
await api(`/merchant/items/${itemId}/images`, { token }); // list
await api(`/merchant/items/${itemId}/images/${imageId}`, { // update meta
method: "PATCH", token,
body: JSON.stringify({ alt: "…", focalX: 0.5, focalY: 0.3, isPrimary: true }),
});
await api(`/merchant/items/${itemId}/images/order`, { // reorder
method: "PATCH", token, body: JSON.stringify({ imageIds: ["a", "b"] }),
});
await api(`/merchant/items/${itemId}/images/${imageId}`, { // delete
method: "DELETE", token,
});focalX / focalY (0–1) let a BYO frontend crop responsively around the
subject. Deleting an image removes the metadata row immediately; the storage
object becomes visible to media reconciliation. When quarantine is enabled for
the environment, the sweep can move it non-destructively after the grace
window; it does not delete the object.
Checkout logo
Checkout logos use a dedicated three-endpoint contract and require
settings:manage. Direct external image URLs are not accepted.
1. Mint and upload
const signedLogo = await api<{
imageId: string;
token: string;
bucket: string;
storagePath: string;
}>("/merchant/uploads/checkout-logo/signed-url", {
method: "POST",
token,
body: JSON.stringify({
filename: file.name,
mimeType: file.type, // image/jpeg | image/png | image/webp | image/avif
bytes: file.size, // 1 byte through 5 MiB
}),
});
await supabase.storage
.from(signedLogo.bucket)
.uploadToSignedUrl(signedLogo.storagePath, signedLogo.token, file, {
contentType: file.type,
});2. Confirm and attach
const logo = await api<{
id: string;
storagePath: string;
url: string;
mimeType: "image/jpeg" | "image/png" | "image/webp" | "image/avif";
bytes: number;
width: number;
height: number;
createdAt: string;
}>("/merchant/uploads/checkout-logo/confirm", {
method: "POST",
token,
body: JSON.stringify({
imageId: signedLogo.imageId,
storagePath: signedLogo.storagePath,
}),
});Confirmation downloads and fully decodes the object before attaching it; the client-reported MIME, byte count, filename, and dimensions are not proof. A replacement repeats these two steps and receives a new immutable URL. Both upload gates apply the storage cap to the replacement's full byte count while the prior immutable object remains retained for non-destructive reconciliation; they never assume those prior bytes have already been released. While reconciliation accounting is enabled, retained logo bytes remain in reported storage usage through detachment and non-destructive quarantine.
3. Remove
await api("/merchant/uploads/checkout-logo", {
method: "DELETE",
token,
});Removal clears both upload-backed metadata and any retained legacy external
URL. GET /merchant/settings includes checkoutSettings.logoAsset only when
the stored asset is valid for the current tenant and environment; otherwise it
is absent and hosted surfaces fall back to the tenant name.
- Accepted formats: static JPEG, PNG, WebP, or AVIF. SVG and animated or multi-page images are rejected.
- Maximum size: 5 MiB and 4096 × 4096 pixels.
- The browser uploads to a tenant/versioned path, then the API downloads and fully decodes the object before attaching it. Filename, client MIME, byte count, and dimensions are never accepted as proof.
- Preview attaches only the Preview project/bucket and platform-media URL; Production attaches only Production. Copying a saved setting between those environments does not make it renderable.
- Replacing a logo creates a new immutable URL, so browser and CDN caches cannot pin shoppers to old bytes. The API records every server-minted exact path before returning its signed URL, so an abandoned upload remains discoverable even if the tenant slug changes. When media quarantine is enabled, detached logos are eligible for its bounded, non-destructive exact-path flow; unconfirmed uploads become eligible one day after the two-hour upload token expires. The sweep never inventories a released slug namespace and does not delete objects.
- Removing the logo makes hosted checkout and account/sign-in surfaces fall back to the tenant name.
A legacy external checkoutSettings.logoUrl remains visible in Admin until an
operator uploads a replacement or explicitly removes it. Saving unrelated
checkout settings does not delete it, but hosted surfaces do not report or
render it because their tight image CSP intentionally trusts platform media,
not arbitrary tenant origins.
Related
- Catalog: products & variants — what images attach to
- Collections & pages — collection cover images