Build a Plugin
A reference architecture for shipping an OV25 plugin on any e-commerce platform.
This guide is for engineers who need to wrap OV25 in a first-class plugin / app for an e-commerce platform we don't already ship for. It's a checklist plus the cross-cutting patterns we already shipped in the open-source WooCommerce extension.
One mental model, every platform. Every plugin is the same five layers wired into different platform APIs. Read the section that matches the layer you're working on, not the platform.
🧱 1. Plugin Anatomy
A complete OV25 plugin is five layers stacked on top of ov25-ui:
| # | Layer | Job | Reference implementation |
|---|---|---|---|
| 1 | Settings UI | Admin enters API key, selectors, branding, behaviour flags | OV25_Admin_Page (Woo) / Remix admin (Shopify) |
| 2 | Product linking | Map a platform product → OV25 product link ('217', 'range/126', 'snap2/4', 'bed-configurator/2', 'dining-configurator/3') | OV25_Product_Field metabox / product metafield |
| 3 | Storefront bundle | Load on PDP/cart, call injectConfigurator(...), hide native ATC | src/frontend/index.ts / extensions/ov25-configurator/assets/ov25-configurator.js |
| 4 | Cart bridge | Convert OnChangePayload → cart-line metadata, override the line price, promote everything to the order | OV25_Ajax_Cart + cart-item-config.php / Shopify create-invoice webhook |
| 5 | Checkout bridge | Force OV25-priced lines through a checkout that respects them and reject anything that bypasses it | Woo cart_item_price filter / Shopify requestOV25Checkout + extensions/ov25-checkout-guard function |
💡 You almost always want a sixth piece - a swatches path - but it's optional and reuses the cart bridge.
⚙️ 2. Settings UI - use ov25-setup, don't roll your own
Do not build the settings form by hand.
Every option injectConfigurator accepts (selectors, display modes, branding, flags, multi-line layout shells like snap2 / bed / dining) is already exposed by the ov25-setup React package - the same UI that powers the visual builder at app.orbital.vision/configurator-setup.
It emits the exact JSON shape that injectConfigurator consumes, with a live preview, so plugin authors get the entire settings surface for free and stay in sync as we add new options.
🌍 Two scopes, same component
All oif our plugins reference it twice:
| Scope | Where it lives | Saved to |
|---|---|---|
| Global - defaults for every linked product | A "Configurator Setup" page in the plugin's admin section | wp_options['ov25_settings'].configuratorConfig (Woo) / shop.metafields.ov25.defaultConfiguratorSettings (Shopify) |
| Per-product override - optional, opt-in | Inside the product editor, behind an "Override global configurator settings" toggle | Product meta _ov25_configurator_config (Woo) / product.metafields.ov25.configuratorSettings (Shopify) |
Both wire it up the same way:
The payload is the same JSON injectConfigurator(...) already accepts - store it verbatim and pass it straight into the storefront bundle at runtime. The Woo extension's per-product editor (src/admin/components/ProductField.tsx) and global page (src/admin/pages/ConfiguratorSetup.tsx) are both ~50 lines of wrapper around <ConfiguratorSetup> plus the platform's save mechanism.
2.1 Merging global + per-product
When both exist, deep-merge the per-product payload over the global one before injecting:
Per-product wins. Arrays replace, objects merge.
The Woo plugin does this in resolveConfiguratorConfig + deepMerge (src/frontend/index.ts); the Shopify equivalent is in assets/ov25-configurator.js (window.ov25DefaultConfiguratorSettings overlaid by window.ov25ProductConfiguratorSettings).
Configurator layouts are bucketed by shape (single-line vs multi-line - currently standard vs snap2) so a product flipped to a multi-line layout picks up the right defaults.
2.2 What the plugin still has to persist itself
ov25-setup does not know about the things that aren't part of injectConfigurator's contract. Your settings UI still needs fields for:
apiKey-productConfiguratorAccesskey, generated at app.orbital.vision/auth/api-keys. Used by the storefront bundle.privateApiKey- a separate privateproductConfiguratorAccesskey used only by the admin / settings UI to call the products-list endpoint and the swatches admin endpoints. Never expose it to the storefront.swatchProductId- platform product/variant id used to charge for sample packs.- Anything platform-specific - Woo cart flags like
useNativeCartSubmit/disableCartFormHiding, Shopify metafield names, etc.
Push the merged result into a window.ov25Settings-style global from the server template so the storefront bundle picks it up synchronously - see ov25_configurator.liquid (Shopify) and OV25_Admin_Page::enqueue_settings (Woo).
Don't fetch settings asynchronously from the storefront bundle. injectConfigurator should be callable on the first paint of the PDP, otherwise the gallery will flash unstyled.
🔗 3. Product Linking
Each platform product needs one piece of data: the OV25 productLink string. The current valid shapes - every one of them is rendered straight from the products-list endpoint:
| Shape | Use it for | Cart shape |
|---|---|---|
"217" | A standard single-product configurator | single line |
"range/126" | A range selector (lets the user pick a sub-product before configuring) | single line |
"snap2/4" | Snap2 modular scene attached to a range | multiple lines |
"bed-configurator/2" | A bed configurator (base + headboard + mattress + accessories) | multiple lines |
"dining-configurator/3" | A dining configurator (table + chairs + benches) | multiple lines |
🧭 Branch on shape, not prefix. Anything that writes through
mode: 'multi'is a multi-line layout. Future configurator types will surface as new prefixes on this same field - your plugin only needs to branch onpayload.skus.mode === 'single' | 'multi', never on the prefix string. Treat theproductLinkas an opaque string the merchant pastes in from the picker.
3.1 The products-list endpoint
To build a product picker (so the merchant doesn't have to type the link by hand), call:
{privateApiKey} is the private productConfiguratorAccess key from your settings UI - call this server-side (Woo: OV25_Admin_API) or from your platform's admin extension context (Shopify: extensions/ov25-product-admin/src/utils.js → fetchOV25Products); never from the storefront.
The response has three top-level lists. ranges carries products and snap2; bedConfigurators and diningConfigurators are separate top-level entries because they aren't tied to a product range:
Render one row per link target
| Source field | productLink to write | Show as |
|---|---|---|
ranges[i].products[j].id | "{id}" (just the number) | a single product |
ranges[i].id (always) | "range/{rangeId}" | a range selector |
ranges[i] where hasSnap2 && snap2Active | "snap2/{rangeId}" | a snap2 modular scene |
bedConfigurators[i].id | "bed-configurator/{id}" | a bed configurator (use sizes for the subtitle) |
diningConfigurators[i].id | "dining-configurator/{id}" | a dining configurator |
The Shopify picker (extensions/ov25-product-admin/src/utils.js → fetchOV25Products) is the canonical implementation - it iterates all five buckets in this order and pushes one item per row, with kind, linkValue, name, and subtitle so the search UI can group/filter.
🧱 Mirror that structure rather than rolling your own - if Orbital Vision adds a new top-level configurator family (e.g.
kitchenConfigurators), your picker only needs to add one more loop.
Picker UX gotchas
- Bed and dining are MANUFACTURER-key only. Retailer keys can return non-empty
bedConfigurators/diningConfiguratorslists too, but only for entries explicitly granted to that retailer in OV25 - empty arrays are normal for retailer keys, don't surface that as an error. - Render the bed
sizesarray (e.g."Single, Double, King") as a subtitle so merchants who carry the same bed in multiple sizes can pick the right configurator instance. hasSnap2withoutsnap2Activemeans the range has a snap2 configurator that's currently disabled in OV25 - don't expose"snap2/{id}"as a valid choice for that row.
Always provide a manual-entry fallback (text input) for when the API is unreachable or the merchant uses a retailer key that returns an empty list.
3.2 Where to store the link
| Platform | Storage location |
|---|---|
| WooCommerce | Post meta - _ov25_product_id on the product |
| Shopify | Metafield - product.metafields.ov25.configuratorID |
| Anything else | Whatever your platform uses for "private product attributes" / "metafields" / "custom fields" |
The key thing is that it has to be readable from the storefront context (Liquid, PHP template, theme JSON, etc.) so the bundle can pick it up at first paint.
If your plugin supports per-product overrides for the inject config (see section 2.1), store that as a second JSON field next to the product link.
🖼️ 4. The Storefront Bundle
This is where ov25-ui does almost all of the work. The bundle's only job is to:
- Build the inject config from the global settings + per-product overrides.
- Call
OV25.injectConfigurator(...)once. - Wire
addToBasket,buyNow,buySwatchescallbacks to your platform's cart APIs. - Hide the native add-to-cart UI on linked PDPs.
A minimal cross-platform skeleton:
addToCart is your platform-specific function - it takes the normalized OnChangePayload produced by ov25-ui and POSTs it to whatever cart endpoint the platform exposes. See section 6 for what to do with the payload.
📚 For the full surface area of
injectConfigurator(every selector, flag, branding option, and multi-line layout setting) read UI Package Integration. For the raw iframe message types under the hood—includingSELECT_SELECTIONby UUID triple or by a single fuzzy-matched display-name pair—read Code Integration.
4.1 Hiding the native add-to-cart UI
Once OV25 is in charge of pricing, you cannot let the platform's native form post - it would add the item at the platform's static price.
Both reference plugins remove the native form on linked PDPs:
- WooCommerce (
src/frontend/index.ts → ensureOv25NativeAtcHideStyles + scheduleOv25NativeProductFormRemoval) ships a CSS rule keyed bybody.postid-{id}that hides every flavour ofform.cartsubmit, plus aMutationObserverthat yanks any form WooCommerce re-renders later (variations form, blocks, etc.). - Shopify does the same with a Liquid guard (
{% if product.metafields.ov25.configuratorID %}) plus a JS hijack on[name="checkout"]and.shopify-payment-button.
🧯 Provide a way to opt out (the Woo plugin uses
disableCartFormHiding/useNativeCartSubmit) for themes that need to keep the native form for non-OV25 reasons.
You should also remove the Add to Cart button from category / collection / related-product loops on linked products (those bypass the PDP and would post at the catalogue price). The Woo plugin does this in OV25_Loop_Button_Hook via the woocommerce_loop_add_to_cart_link filter; on Shopify the equivalent is wrapping the loop button in a {% if product.metafields.ov25.configuratorID %} guard.
The replacement should send the merchant to the PDP (or open the configurator from the loop), never let them buy without configuring.
4.2 Server-rendered DOM placeholders beat CSS selectors
Wherever you can, drop server-rendered placeholder elements on the PDP and let injectConfigurator mount into them, instead of relying on CSS selectors that vary per theme:
| Placeholder | Where the bundle reads it | Both plugins emit it from |
|---|---|---|
data-ov25-iframe="{apiKey}/{productLink}" | Source of truth for apiKey + link at inject time | Woo OV25_Gallery_Hooks::inject_attribute_into_gallery_html / Shopify ov25_configurator.liquid |
data-ov25-variants | Mount point for the options/variants tree | Woo OV25_Variant_Hook::render_placeholder_* |
data-ov25-configure-button | Mount point for the "Configure" CTA on simple themes | Same hook, gated by useSimpleConfigureButton |
The CSS selectors in
window.ov25Settings(gallerySelector,variantsSelector, …) stay as the escape hatch for themes that won't tolerate template overrides. Document both paths in your plugin's setup wizard.
4.3 Thumbnails
OV25 exposes window.ov25GenerateThumbnail() after injectConfigurator finishes - call it inside addToBasket / buyNow and ship the resulting URL on the cart line so every layer of the platform shows the configured product, not the catalogue photo.
You will need to override the thumbnail in every of these contexts:
- 🛒 Mini-cart / side-cart drawer
- 📄 Cart page
- 💳 Checkout summary
- ✅ Order confirmation page
- ✉️ Transactional emails
- 🛠 Order detail in the admin / merchant dashboard
- 🌐 Any REST / GraphQL / Store API response the theme uses for cart rendering (
woocommerce_store_api_cart_item_imageson Woo, line-itemimagefield on Shopify)
The Woo plugin hooks four separate filters (woocommerce_cart_item_thumbnail, woocommerce_checkout_item_thumbnail, woocommerce_order_item_thumbnail, woocommerce_store_api_cart_item_images); plan an equivalent matrix for your platform up-front, otherwise customers see two different products in cart vs checkout.
📦 5. The OnChangePayload Contract
The single piece of API surface you need to understand to wire any platform's cart is what ov25-ui hands you. Every commerce callback (addToBasket, buyNow, onChange) receives a normalized payload with this shape:
🎯 Two decisions follow from mode
mode | Meaning | What you do at the cart |
|---|---|---|
'single' | Standard or range product, one configured item | Add one cart line. Use price.totalPrice as the unit price, skus.skuString as the SKU. |
'multi' | A multi-line layout (snap2 today; dining, bed, and any future multi-line configurator come through the same mode) with N items | Add N cart lines (one per skus.lines[i]). Use each line's quantity and the matching productBreakdowns[i].price as the line total. |
Money is integer minor units. totalPrice and subtotal are always integers (e.g. 12950 = £129.50). Don't reparse formattedPrice; just format totalPrice / 100 with the user's locale when displaying.
If you need the raw iframe payloads (e.g. you want to render a per-selection breakdown in the cart), pull them from
price.priceBreakdownorprice.productBreakdowns.
🛒 6. The Cart Bridge
The cart bridge is a server endpoint that takes the normalized payload and creates one or more cart lines with the correct dynamic price. Three problems to solve.
6.1 Force the configured price onto the line
Every e-commerce platform calculates line prices from the catalogue - you have to override that. Three patterns cover essentially every platform:
Pattern A - Cart-line price filter
Best for: WooCommerce, Magento, anything with a per-line price hook.
Store cfg_price as cart-item meta and re-apply it on every cart total recalculation, not just at add-to-cart. WooCommerce does this by cloning WC_Product per cart row inside woocommerce_before_calculate_totals (priority 1000) and calling set_price(), set_regular_price(), set_sale_price(''). Same goes for the price HTML filter (woocommerce_cart_item_price) so cart/checkout templates show the OV25 number.
Pattern B - Custom-price cart attribute
Best for: Magento
custom_price, BigCommercelist_price, Adobe Commerceoriginal_custom_price.
Set it directly when adding the line. Easiest path when available.
Pattern C - Draft order / invoice redirect
Best for: Shopify, any platform with a locked storefront cart.
You can't override the line price, so the native cart stays at the catalogue price as a placeholder; the checkout button is hijacked (requestOV25Checkout) and POSTs the cart to a server webhook that creates an Admin-API draft order with custom line prices and returns an invoiceUrl to redirect to.
Cross-cutting requirements
Whichever strategy you pick:
- Re-apply on every recalc. Carts can recalculate on coupon application, address changes, currency switches, and quantity edits. The override has to be idempotent and run every time.
- Respect tax-display mode. Read the platform's "prices include tax" setting and honour it when displaying - Woo uses
wc_get_price_including_tax/wc_get_price_excluding_taxagainst the cloned product. Don't hard-code an inclusive or exclusive number. - Respect the storefront's currency. Multi-currency / multi-region storefronts (Shopify Markets, Woo Multi-Currency, etc.) will format
totalPricefor display but you still send the OV25 number unchanged - currency conversion is the platform's job. The Shopify plugin readslocalization.country.currencyfrom Liquid and forwardsshopifyShopCurrencyCodeso the bundle renders the symbol correctly. - Validate the price server-side. Never trust
cfg_pricefrom the client without re-running it past your OV25 backend.
6.2 Persist the configuration on the line
Stash everything you'll need later (cart UI, email receipts, order admin, factory export) on the cart line as private metadata. The keys both reference plugins use:
| Key | Value |
|---|---|
_ov25_product_id | OV25 product link, e.g. "snap2/4" |
_ov25_product_sku | Final SKU string for this line |
_ov25_product_base_price | Catalogue base price (minor units) |
_ov25_original_price | Subtotal before discount (minor units) |
_ov25_final_price | Final unit price (minor units) |
_ov25_organization_id | OV25 org id (parsed from API key prefix) |
_ov25_discount_percentage | discount.percentage from the payload |
_ov25_ss | JSON of the per-selection breakdown |
_ov25_cart_image_url | Result of window.ov25GenerateThumbnail() |
🔒 Underscore-prefixed properties are hidden from the customer in both Shopify and WooCommerce - use whatever the equivalent "private/hidden line property" convention is on your platform.
The visible "cart properties" the customer sees on the cart line should be a small, human-readable subset:
Both reference plugins build this list by decoding cfg_skumap (the JSON SKU map) and emitting one row per non-structural key - see Woo's woocommerce_get_item_data filter in cart-item-config.php.
Never rely on selectedSelections from a global; the cart line has to be self-contained because it gets serialized into sessions, drafts, and order rows that outlive the configurator instance.
6.3 Handle multi-line add-to-cart
When payload.skus.mode === 'multi', you must split the request into one cart line per skus.lines[i]. Every line has its own skuString, quantity, and matching entry in price.productBreakdowns[i] (per-line subtotal, price, selections, image).
This is the path snap2 uses today; dining, bed and any future multi-line configurator type produce the same payload shape, so a
mode === 'multi'branch covers all of them - don't gate the cart-bridge logic on thesnap2/prefix.
The Woo extension does this in OV25_Ajax_Cart::finish_multi_line_add_to_cart:
- Allocate per-line totals from
cfg_payload.price.lines(preferred) or splitcfg_priceevenly as a fallback (allocate_line_prices_minor). - Reconcile rounding drift so the per-line sum matches
cfg_price(reconcile_line_pence_to_cfg_total). - Tag every resulting cart row with the same
ov25_multi_line_groupUUID so the storefront / order admin can show them as one bundle. - Roll back all already-added rows if any single line fails validation.
The Shopify equivalent (buildSnap2CreateInvoiceItems) builds one item per productBreakdowns row and ships them to the create-invoice webhook for resolution to Shopify product/variant ids on the server.
6.4 Promote line meta to the order
The cart bridge is only half the story - the same metadata has to land on the order so production / fulfilment / customer service can read it after checkout.
Hook the platform's "create order line item from cart item" event:
| Platform | Hook |
|---|---|
| WooCommerce | woocommerce_checkout_create_order_line_item |
| Shopify | Copy via the draft-order create payload |
…and copy:
SKU- the finalcfg_skufor the linePART- multi-line carts only - the per-line product label decoded fromskuMap.Product/Products; lets factory/admin tell which item in the multi-line set the row belongs to- One order-meta entry per non-structural
skuMapkey (Fabric: …,Legs: …,Frame: …) - The thumbnail URL (so admin order screens and emails render the configured product, not the catalogue photo)
- The
_ov25_*private bag from section 6.2 verbatim, in case support needs to re-issue or audit a configuration
If your platform splits cart-item meta and order-item meta into different storage (Woo does), you have to write to both. Don't assume cart meta automatically promotes.
💳 7. The Checkout Bridge
Whatever you do at the cart, the checkout has to honour the OV25 price too.
Two patterns:
Pattern A - Cart-line price filter
WooCommerce, Magento, anything with a checkout-line filter.
Hook the platform's per-line price calculator and reapply cfg_price. This means default checkout, Shop Pay equivalents, finance widgets, and analytics all see the OV25 price natively. Make sure the filter runs on every OV25 line on every request - not just at add-to-cart.
Pattern B - Custom checkout
Shopify.
Shopify's checkout cannot accept arbitrary line-item prices, so the plugin POSTs the cart to a webhook that creates an Admin-API draft order with custom line prices and returns an invoiceUrl. The plugin replaces the storefront checkout button (requestOV25Checkout) with one that calls the webhook then window.location = data.invoiceUrl.
Subtleties shared by both patterns
1. Hide accelerated checkouts when an OV25 product is in the cart.
Apple Pay / Google Pay / Shop Pay / PayPal Express all skip your custom price logic - they post directly from the cart to the wallet at the catalogue total.
Detect "any cart line has an OV25 product link" and CSS-hide every express button you can find. The reference Shopify integration keys this on cart.items[].properties._ov25_product_id; the Woo equivalent sits in class-ov25-ajax-cart.php. See the theme docs in Shopify.
2. Provide a programmatic checkout entry point (window.requestOV25Checkout()) so themes that ship custom checkout buttons can opt in instead of being hijacked.
3. Set a cart-level marker when redirecting through your custom checkout. If your platform supports cart-level attributes (Shopify's cart.attributes, Woo's session data), set something like ov25_draft_order=true whenever you build the OV25 invoice/draft order. This is what your server-side guard checks (next bullet).
4. Ship a server-side guard.
Theme code, browser extensions, deep links, and copy-pasted checkout URLs can all bypass the JS hijack. Without a server-side guard, you will eventually ship configured products at the wrong price.
Add a server-side guard at the checkout step that fails if a cart line has an OV25 product link and the cart-level "this went through OV25" marker is missing.
| Platform | Guard implementation |
|---|---|
| Shopify | Cart Validations Function (extensions/ov25-checkout-guard) returning "Custom pricing not applied. Please return to cart" when ov25_draft_order != 'true' and an OV25 product is present |
| WooCommerce | Throw WC_Error from woocommerce_check_cart_items |
🎨 8. Swatches
Sample-pack ordering uses the same payload contract but goes through a separate "swatches-only cart" path.
The user clicks Order Samples in the configurator UI →
ov25-uicalls yourbuySwatches(swatches, rules)callback → you redirect them to a checkout for the chosen swatch SKUs.
Both reference plugins implement it as a single REST endpoint:
| Platform | Endpoint |
|---|---|
| WooCommerce | POST /wp-json/ov25/v1/create-swatch-cart (OV25_Swatch_API) - builds a Woo cart of swatch products (priced from rules.pricePerSwatch) and returns the checkout URL |
| Shopify | POST /webhooks/shopify/swatches/create-cart - builds a Shopify cart with swatch variants and returns the cart URL |
You also need a swatch book page - a page template that renders the swatches block from ov25-ui against your whole product range. See the WooCommerce OV25_Swatch_Page and the Shopify ov25_swatches.liquid block for examples.
🎛️ 9. Programmatic Controls
Once injected, OV25 exposes a small global API the host theme can call from custom buttons:
Expose at least
ov25OpenConfiguratorandrequestOV25Checkoutin your plugin's documented API - every storefront integrator will need them at some point.
🚀 10. Update & Distribution
A real plugin needs to ship updates without breaking stores in production:
- 🏷️ Versioning - tag every release in semver. The Woo plugin uses Plugin Update Checker pointed at GitHub releases (
includes/plugin-update-checker/). - 🛑 Kill switch - keep the ability to remotely disable the plugin if a release goes wrong. The Woo extension polls a webhook (
/api/woo-commerce/kill-switch) at boot, returns early if it getsdisabled, and caches the result in a 5-minute transient. - 🪵 Logging prefix - every console message should be prefixed (
[ov25-woo],[ov25-shopify]) so support can grep merchant consoles quickly. - 🧬 Migration safety - when you rename a settings field or cart-item key, write a one-shot migration that copies the old value to the new one.
Stable cart-item keys. OV25 cart-item keys are stable - never rename cfg_price, cfg_sku, cfg_payload, cfg_commerce_mode or any _ov25_* line property.
📚 11. Reference Implementations
Both repositories are MIT-licensed; copy and adapt liberally.
WooCommerce extension
orbitalvision/ov25-woo-extension
| File | What it covers |
|---|---|
src/frontend/index.ts | Full storefront bundle (inject + cart + thumbnail + multi-line splitting) |
includes/class-ov25-ajax-cart.php | Server cart endpoint with per-line price reconciliation |
includes/class-price-hook.php | Replaces woocommerce_get_price_html with an OV25 price skeleton |
includes/class-product-field.php | Product metabox + per-product config override |
Shopify app
orbitalvision/ov25-shopify-app (private; ask Orbital Vision for access)
| File | What it covers |
|---|---|
extensions/ov25-configurator/blocks/ov25_configurator.liquid | Liquid → window globals |
extensions/ov25-configurator/assets/ov25-configurator.js | Storefront bundle |
extensions/ov25-checkout-guard/ | Function extension that prevents non-OV25 checkout for OV25 lines |
app/routes/... | Remix admin (settings + product linking) |
Read this first. If you only read one file before starting, read src/frontend/index.ts - every cross-cutting concern (payload normalization, multi-line splits, native form removal, cart-meta keys, thumbnail capture) shows up there in under 1000 lines.