Skip to content

Media uploads

Image bytes never pass through the litecommerce API. Instead you mint a signed upload URL, PUT the file straight to storage, then confirm the upload to 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 is short-lived (minutes).

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";
 
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_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 is cleaned up by a background sweep.