Skip to content

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 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.