Developer documentation

Everything you need to integrate seeonwall.com into your online shop.

Setting up a shop? Install guides and step-by-step guides are on the help pages. Help →

Quick Start

Get up and running in under 5 minutes.

1

Create an account

Create a free account at seeonwall.com.

Sign up free →
2

Add the script tag

After logging in, copy the snippet from your dashboard and paste it into the <head> section of your shop.

<script async
  src="https://seeonwall.com/seeonwall.js"
  data-shop-id="YOUR_SHOP_ID"
></script>
3

Add buttons to product pages

Place a .seeonwall-button element wherever you want a preview button to appear. The snippet injects a styled "See on wall" button inside it automatically.

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"></div>

Installation

Paste this snippet into the <head> of every page where the preview button should appear.

<script async
  src="https://seeonwall.com/seeonwall.js"
  data-shop-id="YOUR_SHOP_ID"
  data-lang="en"
></script>

Script tag attributes

AttributeRequiredDescription
data-shop-idYesYour shop ID from the admin dashboard. Required.
data-langNoLanguage for the button and visualizer UI. Accepted values: en, pl, de, es, fr, it, pt, nl, sv, nb, da, fi, cs, sk. When omitted, the snippet reads the page's <html lang> attribute and updates automatically when it changes — no extra configuration needed for SPA language switchers.

Button Markup

Any element with the class seeonwall-button becomes a mount point. The snippet injects a styled "See on wall" button inside it.

Minimal example

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"></div>

With multiple sizes

When data-poster-sizes contains more than one size, the visualizer shows a row of size pills on the finished preview — on desktop and on the phone alike. When data-poster-width and data-poster-height are also present, they determine the initially selected size (nearest match by area). If omitted, the middle size is selected by default (the smaller of the two middle sizes for an even count).

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-title="Abstract Mountains"
     data-poster-width="50"
     data-poster-height="70"
     data-poster-sizes="30x40,50x70,70x100"
     data-product-page-url="https://example.com/products/abstract-mountains"></div>

Units and paper formats

Any dimension can state its own unit — "24cm", "10in", '11"' — or name an ISO format: "a4" in data-poster-width is 21cm, and in data-poster-height is 29.7cm, so writing it in both declares an A4 poster. Sizes lists take the same forms per entry, so one list may mix them. Shoppers see each size labelled the way you wrote it.

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="18in"
     data-poster-height="24in"
     data-poster-sizes="12x16in,18x24in,a3"></div>

A shop-wide default unit

data-size-unit sets the unit for values that do not state one. It can also go on the <script> tag to cover the whole shop. It never overrides a value that spelled out its own unit.

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-size-unit="in"
     data-poster-width="18"
     data-poster-height="24"
     data-poster-sizes="12x16,18x24,24x36"></div>

Framed prints

Previews are unframed by default, matching the bare print most shops sell. If the price on your page includes a frame, set data-frame-default-cm so the preview opens framed. Shoppers can add or remove a frame themselves either way. Shopify and WooCommerce merchants set this in the app block's Framing section and the plugin's Framing settings respectively, rather than editing markup. Preview links have the same option on the create form — tick “Set default frame”.

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"
     data-frame-default-cm="2"
     data-frame-default-color="#3E2723"></div>

Your own frames

If you have set up a frame catalogue in your seeonwall.com dashboard, name one and the preview opens with it — your name, your width, your colour. This takes priority over data-frame-default-cm and data-frame-default-color.

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"
     data-frame-preset="Oak"></div>

The name is the key. It has to match a frame in your catalogue exactly, apart from letter case. Rename a frame in the dashboard and this markup stops matching it, so change both together. If a shopper picks a different frame variant on your page, rewrite this attribute and an open preview follows along — until the shopper picks a frame of their own, which always wins.

→ Step-by-step guide: Show the frames that you sell

Limit the frames a shopper can pick

Add data-frame-options to offer only the frames that you sell with this product. The preview shows one button for each frame, and no width, colour or mat controls. Use null for a print without a frame. A list with one entry fixes the frame: ["Oak"] always shows Oak, [null] always shows no frame. Without the attribute, the setting under Frames in your dashboard applies.

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="54"
     data-poster-height="74"
     data-frame-options='["Oak", "Black"]'
     data-frame-size-included="true"></div>

When your listed size is the outer size of the framed product, add data-frame-size-included="true". The preview then draws the artwork smaller by two frame widths, so the framed poster has the listed size whichever frame the shopper picks. With "Unframed", the artwork has the listed size. A product with data-poster-mat ignores this attribute.

Add to cart from the preview

Tell the snippet what you sell of this poster. When the shopper's size and frame match a purchasable offer, the finished preview shows a compact cart-plus icon beside fullscreen, labelled Add to cart. Each offer is one sold size in one frame from your seeonwall.com frame catalogue — or unframed, written as null. The key is yours: we never read it, we only hand it back. The purchaseUrl is a page that adds exactly that offer when a browser opens it; it is what makes the control say Add to cart, and it needs no JavaScript from you. The productUrl shows the offer, ideally preselected. Relative URLs are fine.

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-sizes="30x40cm,50x70cm"
     data-product-page-url="/products/aurora"
     data-poster-offers='[
       {"key": "print-30x40", "width": "30cm", "height": "40cm", "framePreset": null,
        "productUrl": "/products/aurora?offer=print-30x40",
        "purchaseUrl": "/cart/add/print-30x40"},
       {"key": "oak-50x70", "width": "50cm", "height": "70cm", "framePreset": "Oak",
        "label": "Large", "image": "https://example.com/aurora-oak-50x70.jpg",
        "drawFrame": "forbidden",
        "productUrl": "/products/aurora?offer=oak-50x70",
        "purchaseUrl": "/cart/add/oak-50x70"}
     ]'></div>

The snippet matches exactly or not at all. A selection that matches no offer, or two, opens the product page instead — that includes a mat in a frame from your catalogue, and a size you do not list. A frame the shopper mixed themselves, with or without a mat, matches your unframed offer of that size: the shopper frames the print, so only the print goes in the cart. When none of your offers names a frame, the product is sold as the print only: a frame from your catalogue then also matches your unframed offer of that size, and the shopper adds the frame separately. The frame of data-frame-default-cm is yours, not the shopper's, so it matches no offer until the shopper changes it. The keys, productUrl and purchaseUrl stay in your page, and the snippet does the matching there. The preview gets only the label of each offer, in its list of sizes, and the size, framePreset, image and drawFrame of each offer that has an image: the snippet stores these on seeonwall.com, and the preview reads them by an ID. Up to 250 offers per button; a longer list is ignored whole, and an offer with a missing size, an invalid URL or a duplicate key is skipped. There is no price in an offer on purpose: your product and cart pages stay the price authority.

Three optional fields show each variant in the preview. image is an absolute https URL of an image of this variant: this size in this frame. drawFrame is "inherit", the default, or "forbidden". With inherit, the preview draws the selected frame on the image. Use forbidden when the image already shows the frame, or when the piece must have no frame: the preview shows the image as it is, at the declared size of the offer as the outer size, and draws no frame and no mat. In a size that has a forbidden image, every frame choice without an image of its own in that size is disabled, including no frame and a frame the shopper mixed themselves. label is the name of the size, at most 32 characters, shown in the size picker. The offers do not change which sizes and frames the shopper can pick: data-poster-sizes and the frame settings still decide that. A size or frame without an image of its own uses the unframed image of that size, else data-poster-url. When your Allowed domains list is not empty, the domain of each image must be on it; otherwise the whole list is refused and the preview shows data-poster-url. The browser console shows a warning when the offers and the sizes of the button do not agree.

