This is the full developer documentation for TryOn
# TryOn Documentation
> Virtual try-on for eyewear, watches, shoes, jewelry, and clothes — one API, one embed script.
[Developers](/developers/quickstart/)REST API, storefront embedding, platform guides.
[Panel guide](/panel/getting-started/)Manage products, AR fit, previews, team and keys.
[Shopify app](/shopify/install/)Install the app, theme blocks, enable try-on.
## Popular guides
[Section titled “Popular guides”](#popular-guides)
[Storefront integration](/developers/storefront-integration/)Drop the try-on button and bootstrap script onto a product page.
[Using your own IDs](/developers/using-your-own-ids/)Map products to your own SKUs or database IDs with external\_id.
[Theme extension](/shopify/theme-extension/)Add the try-on block to a Shopify theme in the theme editor.
[Managing products](/panel/products/)Create, sync, and configure products from the merchant panel.
## First API call
[Section titled “First API call”](#first-api-call)
Create a product with a title, category, and image — this also starts the processing that makes it try-on-ready.
Create a product
```bash
curl -X POST https://api.integration.tryonvirtual.com/v1/products \
-H "Authorization: Bearer tryon_sk_your_key" \
-H "Content-Type: application/json" \
-d '{
"title": "Aviator Sunglasses",
"category": "eyewear",
"images": ["https://yourstore.com/images/aviator.jpg"]
}'
```
See the full walkthrough in the [developer quickstart](/developers/quickstart/).
***
Building with an AI agent? Feed it [`/llms.txt`](/llms.txt) or the [OpenAPI spec](https://api.integration.tryonvirtual.com/openapi.yaml).
# Authentication
> How TryOn API keys work — format, headers, creation, revocation, and safe rotation.
Every request to the TryOn API is authenticated with an API key.
## Header format
[Section titled “Header format”](#header-format)
Send the key as a bearer token on every request:
Header
```http
Authorization: Bearer tryon_sk_your_key
```
Request
```bash
curl https://api.integration.tryonvirtual.com/v1/products \
-H "Authorization: Bearer tryon_sk_your_key"
```
Requests without a valid key get a `401` with the `unauthorized` error code:
Response — 401
```json
{"error": {"code": "unauthorized", "message": "Invalid or missing API key"}}
```
## Creating a key
[Section titled “Creating a key”](#creating-a-key)
Keys are created and revoked in the [merchant panel](https://panel.tryonvirtual.com), under **Settings → API Keys** — there is no API endpoint for key management, this is deliberately admin-only and UI-only. Only admin users on the shop can create or revoke keys.
The full key (`tryon_sk_...`) is shown **once**, at creation time. After that, the panel only ever shows a truncated reference — if you lose the key, revoke it and create a new one. There is no way to retrieve a lost key.
## Limits
[Section titled “Limits”](#limits)
A shop can have at most **10 active keys** at a time. If you’re at the limit and need a new one, revoke a key you no longer use first.
## Rotating a key
[Section titled “Rotating a key”](#rotating-a-key)
Because a key is only visible once, rotate before you run out rather than after a key stops working:
1. Create a new key in the panel.
2. Update your integration (env var, secret store, wherever it’s configured) to use the new key.
3. Confirm the new key works — a `200` from any authenticated endpoint (e.g. [`GET /v1/usage`](/api/operations/usage/)) with the new key is enough. `/v1/usage` reports monthly try-on quota — opens, swaps, product counts — not API call volume, so don’t rely on it to see your integration’s traffic; check your own logs for that.
4. Revoke the old key in the panel.
Keep both keys active during the switch — there’s no outage window, and you can revoke the old one whenever you’re confident the new one is live.
## Never expose keys client-side
[Section titled “Never expose keys client-side”](#never-expose-keys-client-side)
The API key is a server-side secret. Never put it in storefront JavaScript, a mobile app bundle, or any code that ships to a browser — treat it like a database password.
This isn’t a tradeoff you need to make: the storefront integration never needs a key at all. The [bootstrap script](/developers/product-lifecycle/) that opens the try-on experience on a product page is loaded as a public, unauthenticated URL — all authenticated calls (creating products, adding images, enabling try-on) happen from your backend or build pipeline, not the browser.
## Next
[Section titled “Next”](#next)
[Product lifecycle](/developers/product-lifecycle/) — the three steps that make a product try-on-ready.
# BigCommerce
> Add the try-on button and bootstrap script to a Stencil product template, using the product SKU as the identifier.
BigCommerce product pages are rendered server-side from Stencil (Handlebars) templates, and the per-product try-on script needs a product identifier at render time — so the reliable place to add both snippets is the product template itself, not a site-wide script.
## Sync products first
[Section titled “Sync products first”](#sync-products-first)
Same as any platform: create a TryOn product for each BigCommerce product before you can render anything, using the BigCommerce SKU as `external_id` so the two catalogs stay joined:
Request
```bash
curl -X POST https://api.integration.tryonvirtual.com/v1/products \
-H "Authorization: Bearer tryon_sk_your_key" \
-H "Content-Type: application/json" \
-d '{
"title": "Classic Fit Shirt — Red, XL",
"category": "clothes",
"external_id": "SHIRT-XL-RED",
"images": ["https://cdn11.bigcommerce.com/.../shirt-xl-red.jpg"]
}'
```
Pull your catalog from the [BigCommerce Catalog API](https://developer.bigcommerce.com/docs/rest-catalog/products) (`GET /catalog/products`) to loop this over every product, and enable try-on the same way as elsewhere once each one reports `swap_ready: true` — see [Product lifecycle](/developers/product-lifecycle/). See [Using your own IDs](/developers/using-your-own-ids/) for more on the `external_id` mapping.
## Edit the product template
[Section titled “Edit the product template”](#edit-the-product-template)
In the [Stencil](https://developer.bigcommerce.com/docs/storefront/stencil) theme you’re running, edit the product page template (`templates/pages/product.html`, or the `product-view` component it includes, depending on your theme) and add both snippets where you want the button to appear. `{{product.sku}}` gives you the same value you set as `external_id` during sync:
templates/components/products/product-view\.html
```html
```
Edit the template through the [Stencil CLI](https://developer.bigcommerce.com/docs/storefront/stencil/start/setup) (`stencil push` after a local edit) or your theme editor, depending on how your store is set up.
Note
[Script Manager](https://support.bigcommerce.com/s/article/Using-Script-Manager) (Storefront → Script Manager, no theme access required) is the usual way to add third-party scripts on BigCommerce, but its page-scoping is coarse — *Storefront pages*, *Checkout*, *Order confirmation*, or *All pages* — with no product-page-specific option, and scripts added there are static text with no access to Handlebars variables like `{{product.sku}}`. That makes it a poor fit for a script that needs a different `productId` per product page; the template edit above is the reliable path if you have theme access.
## Only render when ready
[Section titled “Only render when ready”](#only-render-when-ready)
Check `tryon_status` and `swap_ready` before you decide whether to render the two snippets, same as any other platform — see [Storefront integration](/developers/storefront-integration/#only-render-for-ready-products). If your Stencil template can’t call the TryOn API directly at render time, keep a synced flag (a custom field on the BigCommerce product, or a lookup table on your side) that your sync job updates once a product is both enabled and processed, and branch on that in the template instead of calling out live on every page view.
## Next
[Section titled “Next”](#next)
[Wix](/developers/wix/) — the same integration where there’s no template to edit, via a Velo custom element.
# Custom storefront
> The platform-agnostic integration pattern — render decision, templated snippets, and the SPA caveat for client-routed apps.
If you’re not on WooCommerce, BigCommerce, or Wix, the integration is the same three pieces described in [Storefront integration](/developers/storefront-integration/) — you just wire them into whatever templating or rendering layer your storefront already uses.
## 1. Decide whether to render, server-side
[Section titled “1. Decide whether to render, server-side”](#1-decide-whether-to-render-server-side)
Wherever you generate a product page — a template render, a static build step, an API response your frontend consumes — check the product’s current state before deciding whether to include the try-on snippets at all:
Request
```bash
curl https://api.integration.tryonvirtual.com/v1/products/TRYON_PRODUCT_UUID \
-H "Authorization: Bearer tryon_sk_your_key"
```
`TRYON_PRODUCT_UUID` is the TryOn `id` — REST paths like this one always take that UUID, never your own `external_id` (that works only in the storefront snippet’s `productId` and the `?external_id=` filter — see [Using your own IDs](/developers/using-your-own-ids/)). If your sync job only has its own identifier on hand, look the product up first with `GET /v1/products?external_id=...` to get the `id` this call needs.
Render the button only when `tryon_status == "active"` **and** `swap_ready == true`. If you sync TryOn state into your own database rather than calling the API per request (recommended for anything higher-traffic than a handful of pages), keep both fields current there and branch on your local copy instead — see [Product lifecycle](/developers/product-lifecycle/#tryon_status-vs-swap_ready) for why both checks matter independently.
## 2. Template the two snippets with your own id
[Section titled “2. Template the two snippets with your own id”](#2-template-the-two-snippets-with-your-own-id)
Wire your product identifier — whichever value your system already has, ideally the same one you’re using as `external_id` (see [Using your own IDs](/developers/using-your-own-ids/)) — into both snippets:
Your product template
```html
```
(`{{ product.id }}` here is a stand-in for whatever your templating language uses — Jinja, ERB, JSX, a plain string concatenation in a hand-rolled renderer. The two values that matter are your shop slug and the product identifier.)
## 3. Handle client-side routing
[Section titled “3. Handle client-side routing”](#3-handle-client-side-routing)
The button-binding behavior described in [Storefront integration](/developers/storefront-integration/#how-the-button-binds) runs once, on `DOMContentLoaded`: the bootstrap script scans the page for elements carrying `data-tryon-product-id` at that moment and binds them. That works fine for a traditional multi-page site, where every product page is a fresh document load. It does **not** work for a single-page app that swaps product views via client-side routing without a full page load — the button rendered by your router after the initial load was never scanned, so it won’t be bound.
Two ways to handle this in a client-routed app:
* **Trigger try-on from your own UI instead of relying on the auto-bound button.** Skip `data-tryon-product-id` entirely and call `window.openTryOn()` from your route/component’s click handler once the bootstrap script for the current product has loaded.
* **Re-insert the script tag on every route change**, so a fresh `
```
Find your shop slug in the panel under **Settings → Shop**. The script ships with the product’s try-on manifest already inlined, so there’s no extra round-trip when a customer clicks the button. Any element with `data-tryon-product-id` binds automatically — you can also call `window.openTryOn()` from your own code.
Tip
Set `external_id` when you create a product and you can use your own SKU or database id as `PRODUCT_ID` everywhere above instead of ours — see [Product lifecycle](/developers/product-lifecycle/#mapping-to-your-catalog).
## Reference
[Section titled “Reference”](#reference)
* [API reference](/api/) — full request/response schemas for every endpoint
* [Interactive console](/playground/) — try requests against the live API from your browser
## Next
[Section titled “Next”](#next)
[Authentication](/developers/authentication/) — key format, rotation, and revocation.
# Storefront integration
> The two-snippet integration that puts the try-on button and bootstrap script on a product page.
Everything in the API up to this point — creating a product, adding images, enabling try-on — exists to make one moment possible: a customer on your storefront clicks a button and sees themselves (or their product) in try-on. This page covers that last step, independent of which platform your storefront runs on.
## The two snippets
[Section titled “The two snippets”](#the-two-snippets)
Each product page that should offer try-on needs two things: a button your customer clicks, and a script that knows how to open the try-on experience for that specific product.
Your product page
```html
```
`PRODUCT_ID` is either the `id` this API returns for the product, or your own `external_id` if you set one — the two are interchangeable **here in the storefront snippet**. (REST endpoint paths like `/v1/products/{id}` always take the TryOn `id` — see [Using your own IDs](/developers/using-your-own-ids/) for the full scoping and a lookup-then-act pattern for when you only have the `external_id`.) See that same page if you’d rather your templates never store our ids at all.
Find `YOUR_SHOP_SLUG` in the panel under **Settings → Shop**.
The script is scoped to **one product per page** — it ships with that product’s try-on manifest already inlined, so there’s no extra network round-trip when the customer clicks the button. This is a hard limit, not just a convention: every bootstrap script defines the same `window.openTryOn` global, so loading a second one on the same page overwrites the first rather than adding a second product. For a page that lists or bundles multiple products (a grid, a quick-view card, a bundle page), link each product to its own product page rather than trying to offer try-on for more than one product inline.
## How the button binds
[Section titled “How the button binds”](#how-the-button-binds)
Any element on the page carrying `data-tryon-product-id` is bound automatically once the DOM has finished loading — no extra JavaScript required on your end. If you want to trigger try-on from your own UI instead (a custom “Try it on” link, an image overlay, a menu item), call the bootstrap script’s global function directly:
Trigger it yourself
```html
See it on you
```
`window.openTryOn()` opens the same experience the bound button would, using the `productId` the script tag was loaded with.
## Only render for ready products
[Section titled “Only render for ready products”](#only-render-for-ready-products)
Not every product is ready to show a working try-on experience. Before you render either snippet on a page, check the product’s current state:
Request
```bash
curl https://api.integration.tryonvirtual.com/v1/products/TRYON_PRODUCT_UUID \
-H "Authorization: Bearer tryon_sk_your_key"
```
`TRYON_PRODUCT_UUID` here is the TryOn `id` (a UUID) — REST paths don’t accept `external_id`. If all you have on hand is your own id, look the product up first with `GET /v1/products?external_id=...` — see [Using your own IDs](/developers/using-your-own-ids/#look-products-up-by-it).
Render the button only when **both** of these are true:
| Field | Required value |
| -------------- | -------------- |
| `tryon_status` | `"active"` |
| `swap_ready` | `true` |
A product can be `active` with `swap_ready: false` — you enabled try-on before image processing finished. Showing the button in that state opens an empty experience for the customer, so treat both checks as required, not either/or. See [Product lifecycle](/developers/product-lifecycle/#tryon_status-vs-swap_ready) for why these are two independent gates.
In practice this means doing the check wherever you generate product pages — at build time for a static site, at request time for a server-rendered one, or against your own synced copy of the product data if you mirror it locally (see the platform guides below for concrete examples).
## Let the panel generate the snippet for you
[Section titled “Let the panel generate the snippet for you”](#let-the-panel-generate-the-snippet-for-you)
If you’d rather not hand-assemble the HTML above, the [panel’s Install page](https://panel.tryonvirtual.com/install) generates a ready-to-paste snippet — button and script tag, `shop_slug` and `productId` already filled in — for each of your active products. Useful for a one-off page, or for confirming your hand-rolled template produces the same markup.
## Next
[Section titled “Next”](#next)
[Using your own IDs](/developers/using-your-own-ids/) — mapping `PRODUCT_ID` to a SKU or database id you already have, so your templates never need to know ours.
# Troubleshooting
> Symptoms you'll actually hit integrating the REST API — what each one means, how to confirm it, and how to reach a human.
Organized by what you’re seeing, not by what’s wrong. Each entry says how to confirm the cause before you change anything.
## Every request returns `401 unauthorized`
[Section titled “Every request returns 401 unauthorized”](#every-request-returns-401-unauthorized)
The key is missing, malformed, or revoked.
* The header is `Authorization: Bearer tryon_sk_...` — the `Bearer `prefix is required.
* Keys are shown once at creation. If you didn’t copy it, you can’t recover it; create a new one in the panel under **Settings → API Keys** and revoke the old one.
* A revoked key returns `401`, not `403`.
See [Authentication](/developers/authentication/) for the key format and rotation.
## `404 not_found` on a product you know exists
[Section titled “404 not\_found on a product you know exists”](#404-not_found-on-a-product-you-know-exists)
Two different situations return the same response, deliberately.
* **Wrong id type.** REST paths take the TryOn `id` (a UUID). They do not accept `external_id`. If all you have is your own SKU, look it up first with `GET /v1/products?external_id=SKU-1234` — see [Using your own IDs](/developers/using-your-own-ids/#look-products-up-by-it).
* **Another shop’s product.** Every request is scoped to the shop that owns the key, and a product belonging to a different shop returns `404` rather than `403`. That is intentional: it never confirms whether a resource exists elsewhere. See [Cross-shop access](/developers/errors-and-limits/#cross-shop-access).
## `swap_ready` never becomes `true`
[Section titled “swap\_ready never becomes true”](#swap_ready-never-becomes-true)
Processing normally finishes in under a minute. After a few minutes, assume failure rather than slowness.
1. **Check `images_failed`** from the `POST /v1/products` response. A URL that timed out, wasn’t publicly reachable, wasn’t a JPEG/PNG/WebP/GIF, or exceeded 15 MB is reported there and is never silently retried.
2. **Check the product’s `images` array.** If it’s empty, every URL in the batch failed and there is nothing to process.
3. **Confirm the URL is reachable from the public internet.** A URL your own servers can fetch but the public internet can’t — localhost, a private network address, anything behind auth — fails as `unreachable_url`.
[Images](/developers/images/#error-codes) lists every per-image code and what it means.
## `400 validation_error` when enabling try-on
[Section titled “400 validation\_error when enabling try-on”](#400-validation_error-when-enabling-try-on)
Enabling requires a `category` (set at create time, and it can’t be left unset later) and a `mode`.
The common case is `mode: realtime`, which additionally requires an active 3D asset and returns `400 validation_error` with “Active 3D asset required for real-time mode” when none is usable. 3D assets are managed in the [panel](https://panel.tryonvirtual.com) — this API can’t attach one. `mode: swap` is the only mode you can drive end-to-end through the API.
Also note that changing `mode` on a product that already has try-on enabled turns it off again; send a fresh `tryon_enabled: true` afterward. See [Product lifecycle](/developers/product-lifecycle/#mode-realtime-vs-swap).
## Try-on is on, but the button does nothing
[Section titled “Try-on is on, but the button does nothing”](#try-on-is-on-but-the-button-does-nothing)
Work down this list in order:
* **Both gates satisfied?** `tryon_status` must be `"active"` **and** `swap_ready` must be `true`. A product can be active with `swap_ready: false` — the button opens an empty experience. See [tryon\_status vs. swap\_ready](/developers/product-lifecycle/#tryon_status-vs-swap_ready).
* **Is the bootstrap script actually on the page?** The button is inert on its own; it only binds because the script is present.
* **Does the script’s `productId` match the button’s `data-tryon-product-id`?** They have to be the same product.
* **Is `shop_slug` correct?** Find it in the panel under **Settings → Shop**.
One thing that catches people: the bootstrap script is served from `widget.tryonvirtual.com/api/v1/tryon/scripts/embed/bootstrap.js`, not from the `/v1/...` public API namespace on `api.integration.tryonvirtual.com` that the rest of these docs use. **Different host and different path** — the bootstrap lives on the widget host so it shares an origin with the widget bundle it loads next, which saves the browser a second TLS handshake mid-click. Copy the URL from [Storefront integration](/developers/storefront-integration/) exactly rather than reconstructing it from the REST base.
## Only one product opens on a page with several
[Section titled “Only one product opens on a page with several”](#only-one-product-opens-on-a-page-with-several)
Expected, and it’s a hard limit rather than a bug. Every bootstrap script defines the same `window.openTryOn` global, so a second script on the same page **overwrites** the first instead of adding a product.
For grids, quick-view cards, and bundle pages, link each product to its own product page. See [The two snippets](/developers/storefront-integration/#the-two-snippets).
## `429 rate_limited` during a bulk import
[Section titled “429 rate\_limited during a bulk import”](#429-rate_limited-during-a-bulk-import)
The limit is 120 requests per minute per API key. The response carries a `Retry-After` header in seconds — wait at least that long before retrying, and space out a catalog import rather than firing it all at once. See [Rate limits](/developers/errors-and-limits/#rate-limits).
## Still stuck?
[Section titled “Still stuck?”](#still-stuck)
Open the support chat in the [merchant panel](https://panel.tryonvirtual.com) — the bubble in the bottom corner reaches the team directly. Include the product `id`, the endpoint you called, and the `code` from the error envelope; all three make it answerable on the first reply.
# Using your own IDs
> Map TryOn products to your own SKUs or database ids with external_id, so your templates never store ours.
By default, every product you create gets an `id` we generate — a UUID you’d need to store somewhere in your own system to look the product back up later, or to fill in `PRODUCT_ID` in the [storefront snippet](/developers/storefront-integration/). `external_id` lets you skip that: set it to whatever you already use to identify the product — a SKU, a database primary key — and use that value as the storefront `productId` and to look products up, instead of storing our `id`.
Note
`external_id` is **not** a substitute for `id` in REST paths. `GET/PATCH/DELETE /v1/products/{id}` always takes the TryOn `id` (a UUID) — `PATCH /v1/products/SHIRT-XL-RED` returns `404`, it never reaches the product. `external_id` works in exactly two places: the storefront snippet’s `productId`, and the `GET /v1/products?external_id=...` filter. See [Look products up by it](#look-products-up-by-it) below for the lookup-then-act pattern this implies for any endpoint that isn’t `POST /v1/products`.
## Set it at create time
[Section titled “Set it at create time”](#set-it-at-create-time)
Request
```bash
curl -X POST https://api.integration.tryonvirtual.com/v1/products \
-H "Authorization: Bearer tryon_sk_your_key" \
-H "Content-Type: application/json" \
-d '{
"title": "Classic Fit Shirt — Red, XL",
"category": "clothes",
"external_id": "SHIRT-XL-RED"
}'
```
Response
```json
{
"product": {
"id": "7a2b9c1d-3e4f-4a5b-8c6d-1234567890ab",
"external_id": "SHIRT-XL-RED",
"title": "Classic Fit Shirt — Red, XL",
"category": "clothes",
"tryon_status": "inactive",
"swap_ready": false,
"...": "..."
},
"images_failed": []
}
```
You still get an `id` back — that never goes away — but from here on you can ignore it and work entirely in terms of `SHIRT-XL-RED`.
## Or set it later with PATCH
[Section titled “Or set it later with PATCH”](#or-set-it-later-with-patch)
If a product already exists and you’re backfilling `external_id` for it (migrating from a system that only had our `id`, for example), `PATCH` works the same way:
Request
```bash
curl -X PATCH https://api.integration.tryonvirtual.com/v1/products/7a2b9c1d-3e4f-4a5b-8c6d-1234567890ab \
-H "Authorization: Bearer tryon_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"external_id": "SHIRT-XL-RED"}'
```
## It’s unique per shop
[Section titled “It’s unique per shop”](#its-unique-per-shop)
`external_id` only has to be unique within your own shop — two different shops can each use `SHIRT-XL-RED` for unrelated products. Trying to set an `external_id` that’s already in use on another product in your shop returns `400 validation_error`.
## Look products up by it
[Section titled “Look products up by it”](#look-products-up-by-it)
Request
```bash
curl "https://api.integration.tryonvirtual.com/v1/products?external_id=SHIRT-XL-RED" \
-H "Authorization: Bearer tryon_sk_your_key"
```
This is the read side of the same mapping — useful for a sync job that needs to check “does this SKU already exist as a TryOn product?” before deciding whether to `POST` or `PATCH`.
It’s also how you act on a product when all you have is its `external_id`: look it up, take the `id` from the result, then use that `id` in the path of any endpoint that isn’t `POST /v1/products`. For example, enabling try-on for `SHIRT-XL-RED`:
Look up, then act
```bash
# 1. Look up the TryOn id for your external_id
PRODUCT_ID=$(curl -s "https://api.integration.tryonvirtual.com/v1/products?external_id=SHIRT-XL-RED" \
-H "Authorization: Bearer tryon_sk_your_key" | jq -r '.products[0].id')
# 2. Use that id — not the external_id — in the PATCH path
curl -X PATCH "https://api.integration.tryonvirtual.com/v1/products/$PRODUCT_ID" \
-H "Authorization: Bearer tryon_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"mode": "swap", "tryon_enabled": true}'
```
## Use it as PRODUCT\_ID directly
[Section titled “Use it as PRODUCT\_ID directly”](#use-it-as-product_id-directly)
Once `external_id` is set, drop it straight into the storefront snippet in place of our `id`:
Your product page
```html
```
This is the one place `external_id` is a full drop-in for `id` — the embed script and the button’s `data-tryon-product-id` both accept either value interchangeably. Combined with the `?external_id=` filter for lookups, most integrations that manage their own catalog find they never need to store our `id` in a template at all — only in the occasional server-side call that needs the lookup step above.
## Not available on Shopify-platform shops
[Section titled “Not available on Shopify-platform shops”](#not-available-on-shopify-platform-shops)
`external_id` is read-only for shops on the Shopify platform: `PATCH` requests that try to set it are rejected with `400 validation_error`. On Shopify, that mapping is already owned by the Shopify sync — every TryOn product created from a Shopify sync carries the Shopify product id internally, and introducing a second, independently-set external id would create two competing sources of truth for the same mapping. This API’s `external_id` field exists for WooCommerce, custom platforms, and any other non-Shopify shop that needs to bring its own identifiers.
## Next
[Section titled “Next”](#next)
[WooCommerce](/developers/woocommerce/) — a worked integration using the Woo product SKU as `external_id`.
# Wix
> Sync your Wix Stores catalog to TryOn and render the try-on button on the product page with a Velo custom element.
Wix gives you no server-side product template to edit, so the two snippets from [Storefront integration](/developers/storefront-integration/) can’t be pasted in as static HTML — every product page is the same page, and the bootstrap script needs a different `productId` on each one. The path that works is [Velo](https://dev.wix.com/docs/velo): a **custom element** that renders the button in the page’s own DOM, and page code that tells it which product it’s showing.
Note
Steps 2–4 need Dev Mode turned on for the site (Wix Editor → **Dev Mode** → **Turn on Dev Mode**). The catalog sync in step 1 runs entirely outside Wix and doesn’t.
## 1. Sync products, using a Wix identifier as external\_id
[Section titled “1. Sync products, using a Wix identifier as external\_id”](#1-sync-products-using-a-wix-identifier-as-external_id)
Read your catalog with Wix Stores’ [Query Products](https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products), then create a matching TryOn product for each one. Wix REST calls authenticate with an API key from the [API Keys Manager](https://manage.wix.com/account/api-keys) plus your site id — note the key goes in `Authorization` bare, with no `Bearer` prefix, unlike TryOn’s:
Read the Wix catalog
```bash
curl -X POST https://www.wixapis.com/stores/v3/products/query \
-H "Authorization: WIX_API_KEY" \
-H "wix-site-id: WIX_SITE_ID" \
-H "Content-Type: application/json" \
-d '{"query": {"filter": {"visible": {"$eq": true}}, "cursorPaging": {"limit": 100}}}'
```
Create the TryOn product
```bash
curl -X POST https://api.integration.tryonvirtual.com/v1/products \
-H "Authorization: Bearer tryon_sk_your_key" \
-H "Content-Type: application/json" \
-d '{
"title": "Classic Fit Shirt — Red, XL",
"category": "clothes",
"external_id": "SHIRT-XL-RED",
"images": ["https://static.wixstatic.com/media/shirt-xl-red.jpg"]
}'
```
[category](/developers/product-lifecycle/#1-create-it-with-a-category) is required and drives which AI model processes the product — map it from your Wix collections or product type, it won’t come across automatically. Once images finish processing and `swap_ready` turns `true`, `PATCH` the product to enable try-on exactly as on any other platform; see [Product lifecycle](/developers/product-lifecycle/) for both calls.
For a one-time load you don’t have to script against the Wix API at all — export the catalog to CSV from the store dashboard and feed it through the bulk sync loop on the [WooCommerce page](/developers/woocommerce/#1-sync-products-using-the-woo-sku-as-external_id).
### Choosing the identifier
[Section titled “Choosing the identifier”](#choosing-the-identifier)
Whatever you store as `external_id` has to be a value the product page can read back in step 4. Velo’s `getProduct()` hands you both `sku` and `_id`, so either works — with one wrinkle: in the V3 catalog, SKUs are a variant field, and Query Products [doesn’t return variant data](https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products), so syncing by SKU means an extra per-product call to read it. Syncing by the Wix product id avoids that. Either way the invariant is the same: the value you store as `external_id` must be the exact value your page code reads back. See [Using your own IDs](/developers/using-your-own-ids/).
## 2. Add a custom element
[Section titled “2. Add a custom element”](#2-add-a-custom-element)
public/custom-elements/tryon-button.js
```js
const SHOP_SLUG = 'YOUR_SHOP_SLUG';
class TryOnButton extends HTMLElement {
static get observedAttributes() {
return ['product-id'];
}
attributeChangedCallback(name, previous, productId) {
if (name !== 'product-id' || !productId || productId === previous) return;
this.render(productId);
}
render(productId) {
// No shadow root on purpose — the button and the bootstrap script both
// belong in the page's own DOM.
this.innerHTML = '';
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Try On';
button.disabled = true; // until the bootstrap script has loaded
button.addEventListener('click', () => window.openTryOn());
this.appendChild(button);
// One bootstrap script per product. Wix moves between products without a
// full page load, so drop the previous one before loading the next.
this.script?.remove();
const script = document.createElement('script');
script.async = true;
script.src =
'https://widget.tryonvirtual.com/api/v1/tryon/scripts/embed/bootstrap.js' +
`?shop_slug=${SHOP_SLUG}&productId=${encodeURIComponent(productId)}`;
script.onload = () => {
button.disabled = false;
};
this.script = script;
document.head.appendChild(script);
}
}
customElements.define('tryon-button', TryOnButton);
```
Find `YOUR_SHOP_SLUG` in the panel under **Settings → Shop**.
Add it to the product page from the editor — **Add Elements → Embed → Popular Embeds → Custom Element** in the Wix Editor, **Add Elements → Embed & Social → Custom Element** in Wix Studio — then **Choose Source → Velo file**, pick the file, and enter `tryon-button` as the **Tag Name**. It has to match the string in `customElements.define()` exactly. (Wix’s own walkthrough is [Add a Custom Element](https://dev.wix.com/docs/velo/velo-only-apis/$w/custom-element/add-a-custom-element).)
Two details in that code are deliberate:
* **The click handler calls `window.openTryOn()` instead of using `data-tryon-product-id`.** The automatic binding described in [Storefront integration](/developers/storefront-integration/#how-the-button-binds) scans the page once, on `DOMContentLoaded`. This element renders later — after Velo has fetched the product and set the attribute — so its button was never scanned and would never be bound. Calling the global directly is the documented way out, the same one a client-routed app needs (see [Custom storefront](/developers/custom-storefront/#3-handle-client-side-routing)).
* **The old script tag is removed before a new one is added.** Every bootstrap script defines the same `window.openTryOn` global, so a page carrying two of them offers try-on for whichever loaded last, not for both — try-on is [one product per page](/developers/storefront-integration/#the-two-snippets).
Note
Wix’s [Custom Code panel](https://support.wix.com/en/article/embedding-custom-code-on-your-site) (dashboard → **Settings → Custom Code**) can scope a script to the product page, which makes it look like the obvious home for the bootstrap tag. It isn’t: the code it injects is static text with no access to the current product, and the bootstrap script needs a per-product `productId`. The custom element exists precisely because Velo can set its attributes per product.
## 3. Keep the API key in the backend
[Section titled “3. Keep the API key in the backend”](#3-keep-the-api-key-in-the-backend)
Page code and the custom element both run in the visitor’s browser, so an API key put in either one is public — and a `tryon_sk_` key can create and modify every product in your shop. Store it in the Wix **Secrets Manager** (dashboard → **Settings → Secrets Manager**) and do the readiness check in a backend web module instead:
backend/tryon.web.js
```js
import { Permissions, webMethod } from 'wix-web-module';
import { getSecret } from 'wix-secrets-backend';
import { fetch } from 'wix-fetch';
export const isTryOnReady = webMethod(Permissions.Anyone, async (externalId) => {
const apiKey = await getSecret('tryon_api_key');
const response = await fetch(
'https://api.integration.tryonvirtual.com/v1/products' +
`?external_id=${encodeURIComponent(externalId)}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
if (!response.ok) return false;
const { products } = await response.json();
const product = products?.[0];
// Both gates, not either/or — see Storefront integration.
return Boolean(product && product.tryon_status === 'active' && product.swap_ready);
});
```
The lookup goes through `?external_id=` rather than `/v1/products/{id}` because the value the page has on hand is your identifier, not TryOn’s UUID — REST paths only ever take the UUID ([Using your own IDs](/developers/using-your-own-ids/#look-products-up-by-it)). And both fields are checked because a product can be `active` with `swap_ready: false`, which opens an empty experience for the customer — [why both gates matter](/developers/product-lifecycle/#tryon_status-vs-swap_ready).
As written this calls the TryOn API on every product page view, which is fine for a small catalog. For anything busier, mirror `tryon_status` and `swap_ready` into a Wix Data collection from a scheduled sync job and have this function read that instead — same reasoning as [Storefront integration](/developers/storefront-integration/#only-render-for-ready-products).
## 4. Wire the product page
[Section titled “4. Wire the product page”](#4-wire-the-product-page)
In Dev Mode, open the **Product Page** and add its page code:
Product Page — page code
```js
import { isTryOnReady } from 'backend/tryon.web';
$w.onReady(async () => {
const product = await $w('#productPage1').getProduct();
const externalId = product.sku; // the value you stored as external_id in step 1
if (!externalId) return;
if (!(await isTryOnReady(externalId))) return;
// The element renders nothing until it receives this attribute, so a product
// that isn't ready simply never shows a button.
$w('#customElement1').setAttribute('product-id', externalId);
});
```
`#productPage1` and `#customElement1` are Wix’s default element ids — confirm both in the editor’s Properties panel, since a page with more than one of either will number them differently.
Because the readiness check gates the `setAttribute()` call, there’s no separate “only render when ready” step here: an unready product never gets a `product-id`, and the element renders nothing.
## Don’t use the HTML embed
[Section titled “Don’t use the HTML embed”](#dont-use-the-html-embed)
Wix’s **Embed HTML** element looks like a shortcut for pasting the two snippets, but it renders its contents inside a cross-origin sandboxed iframe, which breaks try-on twice over. The experience is confined to the embed box instead of covering the page, and webcam AR needs the iframe to carry `allow="camera"` — a Permissions Policy delegation the parent page has to grant, which the Wix editor gives you no way to set. The custom element above has neither problem: it runs in the page’s own document.
## Next
[Section titled “Next”](#next)
[Custom storefront](/developers/custom-storefront/) — the platform-agnostic version of the same integration, plus notes for single-page apps.
# WooCommerce
> Create TryOn products from your WooCommerce catalog and render the try-on button on the single product page.
There’s no WooCommerce plugin — this is a plain REST integration using the TryOn API and a small `functions.php` snippet. Two pieces: a sync step that creates a TryOn product for each WooCommerce product (using the Woo SKU as `external_id`), and a template hook that renders the button.
Note
This assumes your theme (or child theme) supports a `functions.php` you can edit. If you’re on a page builder without template access, see [Custom storefront](/developers/custom-storefront/) for the platform-agnostic version of the same idea.
## 1. Sync products, using the Woo SKU as external\_id
[Section titled “1. Sync products, using the Woo SKU as external\_id”](#1-sync-products-using-the-woo-sku-as-external_id)
Loop over your WooCommerce catalog via the [Woo REST API](https://woocommerce.github.io/woocommerce-rest-api-docs/) and create a matching TryOn product for each one, passing the SKU as `external_id` so the two systems stay joined without you having to store TryOn’s own `id` anywhere:
Note
`TRYON_API_KEY` below is a constant you define in `wp-config.php` — `define('TRYON_API_KEY', 'tryon_sk_your_key');` — not a value to hardcode inline. Keep `wp-config.php` out of version control (it usually already is by default) since this key can create and modify products in your shop.
Sync products to TryOn
```php
-1, 'status' => 'publish']);
foreach ($products as $product) {
$sku = $product->get_sku();
if (!$sku) {
continue; // external_id needs something stable — skip products without a SKU
}
$response = wp_remote_post('https://api.integration.tryonvirtual.com/v1/products', [
'headers' => [
'Authorization' => 'Bearer ' . TRYON_API_KEY,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'title' => $product->get_name(),
'category' => 'clothes', // map from your product categories/attributes
'external_id' => $sku,
'images' => [wp_get_attachment_url($product->get_image_id())],
]),
]);
// Store a local flag so the template hook below doesn't need a live API
// call on every page view — see step 3.
if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 201) {
update_post_meta($product->get_id(), '_tryon_synced', '1');
}
}
```
Equivalent as a plain curl loop, if you’re driving this from outside WordPress against a Woo export instead:
Bulk sync from an export
```bash
# products.json: [{"sku": "SHIRT-XL-RED", "title": "...", "category": "clothes", "image": "..."}]
jq -c '.[]' products.json | while read -r p; do
curl -X POST https://api.integration.tryonvirtual.com/v1/products \
-H "Authorization: Bearer tryon_sk_your_key" \
-H "Content-Type: application/json" \
-d "$(echo "$p" | jq '{title, category, external_id: .sku, images: [.image]}')"
done
```
Either way, [category](/developers/product-lifecycle/#1-create-it-with-a-category) is required and drives which AI model processes the product — map it from your Woo product categories or attributes, it won’t come from Woo automatically.
## 2. Enable try-on once images finish processing
[Section titled “2. Enable try-on once images finish processing”](#2-enable-try-on-once-images-finish-processing)
Same as any other integration — poll or re-check the product until `swap_ready` is `true`, then `PATCH` to turn it on. The `PATCH` path needs the TryOn `id` (a UUID), not the SKU — `external_id` only works as a query filter or in the storefront snippet, never in a REST path (see [Using your own IDs](/developers/using-your-own-ids/)). If your sync job already has the `id` from the create response in step 1, use that directly; otherwise look it up by SKU first:
Enable try-on
```bash
# If you only have the SKU: look up the id, then PATCH with it
PRODUCT_ID=$(curl -s "https://api.integration.tryonvirtual.com/v1/products?external_id=SHIRT-XL-RED" \
-H "Authorization: Bearer tryon_sk_your_key" | jq -r '.products[0].id')
curl -X PATCH "https://api.integration.tryonvirtual.com/v1/products/$PRODUCT_ID" \
-H "Authorization: Bearer tryon_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"mode": "swap", "tryon_enabled": true}'
```
In the PHP loop from step 1, skip the lookup entirely — decode the create response and reuse the `id` it already gave you:
Store the TryOn id
```php
$body = json_decode(wp_remote_retrieve_body($response), true);
$tryon_id = $body['product']['id'] ?? null;
if ($tryon_id) {
wp_remote_request("https://api.integration.tryonvirtual.com/v1/products/{$tryon_id}", [
'method' => 'PATCH',
'headers' => [
'Authorization' => 'Bearer ' . TRYON_API_KEY,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode(['mode' => 'swap', 'tryon_enabled' => true]),
]);
}
```
`swap_ready` still needs a moment to flip to `true` after images are added — enabling try-on early is fine (see [Product lifecycle](/developers/product-lifecycle/#tryon_status-vs-swap_ready)), just don’t render the button until the render check in step 3 confirms both are true.
## 3. Render the button on the single product page
[Section titled “3. Render the button on the single product page”](#3-render-the-button-on-the-single-product-page)
Hook into `woocommerce_single_product_summary` and use `$product->get_sku()` as the `productId` — the same value you set as `external_id` during sync:
functions.php
```php
get_sku();
if (!$sku) {
return;
}
// Don't call the TryOn API on every page load — check the flag you cached
// during sync instead (see step 1). If you need it fresher than your sync
// cadence, cache the API response in a transient rather than checking live.
if (get_post_meta($product->get_id(), '_tryon_synced', true) !== '1') {
return;
}
?>
Use the AR Fit Editor's live webcam preview to adjust how a 3D model sits on a customer, then save the fit.
Real-time AR models are generated (or uploaded) with a reasonable default fit, but eyewear and watches sit differently on every face and wrist. The AR Fit Editor lets you nudge the model until it looks right, watching it live on your own camera as you adjust it.
## Opening the editor
[Section titled “Opening the editor”](#opening-the-editor)
On a product’s **3D Assets** list, any eligible asset has an **AR Fit** button that opens the editor at `/products/:productId/ar-fit/:assetId`. It’s only available for AR-capable assets (eyewear and watch GLB models, and eyewear assembly models).
## What it adjusts
[Section titled “What it adjusts”](#what-it-adjusts)
The editor exposes position, rotation, and scale for the model, with scrubable number inputs so you can drag a value up or down instead of typing exact numbers. Changes apply instantly to the live preview.
## Live webcam preview
[Section titled “Live webcam preview”](#live-webcam-preview)
The editor opens your camera and overlays the model on you in real time, the same way a customer would see it in AR try-on. This is the fastest way to tell whether a fit is actually right — static screenshots can be misleading.
## Saving
[Section titled “Saving”](#saving)
Adjustments aren’t saved until you click **Save**. If you change your mind, **Discard** reverts to the last saved configuration. The save button is disabled until something has actually changed, so you can’t accidentally save a no-op.
## Next
[Section titled “Next”](#next)
[Widget & preview](/panel/widget-preview/) — see the finished try-on experience on an actual product page before it goes live for customers.
# Billing
> Where to see your usage against plan limits, what counts toward it, and how to upgrade from the Billing page.
## Plans
[Section titled “Plans”](#plans)
Plan tiers and what each one includes live on the [pricing page](https://tryonvirtual.com/pricing) — the panel doesn’t duplicate that list, it just shows you where you stand against whichever plan you’re on.
## What counts as usage
[Section titled “What counts as usage”](#what-counts-as-usage)
Your plan carries four limits, and they meter different things:
* **Try-on opens** — counted per *unique shopper per month*, not per open. The same shopper opening try-on repeatedly across a month counts once.
* **AI Swaps** — counted per generation *started*, including ones that fail during processing.
* **3D generations** — every 3D model you generate from product photos.
* **Products** — how many products can have try-on enabled at once. This is an ongoing ceiling, not a monthly allowance; deactivating a product frees the slot immediately.
Try-ons you run yourself from the preview aren’t metered — only real shopper sessions on your storefront count.
[Measuring results](/panel/measuring-results/) explains why the dashboard’s **Try-on Sessions** number is higher than your metered usage — it counts a different thing.
## Where to see it
[Section titled “Where to see it”](#where-to-see-it)
The **Billing** page shows a **Quota Usage** card with a progress bar for each of these, current usage against your limit for the billing period. Bars shift color as you approach the limit, so it’s easy to see at a glance if you’re getting close.
## What happens when you go over
[Section titled “What happens when you go over”](#what-happens-when-you-go-over)
Try-on keeps working. Limits are soft by default — shoppers aren’t turned away the moment you cross a threshold, and the widget doesn’t switch off mid-month.
**On Shopify-billed paid plans, extra usage is pay-as-you-go.** Two of the four meters bill past their limit: **try-on sessions** (charged per extra session) and **3D generations** (charged per extra model). Each plan has its own per-unit rates, listed with the plan on the [pricing page](https://tryonvirtual.com/pricing) and on the plan cards in your Shopify admin. Those charges go through Shopify’s Billing API and are capped per billing cycle, so there’s a ceiling on what an unexpectedly busy month can cost, and you’ll see a notification as you approach that cap.
**The free plan has no overage at all.** Usage simply pauses at the limit until the next cycle — nothing is ever billed on it.
Stores that persistently run far past their limits can be switched to hard enforcement, where shoppers are refused once the limit is reached. That’s applied manually and only in cases of sustained overuse.
## Changing your plan
[Section titled “Changing your plan”](#changing-your-plan)
The **Plans & Pricing** section on the same page lists every available plan. What the button does depends on where you’re starting from.
**Your first paid plan.** If you don’t have a subscription yet, choosing a plan opens a hosted checkout. Once payment goes through, your new limits apply right away and you’ll see a confirmation on the page.
**Moving up.** On a more expensive plan than your current one, **Upgrade** takes effect immediately — no checkout, since your card is already on file. You’re invoiced a prorated amount for the rest of the current period, and the higher limits apply straight away.
**Moving down.** On a cheaper plan, **Downgrade** is scheduled rather than immediate: you keep your current plan and its limits until the end of the billing period you’ve already paid for, and the new plan starts at your next renewal. The page asks you to confirm before scheduling it.
**Changing your mind.** While a downgrade is scheduled, the Billing page shows which plan you’re moving to and when. Your current plan’s card offers **Keep this plan**, which cancels the scheduled downgrade and leaves you where you are.
Usage counters aren’t reset by a plan change — they keep running for the calendar month, and the new plan’s limits simply apply to the same numbers.
Two things to know:
* **Moving to the free plan isn’t a downgrade** — use **Cancel subscription** instead. Your access continues until the end of the period you’ve paid for.
* **You can’t switch plans while a cancellation is pending.** Reactivate first, or wait for the period to end and subscribe again.
Only admins and members with the Billing role can manage billing — see [Team & API keys](/panel/team-and-api-keys/) for role details. If your shop is on Shopify, billing is instead managed through your Shopify admin, and plan changes there take effect immediately rather than at the next renewal.
## Next
[Section titled “Next”](#next)
Ready to automate? The [developer quickstart](/developers/quickstart/) walks through creating products and enabling try-on with the REST API.
# Driving try-on usage
> Where to place the try-on button on your storefront, what to label it, and how to link campaigns straight into an open try-on experience.
A working install still goes unused if shoppers never notice the button. Because you control your storefront’s markup directly, placement is entirely yours to choose — here’s what to aim for.
## Put the button next to Add to Cart
[Section titled “Put the button next to Add to Cart”](#put-the-button-next-to-add-to-cart)
Place the element carrying `data-tryon-product-id` directly beside or below your **Add to Cart** button, above the fold. Shoppers are already looking there when they’re deciding, and it doesn’t ask them to go hunting.
Any element with that attribute is bound automatically — it doesn’t have to be a `