Guide

Add try-on to a custom store

For stores that aren't on Shopify or WooCommerce: a hand-built storefront, a headless setup, or your own app. You add a button and two small routes on your server; Clothsy AI does the rest.

On Shopify or WooCommerce? Skip this — install the app or plugin and the button is added for you, with no code. Using Next.js? The Next.js guide gives you the route and the button ready-made.

How the pieces fit

  1. The shopper's browser shows a “Try it on” button, takes a photo and asks for consent. It only ever talks to your server.
  2. Your server uploads the photo to Clothsy AI, starts the try-on with your key, and relays progress back to the browser. It doesn't need to store the photo anywhere.
  3. Clothsy AI creates the try-on and hands back an image URL your storefront can display.

Your API key lives only on your server. That's the one rule that matters: a key in browser code can be copied by anyone and used to spend your credits.

1. Add the button and consent

Put a button on the product page that opens a small panel for the photo. Ask for consent there, before anything is uploaded — the shopper must be an adult, it must be their own photo, and they must agree to it being processed.

product-page.html
<!-- On the product page -->
<button id="tryon-open">Try it on</button>

<dialog id="tryon">
  <input id="tryon-photo" type="file" accept="image/jpeg,image/png" />
  <label>
    <input id="tryon-consent" type="checkbox" />
    I'm 18 or over, this is a photo of me, and I agree to it being processed
    to create a virtual try-on. <a href="/privacy">How we use it</a>
  </label>
  <button id="tryon-go">See it on me</button>
  <p id="tryon-status" role="status"></p>
  <img id="tryon-result" alt="You wearing this item" hidden />
</dialog>

In the browser, shrink the photo before sending it. Phones take 5–12 MB photos; the API accepts up to 4 MB, and a 1600-pixel JPEG is plenty for a try-on.

tryon.js — runs in the browser
// Shrinks the photo to at most 1600px and re-encodes it as JPEG. This keeps it
// well under the 4 MB limit and drops the camera's EXIF data (including GPS).
async function shrink(file) {
  const bitmap = await createImageBitmap(file);
  const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
  const canvas = document.createElement("canvas");
  canvas.width = Math.round(bitmap.width * scale);
  canvas.height = Math.round(bitmap.height * scale);
  canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height);
  return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.88));
}

document.getElementById("tryon-go").addEventListener("click", async () => {
  const file = document.getElementById("tryon-photo").files[0];
  const status = document.getElementById("tryon-status");
  if (!file || !document.getElementById("tryon-consent").checked) {
    status.textContent = "Choose a photo and tick the box first.";
    return;
  }

  status.textContent = "Creating your try-on…";
  const form = new FormData();
  form.append("photo", await shrink(file), "photo.jpg");
  form.append("productId", window.PRODUCT_ID);       // your own product id
  form.append("requestId", crypto.randomUUID());      // one per click

  // YOUR server — never Clothsy's API directly. Your key stays there.
  const start = await fetch("/tryon", { method: "POST", body: form });
  const started = await start.json();
  if (!start.ok) { status.textContent = started.message; return; }

  // Ask your server every 2.5 seconds until the result is ready.
  for (let i = 0; i < 72; i++) {
    await new Promise((r) => setTimeout(r, 2500));
    const res = await fetch("/tryon/" + started.id);
    const body = await res.json();
    if (body.status === "success") {
      const img = document.getElementById("tryon-result");
      img.src = body.resultUrl;
      img.hidden = false;
      status.textContent = "";
      return;
    }
    if (body.status === "failed") { status.textContent = body.message; return; }
  }
  status.textContent = "This is taking longer than usual. Please try again.";
});

2. Upload the photo and start the try-on

Your POST /tryon route receives the photo from the browser, uploads it with POST /images, and starts the try-on with the returned id. Uploading is free, and there's nothing to store or clean up on your side.

Look the product up yourself instead of trusting a URL or title sent by the browser — otherwise anyone could use your credits to try on images you don't sell.

SDK (Node.js)
import express from "express";
import multer from "multer";
import { Clothsy, friendlyMessage } from "clothsy-ai";

const app = express();
const clothsy = new Clothsy();                                   // reads CLOTHSY_API_KEY
const upload = multer({ limits: { fileSize: 4 * 1024 * 1024 } }); // the API's limit

// POST /tryon — called by your storefront, runs on your server.
app.post("/tryon", upload.single("photo"), async (req, res) => {
  const product = await db.products.find(req.body.productId);   // your catalogue
  if (!product || !req.file) return res.status(400).json({ message: "Missing photo or product." });

  try {
    const photo = await clothsy.images.upload(req.file.buffer, { contentType: "image/jpeg" });
    const tryon = await clothsy.tryons.create({
      person: { imageId: photo.id },
      garment: { url: product.imageUrl },     // your product photo, on HTTPS
      title: product.title,
      consent: true,                          // the shopper ticked the box
      idempotencyKey: req.body.requestId,     // same click => same try-on
    });
    res.status(202).json({ id: tryon.id });
  } catch (error) {
    res.status(error.status ?? 502).json({ message: friendlyMessage(error) });
  }
});