On a desktop the snippet fires a cancelable purchase-request event on window before it navigates to the purchaseUrl. Listen to it to add with your own cart API instead — for a cart drawer, or an Ajax cart. Call preventDefault() synchronously, add the offer named by selectionKey, then navigate to your cart. If the add fails, call navigateFallback(): it opens the product page and never the purchase URL, which could add twice. On a phone the preview navigates itself to the purchaseUrl, so the event is a desktop enhancement, not a requirement. Shopify and WooCommerce stores get both the offers and the adapter from the app and the plugin.

// Optional: add with your own cart API instead of the purchaseUrl
// onSeeOnWall() waits for the async script tag. See JavaScript API.
onSeeOnWall(() => {
  const unsubscribe = SeeOnWall.on('purchase-request', (event) => {
    event.preventDefault()                       // take the request over
    const { selectionKey, navigateFallback } = event.detail
    addToCart(selectionKey)                      // your cart call, keyed by your offer key
      .then(() => location.assign('/cart'))
      .catch(() => navigateFallback())           // the product page, never the purchase URL
  })
})

Mockup product images

If your product photo is a mockup — the print on a coloured background, or inside a frame — the preview shows that whole photo at your poster's size, so the print looks smaller than it will be on the wall. Set data-poster-inset to the rectangle the print actually occupies, as left,top,width,height percentages of the image (e.g. 14.07,23.6,70.07,50.2). Because mockups are usually generated from a template, one value normally covers your whole catalogue. Leave it off if your images are the artwork itself. Shopify merchants set it per product in the Poster artwork inset metafield; WooCommerce merchants draw it on the product's seeonwall.com tab, under Artwork area. On Shopify the inset only applies when the Poster image metafield is set, and on WooCommerce when a poster image is chosen on that tab — the rectangle is measured against that specific image, and the storefront's own product image can change (Shopify swaps it per selected variant).

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster-mockup.jpg"
     data-poster-width="50"
     data-poster-height="70"
     data-poster-inset="14.07,23.6,70.07,50.2"></div>

Mats (passe-partout) that are part of the product

If the print you sell comes with a mat, declare it so the preview shows the mat and keeps the print at its true size. data-poster-mat states, for each size, how large the mat is, and data-poster-mat-color gives its colour. There are two cases, and which one applies depends on what your sizes describe.

The size is the print, and the mat goes around it. Write the print size, a colon and the outer size of the mat: 21x30:30x40 is a 21 × 30 cm print in a 30 × 40 cm mat. The print keeps its size, the object on the wall is 30 × 40 cm, and the mat is 4.5 cm wide at the sides and 5 cm at the top and bottom.

<!-- A 21x30 or 30x40 print; the mat adds 4.5 cm at the sides and 5 cm at the top and bottom -->
<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-sizes="21x30,30x40"
     data-poster-mat="21x30:30x40, 30x40:40x50"
     data-poster-mat-color="#FFFFFF"></div>

The size includes the mat, and the artwork is smaller. Write the size, a colon and the window of the mat: 30x40:21x30 is a 30 × 40 cm product with a 21 × 30 cm opening. Or write one width for all four sides: 50x70:5 is a 5 cm mat inside a 50 × 70 cm product. A width with no size in front of it (3 in this example) applies to every size that no other entry names.

<!-- A 30x40 or 50x70 print whose outer size includes the mat -->
<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-sizes="30x40,50x70"
     data-poster-mat="3, 30x40:21x30"
     data-poster-mat-color="#F5F0E8"></div>

Sizes and widths use the same notation as data-poster-sizes, and a number without a unit is in data-size-unit. A size larger than the size before the colon is the outer size of the mat; a smaller one is the window. Sizes match in either orientation, and the mat turns with the print. A size with no entry, and an entry that leaves no opening, show no mat. Commas separate entries, so use a period for decimals. data-poster-mat-color takes a #RRGGBB colour and is white when omitted.

If shoppers choose the mat on your product page, update the two attributes when they do: set data-poster-mat-color to the chosen colour, and remove data-poster-mat when they choose no mat. The button reads both when it is clicked. The same values go into SeeOnWall.open() and SeeOnWall.openCataloguePreview() as posterMat and posterMatColor. Without markup, tick “Sold with a mat”: on Shopify when editing a product on the seeonwall.com app’s “Your posters” page (or fill in the Poster mat and Poster mat colour metafields), on WooCommerce in the product’s seeonwall.com tab, and for preview links on the create form. These set one mat per product; a mat the shopper picks per variant is not supported yet.

In the preview the mat needs no frame. A frame the shopper adds goes around the mat, and the shopper’s own mat option is hidden while the product has one. Downloaded images, shared links, bookmarks and recently viewed posters show the mat too.

Per-button language

Add data-lang to an individual button element to pin it to a specific language, independent of the global snippet language. Both the button label and the visualizer opened from it will use that language.

<div class="seeonwall-button"
     data-lang="pl"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"></div>

Button shape

By default the button is filled with our own colours. data-button-variant changes its shape: "outline" keeps the same box with no fill, "glyph" is an icon and a label with no box at all, and "icon" is the icon on its own. The shapes without a fill take their colour from the text around them, so they follow your theme, including in dark mode. With "icon", the button text is still used as the tooltip and as the label screen readers read out. The colour attributes still apply wherever a shape can use them: data-text-color paints the label and the mark — and the outline’s border, unless data-border-color says otherwise — while data-bg-color reaches the solid button alone.

no attribute — the default
data-button-variant="outline"
data-button-variant="glyph"
data-button-variant="icon"

Those four buttons are real, injected by the same snippet this page documents. Click any of them to open the preview on a print from our demo shop.

<!-- An icon and a label, with no box around them -->
<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"
     data-button-variant="glyph"></div>

<!-- The icon on its own, beside your Add to cart button -->
<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"
     data-button-variant="icon"></div>

<!-- The mark and the label in your own colour -->
<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"
     data-button-variant="glyph"
     data-text-color="#2f5d50"></div>

Match your theme's button

Instead of setting a font, size, corners and spacing by hand, point the button at one your theme already has. data-match-button="auto" finds your Add to cart button and copies its shape and typography. Pass a CSS selector instead of "auto" to name a specific button.

<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"
     data-match-button="auto"></div>

<!-- Or name the button to copy, when "auto" picks the wrong one -->
<div class="seeonwall-button"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"
     data-match-button=".my-theme-add-to-cart"
     data-match-colors="true"></div>

Colours are not copied unless you add data-match-colors="true". By default the button keeps your own colour settings, so it reads as a secondary action rather than a second Add to cart.

Button element attributes

