# TisFile Studio: Developer API > Add a 3D product customizer to a website you own the code of. Send product photos, get a 3D > model with a hosted customizer, embed it with one iframe, and receive what each shopper > configured so it can go on your own cart line and your own order. Base URL: https://tisfile.com Auth: `Authorization: Bearer tf_live_...` Get a key: the Developer API panel on https://tisfile.com/dashboard/studio/billing **A `tf_test_...` key is not a sandbox.** It resolves to the same account and the same real viewers, origins, colourways and configurations as a live key. The only difference is that `POST /api/v1/items` and `POST /api/v1/items/{id}/rebuild` refuse it with 400 `TEST_MODE_UNSUPPORTED`, because those are the two calls that spend a build. Use it to verify everything except building; do not use it to keep staging away from production data. Included on every paid plan. The API spends the same two allowances as the dashboard and costs nothing extra. Every response carries `requestId` and an `x-request-id` header. Errors are `{ "error": "...", "code": "...", "requestId": "..." }`. Branch on `code`, never on the prose. | code | status | meaning | |---|---|---| | `UNAUTHORIZED` | 401 | missing, malformed, unknown or revoked key | | `INSUFFICIENT_SCOPE` | 403 | the key lacks that scope | | `ACCOUNT_BLOCKED` | 403 | the account cannot start new work; existing viewers keep serving | | `ALREADY_BUILDING` | 409 | a rebuild was asked for while a build is in flight | | `TEST_MODE_UNSUPPORTED` | 400 | a test key tried to create or rebuild an item | | `CREATE_IN_PROGRESS` | 409 | another request is already building that `externalRef` | | `INVALID_ZONES` | 400 | `zones` named something that is not a preset | | `ZONES_OVERLAP` | 400 | two of the requested presets would sit on top of each other | | `ZONE_PRESET_UNSUPPORTED` | 400 | a print area that is not supported yet, such as `back` | | `ZONES_CONFLICT` | 400 | `zones` and `autoZones=true` were both sent | | `GOAL_HAS_NO_ZONES` | 400 | zones were asked for on a `colourways` or `viewer` goal | | `RATE_LIMITED` | 429 | over the per-key bound, `Retry-After` set | | `ALLOWANCE_EXHAUSTED` | 402 | the plan's builds or customisations are used up | | `TRIAL_NOT_STARTED` / `TRIAL_ENDED` | 402 | no plan, and the free trial has not begun or is over | | `INTERNAL` | 500 | our fault, quote the requestId | ## The whole flow, in order ### 1. Check what is available ``` GET /api/v1/account ``` Returns the plan, and both meters as `used` / `included` / `remaining`; builds also carry `returned`, the failed builds given back this period (already off `used`). The numbers come from the same code that enforces a claim, so `remaining` is a promise about the next call and includes banked rollover. `included: null` means unmetered by contract; never read it as zero. Asking costs nothing. Do this before a batch rather than discovering a 402 halfway through one. ### 2. Create an item from photos ``` POST /api/v1/items multipart/form-data photos one to five image files (png, jpeg, webp). Front view first. goal "both" | "decals" | "colourways" | "viewer" REQUIRED name optional, defaults to "Untitled" cleanPhotos optional "true" | "false". Omit to let the photos decide. autoZones optional "true". Omit to follow cleanPhotos. zones optional print areas for a BLANK product: "chest", or "left_chest,right_chest". As JSON, each can carry a size in inches, a name and how it is decorated: [{ "preset": "left_chest", "widthIn": 7, "heightIn": 7.8, "label": "Letter", "decoration": "patch" }, "back"]. See "Zones on a blank product" below. productHeightIn optional, the scale for sizes in inches: the product's real height, top to bottom as it stands in the viewer (a hoodie on its form about 33, a cap about 5, a mug about 4). Omitted with sizes, it is assumed from the product's name and said back in `scale`. mannequin optional "false" builds a garment exactly as photographed, with no body. Garments are shown worn on a mannequin by default (an apron hangs from a torso form); send false only when the product should stand on its own. externalRef optional, STRONGLY RECOMMENDED: your own id for this product allowedOrigins optional JSON array, e.g. ["http://localhost:3000"] ``` **Send `externalRef` and a retry can never charge twice.** Use the id you already have for the product: a SKU, a platform product id, a Shopify gid. One item per reference per account. If a reference already has an item, the call returns that item with `reused: true` and spends nothing, not even the detection. Without it, a timed-out request retried, or a resumed script, builds the same product again and bills for it. Letters, numbers, dot, underscore, colon, slash and hyphen; no spaces. `409 CREATE_IN_PROGRESS` means another request for that reference is building right now: poll that item, do not create a second one. Honour `Retry-After`. **Use `goal: "both"` unless you are certain.** A goal cannot be changed after the item is built, and it decides what stays possible: `colourways` and `viewer` make the design editor permanently read-only, so nobody can add a zone later, not even in the dashboard. `both` leaves zones and colourways both open. Getting this wrong means rebuilding, which costs another build. Answers `202` immediately with a slug you can embed right away: ```json { "item": { "id": "…", "name": "Alpine Hoodie", "goal": "both", "status": "processing" }, "viewer": { "slug": "alpine-hoodie-k7f2qd", "embedUrl": "https://tisfile.com/embed/studio/alpine-hoodie-k7f2qd", "state": "live" }, "promise": { "zones": "auto-placed from the print in your photos", "colourways": "4 colour regions detected" }, "poll": { "url": "https://tisfile.com/api/v1/items/…" } } ``` **Write your integration now.** The embed URL is permanent and already exists; the 3D model arrives into it. Do not block on the build. **Zones on a blank product.** With a print on the photo, the zone is measured from the print. A blank product has none, so without `zones` it comes back with nothing to personalise. `zones` names standard print areas instead, measured from the product's own anatomy in your photo: | preset | where | on | |---|---|---| | `front` | the centre of the main face: a mug's body, a tote's panel, a cap's crown panel | anything | | `chest` | a standard chest print, just below the neckline | garments | | `full_front` | a large front print, stopping above a hoodie's pocket | garments | | `left_chest` | a chest badge on the WEARER's left (the viewer's right) | garments | | `right_chest` | the same, wearer's right | garments | | `back` | on a garment, a full back print (about 12 x 14 in) below the collar, or below the hood; on a cap or a bag, at the front print's height, smaller than the front | anything | | `back_upper` | a band across the upper back, for a name, an arch or a yoke; with `back` it sits above it and the back moves down | garments | | `left_side` | the side on the WEARER's left, at the front print's height: a cap's side panel, a bag's side | caps, bags, mugs | | `right_side` | the same, wearer's right | caps, bags, mugs | | `side_label` | a small flag label low on the WEARER's left side, towards the back, where a brand tag is sewn | caps, bags | | `left_sleeve` | the upper arm on the WEARER's left, at the chest line | garments | | `right_sleeve` | the same, wearer's right | garments | **A print area no preset names** (a beanie's cuff, an apron's pocket, a shirt's hem label, a jersey's front number): look at the front photo you send and DRAW it, as fractions of that image, with a name: `{ "box": { "x": 0.31, "y": 0.62, "w": 0.38, "h": 0.14 }, "label": "Cuff" }`, plus `widthIn`, `heightIn` and `decoration` as on a preset. It is measured onto the model exactly as a preset is. At most two print areas come from the photo per item, presets and boxes together (`400 TOO_MANY_PHOTO_ZONES`); boxes may not overlap each other (`ZONES_OVERLAP`), and one drawn over a preset's own area is not placed. It needs a build: it cannot be added to an existing item. Send one of `front`, `chest` or `full_front`, or one or both chest badges: the centre presets overlap each other and the badges, so combinations are refused rather than stacked. `back`, the upper back band, the sides and the sleeves combine with any of them (a cap: `front,back,left_side,right_side`; a jersey: `chest,back_upper,back,left_sleeve,right_sleeve` for a name over a number), or stand alone. Sized in inches at create (`productHeightIn` with them), a band and a back are stacked at those sizes. A front photo does not show them, so they are placed on the 3D model, turned from the front: on a garment use the sleeves, since a garment's sides are its arms. They are `estimated` in `item.zones`, never `measured`. A repeated create with the same `externalRef` returns the item as it was first built, and its `item.zonePresets` says what that first create asked for. Each preset zone starts with a placeholder graphic, a dashed print area with an add-image mark, and the shopper replaces it with their own artwork, which is fitted inside the print area whatever its shape. That graphic is never a design: on a configuration an untouched print area reads `filled: false, placeholder: true`, and nothing in an order carries it. ### 2a. See what you already have ``` GET /api/v1/items GET /api/v1/items?externalRef=CAP-001,CAP-002 GET /api/v1/items?status=failed&limit=100 ``` Each item carries `externalRef`, `status`, `createdVia` and its viewer's `slug`, `embedUrl` and `shoppersCan`, plus `zoneCount`, `colourwayCount` and `colourRegionsReady`. `hasMore` says whether the page was cut short. **Call this before a batch.** It is free, and it is how a conversion resumes: ask which of your references already have items, then build only the rest. `createdVia` is `"api"` or `"dashboard"`, so you can leave items a human has been working on alone. ### 3. Poll once a minute ``` GET /api/v1/items/{id} ``` `item.status` is `processing`, `completed` or `failed`. Builds are given up on after 25 minutes. **A burst waits, it does not fail.** The 3D provider runs a fixed number of builds at once across all of TisFile. When every slot is busy, a build waits for one instead of failing: `item.waitingForCapacity: true` and `item.queuedAt` say so while it waits, usually for a few minutes, and nothing more is charged for waiting. A build that cannot start within an hour fails with a sentence that says so. Pacing a large conversion to a few creates a minute keeps the wait short. `item.finishing: true` means the 3D model is built and being prepared (downloaded, compressed, zones placed): poll again shortly, and the next poll usually says `completed`. A poll answers in about a second either way; it never waits for that work. `item.zones` describes each zone once the item has some: `id`, `label`, `placeholder` (still the "your design here" graphic), `placement` (`"measured"` on a reference model built from your photo, `"estimated"` by the fallback placement, or `"manual"`) and `confidence`. There is deliberately no size or position: models are normalised to one unit, so a size in inches would be invented. Two zones never share a label. Read `blockers`. An empty array means ready to use. A non-empty one means the build finished but something still stops it working, each with a `code` and a sentence: - `NO_ORIGINS` the viewer is dark everywhere, including localhost. Go to step 4. - `NO_ZONES` nothing was found to place a zone over. Add one in the dashboard. - `NO_COLOURWAYS` the item has no colourway yet. Its sentence says which: create one (step 3b), wait for the colour analysis, or run the analysis on the dashboard. - `NO_VIEWER` no viewer exists; create one in the dashboard. ### 3a. If the build failed, rebuild once ``` POST /api/v1/items/{id}/rebuild { "excludeViews": [2] } optional: drop a generated view that came out wrong ``` **It spends a build**, like a create. The response says so (`rebuild.spent`). The viewer slug and your embed do not change, so nothing on your page needs touching; poll the item again. **A build that fails on our side or the provider's is given back**, automatically, within a few minutes of the failure. A failed item carries `buildRefund`: `{ "status": "returned" }` once it is back, `"pending"` until then, or `"kept"` with a `reason` and a sentence when it is not: `content_policy` (the provider refused the photos themselves) or `daily_limit` (more than 10 failures on the account in a day). `GET /api/v1/account` counts them in `usage.builds.returned`. Rebuild **once**, then deep-link a human to `/dashboard/studio/{itemId}/customize`. The same photos usually produce the same mesh, so a loop just spends the plan. If the failure was a content policy flag, rebuilding will not help: different photos are needed. **A rebuild re-submits the photos the item already prepared. It does not run the create's other steps again**: no new print removal, no new logo extraction, no new zone presets. For an item that failed before those steps ran, or one you want different `zones` on, create a new item with a new `externalRef` instead. `409 ALREADY_BUILDING` means a build is in flight; wait for `completed` or `failed`. A rebuild discards the colour regions and any placement solves measured against the old mesh, so re-read the colourway regions afterwards. Existing colourways survive and work again once the new analysis finishes. ### 3b. Add colourways, if the goal was colourways or both ``` GET /api/v1/items/{id}/colourways the regions you may recolour, and the colourways POST /api/v1/items/{id}/colourways { "name": "Volt", "colours": { "body": "#c8ff00" } } PATCH /api/v1/items/{id}/colourways { "hideOriginal": true } PATCH /api/v1/items/{id}/colourways/{cwId} { "name"?, "colours"?, "original"?, "default"? } DELETE /api/v1/items/{id}/colourways/{cwId} ``` The GET returns `regions`, each `{ region, label, currentHex }`, and `regionsReady`. Use `region` as the key in `colours`; `label` says which part of the product it is. Items created with `goal: "colourways"` or `"both"` get the analysis automatically, and one that missed it is given it again within minutes. `regionsReady: false` comes with a `regionsHint` sentence and a `regionsHintCode` to branch on: - `ANALYSIS_RUNNING`, `ANALYSIS_PENDING` poll again in a minute. - `ANALYSIS_FAILED`, `ANALYSIS_STALE` run the analysis on the dashboard. - `NOT_REQUESTED` the goal was not colourways or both; the dashboard can still add it. - `VARIANT` a recolour variant; colourways live on the original item. Colourways are free: recolouring happens in the shader from masks the analysis already produced, so no build and no customisation is spent. Up to 20 per item. An unknown region name is a `400 UNKNOWN_REGION` naming it, so read the GET first. Without at least one colourway, a `colourways` item has nothing for a shopper to switch. **The colour in the photos is already a swatch.** Shoppers always see an "Original" swatch: the product exactly as photographed. If you also create a colourway for that colour, they see two swatches of the same colour. So for the catalogue colour your photos show, send `{ "name": "Forest", "original": true }` with no `colours`: the viewer then shows Forest from the model's own texture (exact, where a recolour only approximates) in place of the Original swatch. One per item; marking another moves it. On an existing duplicate, `PATCH` it with `{ "original": true }`, or `DELETE` it. `409 ORIGINAL_IS_TEMPORARY` means the item was made in a temporary colour (its original is not a product colour); hide the original instead. If the photographed colour is not one you sell, `PATCH /items/{id}/colourways` with `{ "hideOriginal": true }`. `"default": true` (on create or `PATCH`) picks the colourway shoppers start on. The GET's `startsOn` says what they will see first (`"original"` or a colourway id), and each colourway carries `original` and `isDefault`. A cart line or quote made in the photographed colour names the colourway marked original. Deleting the default or the original clears that mark, and the response says so (`defaultCleared`, `originalCleared`). ### 3c. Apply a placement or colour fix to an existing item TisFile improves how zones are placed and how colours are separated. A new build gets the improvement; an existing item keeps what it was given until it is asked to redo it, and these two calls do that. Both are free: no build, no customisation. ``` POST /api/v1/items/{id}/zones/reset { "flip": true, "force": true } both optional POST /api/v1/items/{id}/colour-analysis POST /api/v1/items/{id}/zones { "add": ["back", "left_sleeve"] } ``` ``` PATCH /api/v1/items/{id}/zones/{zoneId} { "widthIn": 4.5, "heightIn": 2, "label": "Right chest name" } PATCH /api/v1/items/{id}/zones/{zoneId} { "actualWidthIn": 3.5 } on a "measured" zone: sets the scale PATCH /api/v1/items/{id}/zones/{zoneId} { "productHeightIn": 30 } or set the scale directly PATCH /api/v1/items/{id}/zones/{zoneId} { "decoration": "embroidery" } how it is made ``` `decoration` is how the print area is made, and it decides how its design renders and how a shopper's upload is made to look: `print`, `dtg`, `heat_transfer`, `embroidery`, `patch`, `engraving` or `deboss`. On a patch the merchant photographed, the shopper's artwork goes onto that patch. On a blank print area declared a `patch`, the artwork is made into a sewn-on patch, with a twill backing and a merrow border. Chenille is not modelled yet and is refused (`400 UNSUPPORTED_DECORATION`, `patch` is the closest). The poll reports each zone's `decoration`, and a reset keeps it. `PATCH` on one zone sets its size in real units and its name. Given `widthIn` and `heightIn`, the print area takes exactly that shape (a 4.5 × 2 name is wide, a 3 × 14 sleeve tall) and its design is centred inside it, never stretched. Given one of them, it keeps the shape it has. A size needs the item's SCALE, because the model is normalised. There are two ways to give it, once per item. Without either, the scale is ASSUMED from what the product's name says it is, by its last type word, so a "Denim Trucker Jacket" is a jacket (a cap 5 in, a beanie 9, a mug 4, a tumbler 7, a shirt 30, a jacket 31, a jersey 32, a hoodie 33, an apron 34), kept, and said back as `scale: { productHeightIn, source: "assumed", assumedFrom }`; only a name that says nothing answers `409 NEEDS_PRODUCT_SIZE`. The two ways to give it: - **From a print you know (best).** On a zone the poll calls `placement: "measured"`, send that print's real size: `{ "actualWidthIn": 3.5 }` (or `actualHeightIn`, or both). A measured zone's box is the real print, so this fixes the inch for the whole item and nothing has to be guessed. It resizes nothing. It answers `409 NOT_MEASURED` on a zone that was not measured from the photo (or that you already sized), and `400 CONFLICTING_FIELDS` if sent with a size. - **The product's real height** (`productHeightIn`), top to bottom as it stands in the viewer. Only when no zone was measured: a guess here scales every zone on the item. Every answer returns `scale`, the scale now in force and where it came from (`given`, `measured`, `assumed`); the poll reports it too. Changing the scale later keeps every zone you sized at the inches you asked for. What you ask is kept, so a reset or a rebuild re-applies it (`keptOnReset` says so; a zone someone drew by hand is the exception). Once the item has a scale, every zone on the poll carries `sizeIn: { width, height }`. `zones` with `add` gives an existing item the print areas placed on the MODEL (`back`, `back_upper`, `left_side`, `right_side`, `side_label`, `left_sleeve`, `right_sleeve`), with no new build, placed on its own mesh exactly as a new build places them. Each can carry a size and a name as on a create (`{ "add": [{ "preset": "back_upper", "widthIn": 12, "heightIn": 4.6, "label": "Back arch" }] }`), applied at once; a size needs the item's scale first (`409 NEEDS_PRODUCT_SIZE`). The zones already there are not touched, so a design the merchant arranged keeps every edit, with one exception: a garment's back and its upper band are one stack, so adding either to an item that has the other restacks both, as a build does. The one already there keeps its size and moves (a full back moves down below a new band), and the answer names it in `moved`. It answers with the new zones' `id`, `label` and `preset`, and `skipped` for any already on the item. A preset read from the photo (`front`, `chest`, `full_front`, `left_chest`, `right_chest`) still needs a build: `400 NEEDS_BUILD`. Sleeves and the upper back band only on a garment (`NOT_A_GARMENT`); at most seven model-placed areas per item; `409 NO_FRONT_ZONE` when there is no front print area to turn from. Free, about as long as a reset. `zones/reset` places the item's zones again, from the measurement its photo gave, and replaces the primary viewer's zones (the slug and the embed do not change). It answers in seconds when the item has a stored measurement, and can take up to a minute when it does not (the zones are checked against the photo again), so give it a 90 second timeout. `flip: true` places them on the opposite face, for a zone whose poll says "this could be the front or the back". A design a person arranged or confirmed answers `409 MERCHANT_DESIGN`, because a reset replaces every zone; send `force: true` only when replacing it is what the merchant wants. Read `item.zones` on the poll afterwards. `colour-analysis` runs the colour analysis again with the colours the item already has, and answers `202` while it runs, about a minute; poll `GET /api/v1/items/{id}/colourways` until `regionsReady`. Existing colourways keep working throughout and afterwards. `409 NO_PALETTE` means no colours are known for the item yet. ### 4. Register the origins you serve from ``` POST /api/v1/viewers/{idOrSlug}/origins { "origins": ["http://localhost:3000", "https://yourshop.com"] } ``` **An empty origin list blocks the embed everywhere**, which is the single most common reason a developer sees a blank iframe. Register your dev origin first, then your production domain. Additive only, and there is no remove verb: a read-modify-write would lose origins added since the read, and the failure mode is a live storefront going dark. Exact origins only; wildcards are refused, because `https://*.vercel.app` matches every two-label Vercel origin on the internet. ### 5. Embed it ``` GET /api/v1/viewers/{idOrSlug}/embed ``` Returns `iframe` (paste-ready), `portal` (a redirect alternative for a strict CSP), and `renders`, which says plainly whether the thing will show and what to fix if not. The portal's `returnUrl` must be on the page that asks for the session (the snippet passes `window.location.href`); any other origin is a 400, because the shopper is sent there when they finish. Errors from `create-portal-session` carry CORS headers, so your page can read the reason. The snippet is responsive: a wrapper at a 4:3 ratio with the iframe filling it. Keep `referrerpolicy="strict-origin-when-cross-origin"`: the origin gate reads `Origin` or `Referer`, an iframe navigation sends no `Origin`, so a page with `Referrer-Policy: no-referrer` renders "Embedding not allowed" with nothing to diagnose. Never add `sandbox`. ### 6. Decide where a shopper's work goes ``` PATCH /api/v1/viewers/{idOrSlug} { "shoppersCan": "cart" } ``` Three values, and the same field comes back on every viewer read: - `"quote"` (default): the shopper fills in a form and you get a quote request. Nothing to wire up. - `"cart"`: no form. Your own Add to cart asks the viewer what the shopper chose and puts an id on your line item. **Use this if your shop has a cart.** - `"look"`: no customisation, no quote. It defaults to `"quote"`, so a new item shows the quote form until you change it. Also settable in the dashboard under "Shoppers can". ### 7. Get the configuration onto your cart line With the viewer in cart mode, ask the iframe when your Add to cart is pressed: ```js // Ask. requestKey comes back untouched, so two configurators on one page can be told apart. iframe.contentWindow.postMessage( { source: "tisfile-host", type: "request-configuration", requestKey: "sku-123" }, "https://tisfile.com" ); window.addEventListener("message", (e) => { if (e.origin !== "https://tisfile.com") return; // always check if (e.source !== iframe.contentWindow) return; // and that it is YOUR iframe if (e.data?.source !== "tisfile-studio") return; if (e.data.type === "configuration-saved") { // Put e.data.configurationId on your cart line as a hidden property. // e.data.reference is human readable (TF-XXXXXXXX). // e.data.summary is one line, no personal data and no price: fit for a cart row. } if (e.data.type === "configuration-failed") { // e.data.code, e.data.error. The reply always arrives, including on failure, // because your button is waiting on it. Let the shopper buy anyway. } }); ``` Every event and its payload is listed under "The viewer talks to your page" below. The two worth knowing here: `ready` carries `commerceMode`, so you can hide your own quote UI, and `model-ready` is the moment to drop a poster you held over the frame. Ask after the shopper stops changing things rather than intercepting your form's submit. Losing a configuration costs a customisation; breaking Add to cart costs the order. ### 8. Read it back and claim it ``` GET /api/v1/configurations/{configurationId} PATCH /api/v1/configurations/{configurationId} { "claimedRef": "" } ``` The GET returns the colourway, the zones, and `items[]`: per zone (`zoneId`, and `zoneLabel`, the print area's name from the viewer's design) what the shopper typed and an `artworkUrl` for what they uploaded, as a signed URL valid for 15 minutes. Fetch it, do not store it. An upload also says which colours to make it in: `colours: "artwork"` (its own colours) or `"design"` (the colour of the design it replaced, such as the engraving on a leather patch). On a patch, badge or label the shopper's artwork goes on the merchant's patch, inside the area the original design used. A zone is `filled` when something will actually be produced on it: the shopper's own work, or a design the merchant placed. A print area the shopper left as its placeholder is not. `isTest` is true when the configuration was saved from a local development origin (localhost, 127.0.0.1, a `.localhost` or `.test` name), in the iframe or in the hosted portal opened from such a page, so your own wiring does not look like a real order in the merchant's list. It is a label on where the save came from and nothing else: it changes no metering and no behaviour, and a configuration saved from a staging domain is not a test. **Claim it when an order references it.** An unclaimed configuration expires after 30 days, which is what stops abandoned carts keeping a shopper's artwork forever; claiming clears the expiry and makes it permanent and fulfillable. Claiming is idempotent for the same `claimedRef` and answers 409 for a different one. ### 9. Be told instead of polling: webhooks ``` POST /api/v1/webhooks { "url": "https://your.server/tisfile", "events": ["item.completed", "item.failed", "configuration.created", "configuration.claimed"] } GET /api/v1/webhooks DELETE /api/v1/webhooks/{id} POST /api/v1/webhooks/{id}/test sends a signed "ping" now and answers with what your server said ``` The create answers `201` with `secret` (`whsec_...`) **exactly once**: keep it where your server can read it. Events: `item.completed` and `item.failed` (a build finished, within about a minute, once per build, again after a rebuild), `configuration.created` (a shopper saved one, with its `id`, `reference`, `summary` and `isTest`) and `configuration.claimed` (the first claim only). Each delivery is a POST of `{ id, type, createdAt, data }` with `TisFile-Event`, `TisFile-Delivery` and `TisFile-Signature: t=,v1=`, where the HMAC is of `"."` under your secret. Check it on the raw body before parsing: ```js import crypto from "node:crypto"; function fromTisFile(rawBody, header, secret) { const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || ""); if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false; const want = crypto.createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest(); const got = Buffer.from(m[2], "hex"); return got.length === want.length && crypto.timingSafeEqual(got, want); } ``` Answer any 2xx quickly; anything else is retried after 1 minute, 5, 30, 2 hours and 6 hours, then given up on. An endpoint whose deliveries are given up on 10 times in a row is switched off (`disabled: true` on the list): delete it and make it again. Use `id` to ignore a repeat. A webhook must be `https`, on the default port, to a public address (`400 PRIVATE_URL` otherwise, checked again at every delivery); at most 5 per account (`409 TOO_MANY_WEBHOOKS`). Free. ## On Shopify If the store is Shopify, install the TisFile app and add the theme block once. Every published product resolves its own customizer from a metafield, and the block puts the configuration on the cart line itself. No developer code at all. Steps 6 to 8 still apply to your server. ## What the API does not do Deliberately, because an agent cannot see a 3D model: - **Place or move zones freely.** A zone is a position on a mesh. `autoZones` places one over the print your photos showed and `zones` places standard print areas; anything else is the dashboard. Deep-link a human to `/dashboard/studio/{itemId}/customize`. - **Correct colours by painting.** Visual work. - **Judge the mesh.** There is no quality signal to return. If it came out wrong, a human has to look, which is the other reason to deep-link. Not yet, and not because of eyes: - **The extracted logo library and text detection**, which would let you make the product's own printed text shopper-editable. Both are reachable only from the dashboard today. - **Print files.** The production export is dashboard-only. ## Things that will bite you 1. **`goal: "both"` or regret it.** The goal is permanent and the other two lock the editor. 2. **Register an origin or the iframe is blank.** Including localhost. 3. **Do not wait for the build.** The slug exists immediately; the model arrives into it. The create call itself takes 8 to 12 seconds, because it runs the two detect steps server side before it answers, so set a timeout of 30 seconds or more. A 10-second timeout turns one create into two; `externalRef` is what stops the second one buying a build. 4. **Read `blockers`.** A `completed` item can still be unusable, and it says why. Check `status` first: `blockers` is empty while an item is still `processing`, so an empty array means "nothing wrong YET", not "ready", until the status says `completed`. 5. **Keep `referrerpolicy`, never add `sandbox`.** Both break the origin gate. 6. **A blank product needs `zones`.** A visible print gives the shopper a zone automatically; a blank one gets none unless you ask, for example `zones=chest`. 7. **Send `externalRef`.** It is the only thing that stops a retry buying a second build of the same product, and a conversion of any size will retry something. ## Limits Two budgets per key, counted separately. The whole key gets 120 requests a minute. On top of that, each kind of write has its own a minute: 10 creates, 5 rebuilds, 30 colourway writes (create, change, delete and the settings call together), 60 configuration claims, 30 viewer and origin writes (one shared budget for those two), 10 zone resets, 10 zone additions, 30 zone edits and 5 colour analyses. Reads are bounded by the 120 alone. Every response carries where you stand, so pace against the headers rather than against 429s: ``` x-ratelimit-limit: 120 the key's budget x-ratelimit-remaining: 96 left in this minute x-ratelimit-reset: 24 seconds until the window turns over x-ratelimit-operation-limit: 10 this route's own budget (only when it has one) x-ratelimit-operation-remaining: 8 ``` A 429 says which budget ran out, in `scope`: `"key"` means slow everything down, `"operation"` means slow that one call down. `Retry-After` and `retryAfter` carry the seconds. Per viewer: 20 origins. Per item: 5 photos, 10 MB each. Per shopper submission: 8 images, 5 MB each decoded. ## The viewer talks to your page The iframe posts to its parent with `window.postMessage`. Every message carries `source: "tisfile-studio"`; check it, and check `event.origin` is the TisFile origin. ``` ready { configCount, commerceMode: "cart" | "quote" | "look", zones: [{ id, label }], colourways: [{ id, slug, name, original }], showOriginal, colourwayId } model-progress { slug, loaded, total } model-ready { slug } colourway-changed { colourwayId, name } null when back to the original; on every real change, yours (set-colourway) included content-changed { decalId } the shopper edited a zone configuration-saved { requestKey, configurationId, reference, summary } configuration-failed { requestKey, code, error } quote-submitted { referenceId } command-done { requestKey, command, zoneId? | colourwayId? } command-failed { requestKey, command, code, error } state { requestKey, command, colourwayId, colourwayName, editorOpen, zoneOpen, zones: [{ id, label, edited }] } ``` `ready` fires EXACTLY ONCE, so a listener attached after hydration misses it. Attach before the iframe loads, or treat the first `model-progress` as your signal. Your page can send these commands, each `{ source: "tisfile-host", type, requestKey?, ... }`: ```js iframe.contentWindow.postMessage( { source: "tisfile-host", type: "request-configuration", requestKey: "add-to-cart-1" }, "https://tisfile.com" ); ``` ``` request-configuration saves what the shopper configured: configuration-saved or configuration-failed open-zone { zone: "" } opens the editor on that print area (ids from ready, or item.zones[].id) close-editor closes the editor set-colourway { colourway: "" | null } null is the original colour get-state answers with `state` ``` So your own print-area chips and colour swatches can drive the viewer: build them from `ready`'s `zones` and `colourways`, send `open-zone` or `set-colourway` on a click, and listen for `colourway-changed` and `content-changed` to stay in step when the shopper uses the viewer's own controls. `colourway-changed` never fires for the colourway already shown, so answering it with `set-colourway` cannot loop. Every command except `request-configuration` changes only what is shown, never what is saved, and is answered with `command-done` or `command-failed` (`UNKNOWN_ZONE`, `UNKNOWN_COLOURWAY`, `NOT_EDITABLE`, or `BUSY` while the shopper is drawing on a print area), echoing your `requestKey`. `state.zones[].edited` says whether the shopper changed a print area; what they changed comes through `request-configuration`. Commands are honoured ONLY when they come from an origin registered on that viewer: the viewer compares your origin against the one the server verified, so an unregistered page gets silence, not an error. In a frame narrower than 680 px the editor opens as a sheet under the model rather than a panel over it, so a product column keeps the model in view. Deep link: `…/embed/studio/?colourway=`, read once on load. An unknown value falls back to what the viewer starts on. `GET /viewers/{ref}/embed?aspect=4:5` returns the snippet with that wrapper ratio, for a portrait or square gallery slot. Two numbers between 1:4 and 4:1; anything else is ignored and you get 4:3. The response echoes the `aspect` it used.