Use one Idempotency-Key per shopper click — here, the requestId the browser sends. If the request times out and is retried, the photo is simply uploaded again (it's free) and the same key returns the same try-on instead of charging twice.

Alternative: host the photo yourself

If you already keep shopper photos in your own storage, you can pass a URL instead of uploading. Keep the file private and hand out a signed URL that expires in about 15 minutes — an S3 presigned URL, a Google Cloud Storage signed URL or a Cloudinary authenticated URL all work.

host-photo.js — Amazon S3
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";

const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.TRYON_PHOTO_BUCKET; // private bucket, not public

/** Stores the shopper's photo privately and returns a URL that works for 15 minutes. */
export async function hostPhoto(jpegBytes) {
  const key = `tryon-photos/${randomUUID()}.jpg`;
  await s3.send(new PutObjectCommand({
    Bucket: BUCKET, Key: key, Body: jpegBytes, ContentType: "image/jpeg",
  }));
  return getSignedUrl(s3, new GetObjectCommand({ Bucket: BUCKET, Key: key }), {
    expiresIn: 15 * 60,
  });
}

// Then, instead of uploading:
//   person: { url: await hostPhoto(req.file.buffer) }        (SDK)
//   personImageUrl: await hostPhoto(req.file.buffer)         (HTTP)

Whatever you use, the URL must:

  • use HTTPS on the default port;
  • return the image itself with HTTP 200 — redirects are not followed;
  • respond within 12 seconds;
  • point at a JPEG or PNG no larger than 4 MB.

You're then responsible for deleting the photo afterwards. The simplest way is a lifecycle rule on the bucket folder that removes objects after one day, so nothing is left behind even if a request is abandoned halfway.

Your garment images usually qualify as they are: a product photo on your CDN is fine as long as it meets the same rules.

3. Poll through your server

The browser asks your server every few seconds; your server asks Clothsy AI. Poll every 2–3 seconds — most results arrive in under 30 seconds.

SDK (Node.js)
// GET /tryon/:id — your storefront polls this; it asks Clothsy for you.
app.get("/tryon/:id", async (req, res) => {
  try {
    const tryon = await clothsy.tryons.retrieve(req.params.id);
    res.json({ status: tryon.status, resultUrl: tryon.resultUrl, message: tryon.message });
  } catch (error) {
    res.status(error.status ?? 502).json({ message: friendlyMessage(error) });
  }
});

Show shoppers your own wording rather than raw API errors. The SDK's friendlyMessage(error) does this. Without the SDK, switch on code, not on the message text:

server.js
// Only needed without the SDK — its friendlyMessage(error) does this for you.
function messageFor(code) {
  switch (code) {
    case "PERSON_PHOTO_REJECTED":
      return "Please use a clear, well-lit photo of just you, facing the camera.";
    case "IMAGE_REJECTED":
    case "GARMENT_REJECTED":
      return "Virtual try-on isn't available for this photo or item.";
    case "IMAGE_TOO_LARGE":
    case "UNSUPPORTED_IMAGE":
      return "Please use a JPEG or PNG photo under 4 MB.";
    case "RATE_LIMITED":
      return "Lots of people are trying things on right now. Try again in a minute.";
    case "INSUFFICIENT_CREDITS":   // alert yourself too: the button should go quiet
    default:
      return "Virtual try-on isn't available right now. Please try again later.";
  }
}

4. Show the result

When the status is success, put resultUrl into an <img>. It's served from Clothsy AI, works for 24 hours, and needs no key, so the browser can load it directly. Put your “Add to cart” button right next to it — that is the moment the shopper decides.

Add a short caption such as “AI-generated try-on” under the image. The file is already labelled in its metadata, but shoppers can't see that — AI content label explains more.

Before you launch

  • The API key is only in server environment variables — not in your frontend bundle or repository.
  • The consent checkbox is required before anything is uploaded.
  • Your privacy policy says shopper photos are processed by a virtual try-on service to create the image. Ours is at shopper privacy, if you want to link to it.
  • You look products up on your server instead of trusting image URLs from the browser.
  • Try-on results are captioned as AI-generated.
  • You handle INSUFFICIENT_CREDITS — hide the button and alert yourself — so shoppers don't see a broken feature.
  • You know your limits: each account can start 12 try-ons a minute and upload 30 images a minute. Expecting more try-ons than that at peak? Tell us and we'll raise it.

Complete, copy-paste servers in Node.js, Python and Next.js are in Full examples.