AttributeRequiredDescription
data-poster-urlYesAbsolute URL of the poster image. Required.
data-poster-titleNoPoster title shown in the visualizer header.
data-poster-widthYesPoster width in data-size-unit (centimetres by default). Required unless data-poster-sizes is set. When used alongside data-poster-sizes, determines the initially selected size.
data-poster-heightYesPoster height in data-size-unit (centimetres by default). Required unless data-poster-sizes is set. When used alongside data-poster-sizes, determines the initially selected size.
data-size-unitNoUnit for data-poster-width, data-poster-height and data-poster-sizes: "cm" (default) or "in".
data-poster-sizesNoComma-separated sizes, each a WxH pair or a format name: 30x40cm, 18x24in, a4. Enables the size selector. To name a size, put the name and = before it: Large=50x70cm. The size pill shows the name with the size beside it. A name can have at most 32 characters and no comma or =. Commas separate entries, so use a period for decimals — 21x29.7cm, not 21x29,7cm. The whole list may have at most 1000 characters. The preview shows no size selector for a longer list.
data-sizes-fromNoReads the sizes from a size control already on the page instead of from this element. Use "auto" to find the control inside the product form, or a CSS selector naming it, e.g. select[name="size"]. Options count as sizes when they read like 60x40cm, 18x24in or A4. It satisfies the size requirement on its own, overrides data-poster-sizes and the width/height pair whenever a control is read, and follows the shopper's selection.
data-frame-default-cmNoWidth in centimetres of the frame the preview starts with, e.g. 2. Omitted or 0 previews the poster unframed. Set it only when the listed price includes a frame.
data-frame-default-colorNoColour of that default frame as a 6-digit hex value, e.g. #3E2723. Ignored unless data-frame-default-cm is greater than 0. Defaults to black.
data-frame-presetNoName of a frame from your seeonwall.com frame catalogue, e.g. Oak. The preview opens with that frame. Takes priority over the two attributes above. An unknown name is ignored.
data-frame-optionsNoJSON array of frame names from your frame catalogue, and null for no frame, e.g. ["Oak", null]. The shopper can pick only these frames. With one entry, the preview always shows it and has no frame controls. A name that is not in your catalogue is removed.
data-frame-size-includedNoSet to true when the poster size includes the frame. The preview draws the artwork smaller by two frame widths. Works only with data-frame-options that names at least one frame.
data-poster-insetNoWhere the artwork sits inside the product image, as left,top,width,height percentages, e.g. 14.07,23.6,70.07,50.2. Use it when the image is a mockup with padding or a frame so the print previews at its true size. Omitted, or if the value is malformed, the whole image is used.
data-poster-matNoA mat that is part of the product, for each size. 21x30:30x40 puts a 21×30 print in a 30×40 mat; 30x40:21x30 gives a 30×40 product a 21×30 window; 50x70:5 is a 5 cm mat inside the 50×70; a width on its own applies to every size not named. Omitted, or for a size with no entry, no mat is shown.
data-poster-mat-colorNoColour of the mat as #RRGGBB, e.g. #F5F0E8. White when omitted.
data-poster-fitNoHow the image goes into a size of other proportions. auto (default) stretches the image by up to 8% and crops only the remainder from the edges, so text near the edges usually stays visible. cover crops the centre of the image to the size, like CSS object-fit: cover. stretch fills the size with the whole image, like object-fit: fill, and distorts the artwork. Any other value is read as auto.
data-product-page-urlNoExplicit product page URL. When set, bookmark links point here instead of the page where the button was clicked. Useful on listing or search pages.
data-poster-offersNoJSON array of what you sell of this poster: key, width, height, framePreset (a frame from your catalogue, or null for unframed), productUrl, purchaseUrl, and optionally image, drawFrame and label. Gives the finished preview a compact cart-plus icon labelled Add to cart, or a View product action. See Add to cart from the preview.
data-langNoPins this button to a specific language (en, pl, de, es, fr, it, pt, nl, sv, nb, da, fi, cs, or sk). Overrides the global snippet language for this button only — the label and the visualizer UI both use this language. Buttons with data-lang are unaffected by SeeOnWall.setLanguage() calls and <html lang> changes.
data-button-text-{lang}NoOptional. Custom button label for one language: replace {lang} with en, pl, de, es, fr, it, pt, nl, sv, nb, da, fi, cs, or sk. Used when that is the effective language of the button (from data-lang on this element or the global snippet language). Without it, the button shows the built-in label for that language.
data-button-variantNoThe button’s shape: solid (default), outline, glyph (icon and text) or icon (icon only). The shapes without a fill ignore data-bg-color and take their colour from the page.
data-bg-colorNoButton background colour. Accepts any CSS color or gradient, e.g. #1a1a2e or linear-gradient(135deg, #c05a33, #9f441f). Overrides the page-level --seeonwall-btn-bg CSS variable for this button only. Only the solid shape has a background to paint; see data-button-variant.
data-text-colorNoButton label text colour, e.g. #ffffff. Overrides --seeonwall-btn-color for this button only.
data-border-radiusNoButton corner radius, e.g. 8px or 50%. Overrides --seeonwall-btn-radius for this button only.
data-border-colorNoButton border colour, e.g. #333333. Overrides the default border colour for this button only.
data-button-widthNoSet to "full" to stretch the button to the full width of its container. Any other value, or none at all, leaves it at its natural width.
data-match-buttonNoCopy the appearance of another button on the page. "auto" finds your Add to cart button; any other value is treated as a CSS selector.
data-match-colorsNoWith data-match-button, also copy that button's colours. Off by default.

The button reads its data-poster-* attributes at click time, so you can update them live (e.g. when a shopper picks a size) and the next click opens the preview with the new values. The Shopify app uses this to follow the selected size variant automatically.


npm package

For storefronts that build their own product pages — Hydrogen, Next.js, Nuxt. If your shop is a Shopify theme or a WooCommerce site, install the app or the plugin instead; you do not need this package.

npm install seeonwall

React

import { useSeeOnWall, SeeOnWallButton } from 'seeonwall/react'

function ProductPage({ product }) {
  useSeeOnWall({ shopId: 'YOUR_SHOP_ID' })

  return (
    <SeeOnWallButton
      posterUrl={product.image}
      posterTitle={product.title}
      posterWidth={50}
      posterHeight={70}
      posterSizes={['30x40', '50x70', '70x100']}
    />
  )
}

useSeeOnWall loads the widget and stops it when the last component using it unmounts. It counts its callers, so calling it in several components is safe.

SeeOnWallButton renders the mount and lets the widget place the button inside it, so branding, allowed domains, theme matching and the button label behave exactly as they do with the script tag. Its props mirror the data attributes above, and posterSizes also accepts an array.

Without React

import { load } from 'seeonwall'

await load({ shopId: 'YOUR_SHOP_ID' })

Load the widget yourself and write the button markup exactly as shown above.

Server rendering is safe. Every function returns quietly when there is no document, so importing the package on the server does not throw.

Source code, the full API and TypeScript types: github.com/seeonwall/seeonwall-npm


JavaScript API

After the snippet loads, a global SeeOnWall object is available on the page.

// The script tag is async, so window.SeeOnWall can be missing when your code runs.
// Run your code through this helper:
function onSeeOnWall(fn) {
  if (window.SeeOnWall) fn()
  // Before the tag is parsed there is no element yet. The page load waits for an async script.
  else (document.querySelector('script[data-shop-id]') ?? window).addEventListener('load', fn)
}

