GuidesProduct images

Add an image to a product

Product imagery is content-addressed. You never upload bytes through the API: you ask for a presigned upload target, PUT the file straight to object storage, finalize the asset so the platform confirms the object landed, then attach the resulting ref to a product. The public catalog resolves that ref to a fetchable image_url for every consumer.

This guide uses an fdk_ merchant credential. The tenant is carried by the key (never in a path or body), and two scopes are involved: manage:own_media for the upload/finalize steps and manage:own_catalog to attach the image.

The examples use the origin https://api.ruo.pro and the tenant slug nova-peptide. Your credential decides the real tenant; the slug only appears again when you read the resulting public catalog.

Before you start

  • An fdk_ credential with manage:own_media and manage:own_catalog.
  • A product to attach to (see Add products to a catalog).
  • The image file’s SHA-256, lowercase hex. On macOS: shasum -a 256 photo.png.

Steps

Presign an upload

Declare the file you are about to upload. The platform records a pending, tenant-scoped asset row and hands back a presigned target.

curl -X POST https://api.ruo.pro/v1/merchant/media/uploads \
  -H "Authorization: Bearer fdk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "product_image",
    "content_type": "image/png",
    "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    "byte_size": 20481
  }'

A 201 returns the pending asset and the upload target:

{
  "asset_id": "ast_9f2c…",
  "asset_ref": "asset:nova-peptide/e3b0…b855.png",
  "storage_key": "stores/nova-peptide/assets/e3b0…b855.png",
  "cdn_url": "https://assets.ruo.pro/stores/nova-peptide/assets/e3b0…b855.png",
  "status": "pending",
  "replayed": false,
  "upload": {
    "url": "https://…/stores/nova-peptide/assets/e3b0…b855.png?X-Amz-Signature=…",
    "method": "PUT",
    "fields": {
      "content-type": "image/png",
      "x-amz-checksum-sha256": "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
    }
  }
}

Keep the asset_id (to finalize) and the asset_ref (to attach). Because the ref is derived from the content hash, re-presigning the same bytes is idempotent — replayed: true means you already have this asset.

Upload the bytes with a PUT

Send the file directly to upload.url using upload.method (PUT). Every entry in upload.fields is a request header you must echo back — the content-type and the base64 x-amz-checksum-sha256 the store verifies the object against.

curl -X PUT "<upload.url from the previous step>" \
  -H "content-type: image/png" \
  -H "x-amz-checksum-sha256: 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" \
  --data-binary @photo.png

No byte flows through the commerce API — the upload goes straight to storage.

Finalize the asset

Tell the platform the object landed. This confirms the upload and flips the asset from pending to ready; the optional dimensions are stored as metadata.

curl -X POST https://api.ruo.pro/v1/merchant/media/assets/ast_9f2c…/finalize \
  -H "Authorization: Bearer fdk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "width": 1200, "height": 1200, "byte_size": 20481 }'

A 200 returns the asset with status: "ready". A 409 means no object is at the presigned key yet — upload before finalizing.

Attach the ref to a product

Set the product’s image to the asset_ref from step one. The ref must match asset:<storeKey>/<sha256>.<ext> or the call is refused 400 (invalid_asset_ref).

curl -X PUT https://api.ruo.pro/v1/merchant/catalog/products/prod_123/image \
  -H "Authorization: Bearer fdk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "image_asset_ref": "asset:nova-peptide/e3b0…b855.png" }'

The response is { "product": { … } }. Send "image_asset_ref": null to clear the image.

Consume the resolved image_url on the storefront

The public catalog carries both fields: image_asset_ref (the raw ref you set) and image_url (that ref resolved against the platform’s asset origin). Clients should prefer image_url — it is null only when no image is set or the origin is unconfigured, never a broken URL.

import { createStorefrontClient } from "@dscodotco/sdk";
 
const store = createStorefrontClient({
  apiUrl: "https://api.ruo.pro",
  tenant: "nova-peptide",
  storefrontToken: process.env.FLIGHTDECK_STOREFRONT_TOKEN!, // server-side only
});
 
const { products } = await store.products.list({ limit: 24 });
for (const p of products) {
  // p.image_url is ready to drop into an <img src>; null means no image.
  console.log(p.name, p.image_url);
}

Error shape

Every failure is the nested envelope { "error": { "code", "message" } } — for example invalid_asset_ref on a malformed ref, or 404 when the asset or product does not belong to your tenant.