onSeeOnWall(async () => {
  // Open the visualizer for a poster
  SeeOnWall.open({
    posterUrl: 'https://example.com/poster.jpg',
    posterTitle: 'Abstract Mountains',    // optional
    posterWidth: '50cm',                  // optional — number, "24cm"/"10in", or "a4"
    posterHeight: '70cm',                 // optional — same forms; "a4" here is 29.7cm
    posterSizes: '30x40,50x70,70x100',   // optional — enables size selector
    posterInset: '14.07,23.6,70.07,50.2', // optional — artwork rect in a mockup image
    posterMat: '21x30:30x40',             // optional — mat of the product, per size
    posterMatColor: '#FFFFFF',            // optional — colour of that mat
    posterFit: 'auto',                    // optional — 'auto' (default) stretches ≤8% then crops, 'cover' crops, 'stretch' distorts
    productPageUrl: 'https://example.com/products/abstract-mountains', // optional — bookmark target
    sizeUnit: 'cm',                       // optional — fallback unit for values that state none
    frameDefaultCm: 2,                    // optional — preview starts framed (default: unframed)
    frameDefaultColor: '#3E2723',         // optional — colour of that default frame
    frameOptions: ['Oak', 'Black', null], // optional — the only frames a shopper can pick; null is no frame
    frameSizeIncluded: false              // optional — true when the poster size includes the frame
  })

  // Close the visualizer modal (desktop only)
  SeeOnWall.close()

  // Change the snippet language at runtime (SPA use case)
  SeeOnWall.setLanguage('pl')

  // Current snippet version
  console.log(SeeOnWall.version)

  // Storefront catalogue-preview readiness (a cached hint; refresh before acting)
  await SeeOnWall.ready()   // start-up is deferred to DOMContentLoaded
  console.log(SeeOnWall.isSessionReady())
  await SeeOnWall.refreshSessionState()
  const unsubscribe = SeeOnWall.on('session-change', () => {
    // Update catalogue-preview controls in this tab. Also runs when the shopper
    // returns after setting up their wall on a phone.
  })
  // Call unsubscribe() when your page/component is disposed
})
SeeOnWall.open(params)

Opens the visualizer for the given poster. On desktop it appears in a modal overlay. On mobile it opens in a new tab.

SeeOnWall.close()

Closes the visualizer modal. No-op on mobile.

SeeOnWall.openCataloguePreview(params)

Opens the fast wall preview for one product, on the wall the shopper has already set up. Takes the same poster parameters as SeeOnWall.open() and resolves to { opened: true }, or to { opened: false, reason } when nothing opened. Needs a paid plan. → Fast wall preview

SeeOnWall.setLanguage(lang)

Changes the snippet's active language at runtime. Updates all already-injected button labels immediately and applies to every modal opened afterwards. Useful in SPAs when you want to explicitly drive the language from your own logic rather than relying on the automatic <html lang> observer.

SeeOnWall.version

Read-only string with the version of the loaded snippet.

SeeOnWall.ready()

Resolves once the snippet has initialised and the rest of this API is safe to call — button scanning waits for DOMContentLoaded, so the global can exist before it is ready. Await it before reading session state. The script tag is async, so call the API from the tag's load event, or from code that runs after it: window.SeeOnWall does not exist until the script evaluates.

SeeOnWall.isSessionReady()

Returns the cached readiness hint for the current shop session. Use it to decide whether to show optional fast wall preview controls; it is never authorization. It is also false while the snippet is still initialising, so await SeeOnWall.ready() before reading a false as “no wall”.

SeeOnWall.refreshSessionState()

Checks the current session with seeonwall.com and resolves to none, creating, ready, or expired. A missing or expired session is cleared from storefront storage.

SeeOnWall.on('session-change', listener)

Runs the listener when the session state changes in this tab, in another tab on the same shop origin, or when the shopper returns to the page after setting up their wall on a phone — the snippet re-checks a pending session on return. Returns an unsubscribe function.

SeeOnWall.on('purchase-request', listener)

Runs the listener when a shopper presses Add to cart in a desktop preview. The event is cancelable: call preventDefault() to add the offer with your own cart API and navigate to your cart yourself; otherwise the snippet navigates to the offer's purchaseUrl. event.detail carries selectionKey, the size and frame, and navigateFallback(). Returns an unsubscribe function.

SeeOnWall.setConsent(granted)

Tells the snippet whether the shopper agreed to analytics in your consent banner. Call it on every page load. The value is kept in memory only. Without setConsent(true), track() sends nothing. → Order analytics

SeeOnWall.track(event, data)

Reports an add-to-cart ('add_to_cart', { posterUrl }) or an order ('purchase', { orderId, items: [{ posterUrl, quantity }] }) for order analytics. It does nothing until you turn order analytics on and the shopper consents. It waits at most 3 seconds for the settings of the shop and does not wait for the response, so you can await it before a form submit. It never rejects. → Order analytics


Fast wall preview

Once a shopper has set up their wall, they can see any other poster on it in one tap — from a product listing, a category grid, or a row of recommendations — without going through the visualizer again. Included in Basic, Premium and Ultra.

Both platform integrations offer this without code. In the Shopify app, switch on the placements you want under Fast wall preview, and the theme extension puts the control on those listings. In the WooCommerce plugin, do the same under Button → Fast wall preview; the plugin marks each eligible product card itself, so no theme selector is needed. Anywhere else it is for shops that load the script tag themselves and call the launcher from their own listing templates.

How it works

  1. 1A shopper opens the visualizer once, from any product, and sets up their wall. That wall belongs to the session and lasts seven days.
  2. 2On your listing pages you ask the snippet whether a wall is ready, and show a small preview control on each product card when it is.
  3. 3The shopper taps the control and the poster appears on their own wall right there — one product, no camera step, no setup.
  4. 4The way out of the preview is a link to that product’s page.
// Show your preview controls only while the shopper has a wall
// onSeeOnWall() waits for the async script tag. See JavaScript API.
onSeeOnWall(async () => {
  await SeeOnWall.ready()

  function refreshControls() {
    const ready = SeeOnWall.isSessionReady()
    document.querySelectorAll('.my-preview-icon')
      .forEach(el => { el.hidden = !ready })
  }

  refreshControls()
  SeeOnWall.on('session-change', refreshControls)

  // Launch one product — the same poster parameters as SeeOnWall.open()
  icon.addEventListener('click', async () => {
    const result = await SeeOnWall.openCataloguePreview({
      posterUrl: 'https://example.com/poster.jpg',
      posterTitle: 'Abstract Mountains',                                 // optional
      posterWidth: '50cm',
      posterHeight: '70cm',
      posterSizes: '30x40,50x70,70x100',                                 // optional
      posterInset: '14.07,23.6,70.07,50.2',                              // optional
      posterMat: '21x30:30x40',                                          // optional
      posterMatColor: '#FFFFFF',                                         // optional
      posterFit: 'auto',                                                 // optional
      productPageUrl: 'https://example.com/products/abstract-mountains'  // optional
    })

    if (!result.opened) {
      // Nothing opened. result.reason says why — see the list below.
      console.log(result.reason)
    }
  })
})

When it can open

  • The shopper has a wall set up for your shop, and that session has not expired.
  • Your plan is Basic, Premium or Ultra.
  • The call carries a poster image URL and a readable width and height — the same values your buttons use.
  • The page calling it is on your allowed-domains list.
  • Your poster views for this hour and this month are not used up.

What comes back

openCataloguePreview() resolves to { opened: true } when a preview opened, or to { opened: false, reason } when nothing did. The reason tells your page what to do instead:

  • no-ready-session — The shopper has no wall for your shop yet. Show your ordinary “See on wall” button, which starts one.
  • session-expired — The wall passed its seven days and has been deleted. The next full preview starts a new one.
  • not-entitled — The plan of this shop does not include the fast wall preview.
  • rate-limited — The poster views for this hour or this month are used up. They reset at the top of the hour and at the start of the month.
  • invalid-poster-data — The call had no poster image URL, or no width and height that could be read.
  • poster-unavailable — The preview could not be prepared for this product.
  • network-error — The request did not reach seeonwall.com, or the snippet had not initialised yet — await SeeOnWall.ready() first.

What the preview does

One product, read-only

The preview shows the poster you passed and nothing else: no poster history, no search, no way to swap to another product. Its main action takes the shopper to your product page.

Opens in place

A modal over your page on a desktop, a nearly full-screen sheet on a phone. It never opens a new tab, because the wall is already set up and there is no camera step to hand over.

Sizes and mockups carry over

The size list and the artwork rectangle inside a mockup image are read exactly as they are on your buttons, so a shopper can still compare sizes inside the preview.

Counts as a poster view

Each fast wall preview is one poster view against your hourly and monthly allowance. One shopper seeing one poster counts once, however often they open it.

Counted separately in your dashboard

Shop analytics shows fast wall previews beside your poster views, with a 30-day chart, and the top-poster table shows how many of each poster’s previews came from a listing.


Order analytics

See how often shoppers add a poster to the cart and order it, grouped by how far they got in the preview: the poster was on their wall, they set up a wall and saw other posters, they started a preview, or they did not use the preview. The last group is the comparison.

For shops that load the script tag themselves, and in the WooCommerce plugin: turn it on in the plugin's Settings tab. The plugin reports from your server and also counts refunds; your consent banner must support the WP Consent API. In the Shopify app: turn it on in the Analytics tab, and let the app read your orders when Shopify asks. The app reads your orders once a day and also counts refunds; the shopper's browser reports each add-to-cart from your store's own domains, which the app reads from Shopify. Consent comes from your Shopify customer privacy settings.

How to set it up

  1. 1In the dashboard, open Shop settings, add your shop domain under Allowed domains, and turn on Order analytics. Events from a page that is not on the list are not counted.
  2. 2On every page, call SeeOnWall.setConsent(true) when the shopper agreed to analytics in your consent banner. Before that call, the snippet sends nothing and stores nothing for order analytics.
  3. 3On the product page, call SeeOnWall.track('add_to_cart', { posterUrl }) when the poster goes into the cart. It counts only when the page shows the button for that poster.
  4. 4On the order confirmation page, call SeeOnWall.track('purchase', { orderId, items }) with the posters of the order that show the button. A second report of the same order ID is not counted.
// On every page, after your consent banner knows the choice of the shopper
onSeeOnWall(() => {
  SeeOnWall.setConsent(shopperAcceptedAnalytics)
})

// On the product page, when the poster goes into the cart
onSeeOnWall(() => {
  SeeOnWall.track('add_to_cart', { posterUrl: 'https://example.com/poster.jpg' })
})

// On the order confirmation page. Pass only the posters that show the button.
onSeeOnWall(() => {
  SeeOnWall.track('purchase', {
    orderId: '1042',
    items: [{ posterUrl: 'https://example.com/poster.jpg', quantity: 2 }],
  })
})

What is stored

  • Daily counts per poster and per group: lines added to the cart, lines ordered, and the quantity ordered. No order, no customer, no address and no price. A WooCommerce store and a Shopify shop also report refunds, and the refunded quantity is counted the same way.
  • A one-way code of each order ID, for 30 days, to count each order only once. The order ID itself is not stored. The same is kept for each refund ID that a WooCommerce store or the Shopify app reports.
  • In the shop's localStorage entry, the posters that were on the shopper's wall in this session. Only after setConsent(true), and only while order analytics is on. They are removed when the shopper withdraws consent (setConsent(false)) or when you turn order analytics off. In a WooCommerce store, with consent, the store also keeps the preview step of each poster in the cart in the session cookie seeonwall_labels, and deletes it when consent is withdrawn. In a Shopify shop, with consent, the preview step goes into the cart line as the hidden property _sow_state, and the app reads it from the order.
  • If you turn order analytics off, the counting stops and the order codes are deleted. The counts so far stay in your dashboard. For a WooCommerce store or the Shopify app, the order codes stay until their 30 days end, so a refund after you turn it on again still matches its order.

Language & Localisation

The snippet adapts its button labels and visualizer UI to the visitor's language. Three complementary mechanisms handle this, in priority order.

Automatic: <html lang> observer

When data-lang is not set on the <script> tag, the snippet reads document.documentElement.lang at startup and watches it via a MutationObserver. Most i18n libraries (including react-i18next) update <html lang> automatically — if yours does, no extra configuration is needed.

// Keep <html lang> in sync with your i18n library (e.g. react-i18next)
import i18n from './i18n'

i18n.on('languageChanged', (lang) => {
  document.documentElement.lang = lang
})

// The snippet detects the change automatically — no SeeOnWall calls needed.

Once SeeOnWall.setLanguage() is called, the <html lang> observer is disabled for the rest of the page load. The explicit call becomes authoritative.

Explicit: SeeOnWall.setLanguage(lang)

Call setLanguage(lang) to change the language imperatively at any point after the snippet loads. All already-injected button labels update immediately, and every modal opened afterwards uses the new language.

// onSeeOnWall() waits for the async script tag. See JavaScript API.
onSeeOnWall(() => {
  // Drive language from your own logic
  SeeOnWall.setLanguage('pl')

  // All injected button labels update immediately.
  // The <html lang> observer is disabled for the rest of this page load.
  // Call setLanguage() again at any time to switch back.
})

Static: data-lang on the script tag

Set data-lang (any value listed for data-lang under Installation, for example data-lang="de") on the <script> tag to lock the language for the entire page. The <html lang> observer is never activated. You can still call setLanguage() at runtime to override it.

Per-button language: data-lang on the element

Add data-lang to an individual .seeonwall-button element to pin that specific button to a language, independent of the global snippet setting. The button label and the visualizer opened from that button will both use the specified language. These buttons are unaffected by SeeOnWall.setLanguage() calls and <html lang> mutations — useful for static sites that serve content in multiple languages from a single page.

<div class="seeonwall-button"
     data-lang="pl"
     data-poster-url="https://example.com/poster.jpg"
     data-poster-width="50"
     data-poster-height="70"></div>

Priority order

setLanguage() overrides the current language at any time. The <html lang> observer is only active when neither data-lang was set nor setLanguage() has been called yet.

Per-button text overrides

Individual buttons can also override the label text using data-button-text-en, data-button-text-pl, data-button-text-de, data-button-text-es, data-button-text-fr, data-button-text-it, data-button-text-pt, data-button-text-nl, data-button-text-sv, data-button-text-nb, data-button-text-da, data-button-text-fi, data-button-text-cs, or data-button-text-sk. The attribute matching the effective language for that button (set by data-lang on the element or the global snippet language) is used. → Button Markup



Visitor Flow

How a shop visitor experiences seeonwall.com, from clicking the preview button to seeing their poster on their actual wall. → Automatic wall detection

Desktop (default)

  1. 1Visitor clicks “See on wall” on a product page.
  2. 2A modal opens on the desktop with a QR code.
  3. 3The visitor sets up the wall background: scans the QR code with their phone to capture the room live, uploads a photo from their PC, or picks a room template.
  4. 4For photo capture and uploads: the wall is detected automatically and its real-world dimensions estimated, so the preview usually appears with no further input. If the photo is unclear, the visitor adjusts pre-filled corners and dimensions instead of starting from scratch.
  5. 5The preview appears straight away — the poster is placed centred on the wall automatically, at its true printed size.
  6. 6The desktop modal shows the same preview, and the poster stays movable: the visitor drags, resizes or reframes it on the finished preview itself.

Same-device (visitor is already on mobile)

  1. 1Visitor taps “See on wall” on their phone.
  2. 2The visualizer opens in a new browser tab — no QR code step.
  3. 3The photo is analysed on arrival: usually the preview appears immediately, otherwise the visitor confirms the pre-filled calibration first.
  4. 4The final preview is shown in the same tab.

If the visitor closes and reopens the modal within the session’s 7-day validity window, the previous session is resumed automatically — they do not have to repeat the photo and calibration steps.


Automatic Wall Detection

Every wall photo is analysed the moment it arrives, before the visitor is asked to do anything. When the analysis is confident — the common case — the poster appears on the wall straight away. Marking corners by hand is the fallback, not the default path.

What happens to a wall photo

  1. 1Quality and content check. Every photo is screened as it arrives. A photo that fails the content check is refused: it is never linked to a session, never shown, and the uploaded file is deleted immediately.
  2. 2Wall detection. The wall is located in the image and its four corners are returned as percentages of the photo, so they survive any resizing.
  3. 3Dimension estimate. The wall's real-world width and height are estimated from the scene, so the visitor is never asked to measure their wall. Where no estimate is possible, a fallback assumes a standard 250 cm wall height and derives the width from the detected shape.
  4. 4One-step setup. When corner confidence is high enough, the calibration and a centred poster placement are saved together in a single step, and the session goes straight from photo to finished preview.
  5. 5The visitor stays in control. The preview is labelled as an automatic estimate and offers a fine-tune action. Corners, wall dimensions, and poster position all remain editable.

When the photo is difficult

Partially hidden wall

A sofa, a shelving unit, or tight framing can hide the wall's real boundary. Instead of giving up, detection returns the largest usable rectangle — the clear area above a couch, for example — and the poster is placed within it.

Low confidence

Below the confidence threshold the visitor gets the manual calibration step, but the corner handles start on the detected positions and the estimated dimensions are pre-filled. It is an adjustment, not a blank slate.

No result at all

If the analysis times out or fails, the flow continues with default corner handles and manual dimensions. Detection is best-effort and never blocks a preview from being created.

Guidance the visitor sees

  • Dim lighting — suggests switching on more light or opening the curtains.
  • Backlit wall or glare, such as a window behind the wall — suggests shooting from a different angle.
  • No wall found — asks for a retake with the wall clearly visible, while still allowing manual placement.
  • Poster larger than the detected wall — the visitor is told the maximum size their wall setup fits and offered the sizes that do, instead of being shown a preview that could not be true.

What makes a good wall photo

  • Frame the whole wall, including where it meets the floor and the ceiling.
  • Shoot straight on rather than at a steep angle.
  • Avoid a bright window directly behind the wall.
  • Turn the lights on — dim photos produce a weaker estimate.

Automatic detection runs on every wall photo, on every plan including the free one. There is nothing to switch on and no setting to configure. Room-scene templates skip the step entirely, because their geometry is already known.

The analysis is performed by an OpenAI GPT model on Microsoft Azure in the EU, reached through Requesty's EU gateway in Frankfurt. Wall photos are never used to train a model, the gateway does not log them, and the analysis is deleted together with the session. → Sub-processors


Rate Limits

Limits apply per account. Every limit below is hourly, resetting at the top of each UTC hour, except the monthly poster-view ceiling, which resets at the start of each UTC month. Upgrade your plan in the admin dashboard to unlock higher limits. Auto setup analyses are also counted per UTC month.

PlanSessions / hrPhotos / hrPoster views / hrPoster views / moDownloads / hrWalls / sessionAuto setup / mo
FREE1504010050050230
BASIC5002001,0005,0002505200
PREMIUM2,5001,00010,00025,0001,00010500
ULTRA10,0002,00050,000Unlimited2,000102,500
S
A session is created each time a visitor opens the visualizer from your shop.
P
A photo is counted each time a visitor uploads a wall image from their phone.
W
The number of walls one shopper can keep in a session at the same time — photos of their own rooms, sample rooms, or a mix. They switch between them in the preview; adding another once the limit is reached asks them to remove one first. This is a total, not an hourly budget: it is not spent and does not reset.
V
A poster view is counted each time the visualizer renders a poster preview on a calibrated wall. A fast wall preview counts as a poster view as well.
M
Poster views are the one metric with two limits, and both are tracked: a view counts against the hourly budget and against the monthly one, and either being used up stops further views from being recorded. The hourly window resets at the top of each UTC hour, the monthly window at the start of each UTC month.
D
A download is counted each time a visitor saves or shares the final poster preview image.
A
An auto setup analysis is counted for each product the Shopify or WooCommerce app analyses, also when it finds no usable image. A product is not counted when its analysis fails or is stopped before the AI model answers (for example because an image cannot be loaded). It is counted when that happens after the model answered, and analysing the same product again counts again. The count is per account and resets at the start of each UTC month. During the 14-day free trial of a paid plan, at most 100 products can be analysed, whatever the plan. A product that shows the same mockup as an analysed product of the same run is set up from that result and is not counted. When no analyses are left, the other products of the run are not analysed, and a later run selects them again.

An IP-level guard applies on top of plan limits: at most 600 session-creation and 300 photo-upload requests per IP address, counted in a rolling one-hour window that starts with that IP's first request. This protects against single-IP abuse and does not affect normal usage patterns.


When Rate Limits Are Hit

What happens from the visitor’s point of view when your shop reaches a limit.

Session-creation limit reached

The “See on wall” button continues to appear on your product pages. When a visitor clicks it, the visualizer modal opens but shows a friendly error message instead of a QR code. The message asks them to try again shortly. No crash, no blank screen.

Photo-upload limit reached

A visitor who has already scanned the QR code and is on the mobile upload screen will see an error if the limit is exceeded between the time they opened the modal and the time they attempt to upload. They are prompted to try again.

Poster view limit reached

A poster view is counted when a preview is shown on a calibrated wall — on the desktop, on the phone, or both. It counts once per session per day however many times it is rendered, so a shopper looking at one preview costs one view. This is the limit a shopper meets at the last step: instead of the poster on their wall they see a message asking them to try again shortly. Poster views are the one metric with two limits, hourly and monthly, and a view spends both.

Monthly poster-view limit reached

Separate from the hourly view limit and enforced alongside it. Where the hourly one clears by itself within the hour, this one stays exhausted until the month rolls over — so it is the limit that means your plan has been outgrown. Until it resets or you upgrade, the “See on wall” button is hidden on your product pages, so shoppers do not start a preview that cannot finish. A shopper who opened a page before the limit ran out still sees the button and is asked to try again later at the final step. The button comes back within a minute of the reset or the upgrade.

Download limit reached

If the download limit is reached, the save/share action fails and the visitor is shown an error. The preview itself remains visible — only the download action is blocked.

Hourly limits reset at the top of every UTC hour, so traffic spikes shorter than an hour are typically self-resolving. The monthly poster-view ceiling resets at the start of each UTC month — reaching that one is a signal to upgrade rather than something that clears on its own.


Session Lifecycle

Every visualizer session moves through four states. The desktop receives live session updates over a server-sent event stream and reacts to each state change instantly. When wall detection is confident, a session moves from Active to Ready in one step and never enters Calibrated. Sessions expire after 7 days and are then automatically deleted.

CREATED
Created

Triggered when a shop visitor opens the visualizer. A unique session ID is generated and a QR code is displayed on the desktop so the visitor can continue on their phone.

→
ACTIVE
Active

Triggered when the visitor uploads a photo of their wall from the mobile device. The desktop transitions out of the QR-code screen.

→
CALIBRATED
Calibrated

Records the wall calibration: four corner coordinates plus the real-world wall dimensions (width and height in cm). The corners are pre-filled by automatic detection and the dimensions estimated, so this state captures what the visitor confirmed or adjusted. Coordinates are stored as percentages so they survive image resizing.

→
READY
Ready

Reached as soon as the wall is calibrated: a centred poster placement is written with it, so no separate confirmation step stands between the wall and the preview. This is the terminal state — the desktop renders the final canvas overlay. Later edits to the poster update that placement without changing the state.

Fast path: Active → Ready

When corner detection is confident enough, the calibration and a centred placement are written in a single atomic step, so the session skips Calibrated altogether. Room-scene templates take the same path. The desktop then receives one Ready event rather than two separate updates.

Expiry & cleanup

All sessions are kept for 7 days from creation, then permanently deleted regardless of their state. All associated wall photos are removed at the same time. Nothing from the session survives expiry.

Data objects

Sessionunique UUID, current state, shop reference, creation timestamp, 7-day expiry
Photowall image captured or uploaded by the visitor; stored in cloud storage, linked to the session, and accompanied by the analysis derived from it (detected corners, estimated dimensions, quality flags)
Calibrationfour wall-corner coordinates (each 0–100 % of the photo) and real-world wall dimensions (cm)
Placementposter position, scale, and rotation angle on the calibrated wall surface

Poster Image Requirements

Requirements for the poster image URL you provide. The browser loads the image directly into the preview, and the server fetches it separately when generating a downloadable or shareable preview.

  • The URL must be absolute (starting with https://).
  • The image must be publicly accessible — no authentication, no login walls, no hotlink protection that blocks foreign origins.
  • Use JPEG, PNG, or WebP. AVIF displays in the in-browser preview but is not supported for preview download and sharing.
  • For best rendering quality use the largest available version of the image. The visualizer scales it down to fit the wall; low-resolution sources look blurry at large print sizes.
  • No CORS headers are required. The poster is rendered as a plain image overlay rather than drawn onto a canvas, so a cross-origin image from your CDN loads normally.
  • The image must be 4 MB or smaller. The server fetches your poster image during preview generation and rejects anything larger.

The server rejects URLs that do not return a Content-Type starting with image/ (e.g. a page returning text/html will fail). Standard image CDN URLs always satisfy this requirement.

Hotlink protection is the usual culprit when an image will not load: it blocks requests carrying a foreign referrer, which breaks both the in-browser preview and the server-side fetch used for downloads and shares. Disable it for the poster image path, or serve those images from a host that allows it.


WooCommerce hooks & filters

The WooCommerce plugin places and styles the button from its own settings screen. These filters and helpers take over where that screen cannot reach, such as a theme with its own product template. Add them in your theme’s functions.php or in a small site plugin.

Placing the button yourself

Set Position on the product page to “Place it myself with a shortcode”, which stops the plugin adding a button of its own, then write the shortcode wherever your theme or page builder allows. On a page that is not a product page, name the product by ID.

[seeonwall_button]

[seeonwall_button id="123"]

Published products only — an ID naming a draft or pending product renders nothing. The shortcode works under any position setting, but under the others it adds a second button rather than moving the first, so pick “Place it myself” when the shortcode is meant to be the only one.

Filter reference

seeonwall_button_hook( string $hook, string $position ) — the WooCommerce action the button is rendered on. Return an empty string for no automatic placement at all.
seeonwall_button_priority( int $priority, string $position ) — the priority the button is added at on that action, for moving it within a hook other things also use.
seeonwall_button_html( string $html, WC_Product $product ) — the button markup itself, after the plugin has escaped every attribute value.
// Render on an action the settings list does not offer.
add_filter( 'seeonwall_button_hook', function () {
    return 'my_theme_after_gallery';
} );

// Or: no automatic placement at all, leaving the shortcode to place it.
add_filter( 'seeonwall_button_hook', '__return_empty_string' );

Add the two placement filters before wp_loaded — a theme’s functions.php is early enough. The plugin resolves its placement on wp_loaded precisely so a theme can get there first.

Calling the button from a template

mount_html() returns the same markup the settings screen configures, for any product you hand it.

if ( class_exists( 'SeeOnWall_Storefront' ) ) {
    echo SeeOnWall_Storefront::mount_html( $product );
}

It returns an empty string when the store is not connected or the product is not eligible. It does not load the viewer script by itself, and the plugin enqueues that script on product pages only — so anywhere else, use the shortcode, which handles both.


Allowed Domains

By default, the visualizer accepts session-creation requests from any origin. You can restrict this to a specific list of domains to prevent your Shop ID from being used on other websites.

→ Step-by-step guide: Allowed domains: where the preview can open

How to configure

Open the admin dashboard → Shop Settings → Allowed domains. Add each domain where your embed is installed (e.g. example.com). A subdomain is not covered by its parent: add it separately, or add *.example.com to allow every subdomain (example.com itself still needs its own entry). A wildcard must be on a domain you own — *.com or *.myshopify.com are refused.

What happens when a domain is blocked

If a visitor opens the visualizer from an origin that is not on your list, they see a “not allowed” error screen instead of the QR code. The button still appears on the page — only the session creation is blocked.

Example entries

example.com
www.example.com
*.example.com

Both example.com and https://example.com are accepted — the protocol is stripped and only the hostname is stored. Up to 30 domains can be added.

We recommend adding your shop domain to the list. Without it, anyone who finds your Shop ID can embed the visualizer on their own website and consume your plan’s rate limit.


Content Security Policy

If your shop sets a Content-Security-Policy header, you need to whitelist seeonwall.com for the snippet to load and the visualizer to embed correctly.

Required CSP directives

script-src https://seeonwall.comAllow the snippet script to load.
frame-src https://seeonwall.comAllow the visualizer iframe to embed.
connect-src https://seeonwall.comAllow the snippet to read your branding setting from the API.
style-src 'unsafe-inline'Allow the stylesheet the snippet injects for the button and modal. Only needed if your policy sets style-src at all.

The visualizer's own API calls happen inside the seeonwall.com iframe, so they don't count against your shop page's connect-src — but the snippet itself makes one call from your page, to https://seeonwall.com/api/shops/{id}/settings, to read your branding setting. Blocked by CSP, that call fails closed and the seeonwall.com logo stays on the button even on plans that hide it. Your poster image URL is loaded inside the iframe context, so you do not need to add your image host to the shop page's img-src directive.

Minimal example header value

Content-Security-Policy: script-src 'self' https://seeonwall.com; frame-src https://seeonwall.com; connect-src 'self' https://seeonwall.com; style-src 'self' 'unsafe-inline';

SPA & Dynamic Pages

The snippet works automatically on single-page applications and any page that adds .seeonwall-button elements dynamically after the initial load.

After the initial DOM scan, the snippet installs a MutationObserver on the document body. Whenever new .seeonwall-button elements are added to the DOM — whether by a React render, a route change, or any other dynamic update — the observer detects them and injects the preview button immediately.

No special setup is needed. You do not need to call SeeOnWall.init() again after navigation events or re-renders. Buttons that were already injected are tracked and will not be processed a second time.


Troubleshooting

Common issues and how to resolve them.

→ Step-by-step guide: Why isn't the button showing?

The “See on wall” button does not appear on the page.

Check that: (1) the script tag is in the page <head> with a valid data-shop-id; (2) the mount element has exactly the class seeonwall-button (case-sensitive); (3) data-poster-url is an absolute URL; (4) data-poster-width and data-poster-height are set, or data-poster-sizes is used; (5) your monthly poster-view limit is not used up — when it is, the button is hidden until the month resets or you upgrade. Open the browser console for warnings prefixed with [SeeOnWall].

Clicking the button opens the modal but it immediately shows an error.

Likely causes: (a) your allowed-domains list does not include the current page’s origin — add it in the admin dashboard; (b) your hourly session limit has been reached — wait for the next UTC hour or upgrade your plan; (c) the data-shop-id is wrong — copy it again from the dashboard. → Allowed Domains

The QR code appears but the mobile page says “session not found” or “session expired”.

Sessions are valid for 7 days. The visitor may have scanned an old QR code from a previous visit. Closing and reopening the modal generates a fresh session with a new QR code. Also confirm the visitor’s phone has a working internet connection.

The final preview looks blurry or the poster image does not render.

The poster image URL must be publicly accessible and not blocked by hotlink protection — check your CDN or shop platform's image settings. Also make sure the source image resolution is high enough for the selected poster size. → Image Requirements

The browser console shows a Content-Security-Policy error.

Add script-src, frame-src and connect-src for https://seeonwall.com to your shop's CSP header, and allow the snippet's injected stylesheet. See the Content Security Policy section for the full example. → CSP / CORS

The button appears but clicking does nothing in local development.

If your allowed-domains list is non-empty, add http://localhost:PORT to it while developing. An empty list (the default) allows all origins including localhost. → Allowed Domains

The button stopped working on every page, although nothing changed on your side.

Check the status page. It shows whether seeonwall.com has a known problem. If it shows no problem, write to support@seeonwall.com. → Status


Support

How to reach us, and how soon a person replies. The times are for a first reply, not for a fix.

  • Free: email support@seeonwall.com. We reply as soon as we can, with no committed time.
  • Basic: email support@seeonwall.com. First reply within 3 business days.
  • Premium and Ultra: send a support request from the Support page of your dashboard or the Shopify app, or email support@seeonwall.com. First reply within 1 business day.

Business days are Monday to Friday, excluding Polish public holidays. A request sent on a weekend or a public holiday counts from the next business day.

If the button stopped working on every page, check the status page first: it shows known problems while they happen. → Status


Testing & Staging

Use the staging environment to test your integration without affecting production data.

Staging URLs

Replace the production URL with the staging URL when testing:

<script
  src="https://staging.seeonwall.com/seeonwall.js"
  data-shop-id="YOUR_STAGING_SHOP_ID"
></script>

The visualizer iframe loads from: https://staging.seeonwall.com/visualizer/embed

Getting a staging Shop ID

Create a separate account at staging.seeonwall.com/admin. Staging accounts and production accounts are completely independent — data never crosses between environments.

Isolated from production

Sessions created on staging do not count toward your production rate limits or analytics. You can run as many test flows as needed without affecting your shop's usage metrics.

Browser console debugging

The snippet logs useful information to the browser console. Open DevTools and look for:

  • [SeeOnWall] — snippet lifecycle events (script loaded, button injected, modal opened/closed)
  • postMessage events between your page and the visualizer iframe — inspect them with: window.addEventListener('message', e => console.log(e))
  • Session hint stored in localStorage on your shop's own origin, one entry per shop — inspect it at: localStorage.getItem('seeonwall:session:YOUR_SHOP_ID'). The JSON includes sessionId, expiresAt, state (creating or ready), and optionally referenceSize. It is a UI hint, not authorization.
  • Network tab — the snippet calls /api/shops/* from your page, and the iframe calls /api/sessions/* and /api/storage/*; check response codes if the flow stalls

Privacy & GDPR

seeonwall.com is designed to be privacy-friendly by default: no shopper accounts, no tracking cookies, and nothing kept for longer than 7 days.

  • No user accounts. Visitors use the visualizer without signing up or logging in.
  • No tracking cookies. The snippet does not set any cookies on your shop page. It stores one localStorage entry on your shop's origin — the current session ID, expiry, readiness hint and optional comparison size — and the visualizer stores the same on the seeonwall.com origin, so visitors can resume an in-progress session without repeating the photo step. If you turn on order analytics and the shopper consents, the entry also holds the posters that were on their wall in this session.
  • Wall photos are temporary. Each photo uploaded is stored only for the duration of the session (maximum 7 days) and deleted automatically when the session expires.
  • Sessions auto-delete. All session data — including calibration coordinates and poster placement — is permanently deleted on expiry. There is no archive or backup.
  • No personal identifiers. Sessions are identified by a random UUID. No email address, name, IP address, or device fingerprint is linked to session data.
  • Wall photos are analysed by AI. Each photo passes through Requesty's EU gateway (Frankfurt) to an OpenAI GPT model on Microsoft Azure in the EU, to locate the wall, estimate its size, and screen for inappropriate content. The analysis runs inside the EU, photos are never used to train models, and the analysis is deleted along with the session.

If your shop operates under GDPR, you may want to add a brief mention to your privacy policy noting that the wall-preview feature is powered by seeonwall.com, that temporary wall photos are stored for up to 7 days before automatic deletion, and that each photo is analysed by AI through Requesty and Microsoft Azure OpenAI as sub-processors. The photos are processed inside the EU. → Sub-processors