Pretty Product Options
← Merchant Guide

Account

The API

Programmatic access to option sets, pricing, draft orders and saved carts. Advanced plan.

Programmatic access to templates, options, pricing, products, customer groups and saved configurations. Available on the Advanced plan.

Authentication

Generate a token in Pretty Product Options → Settings → API access (shown once; only its hash is stored). Send it on every request:

Authorization: Bearer cov_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • Base URL: https://api.prettyproductoptions.com — every path below hangs off it, e.g. https://api.prettyproductoptions.com/api/v1/templates. It is also shown beside your token in Settings → API access.
  • All requests/responses are JSON.
  • Rate limit: 120 requests/minute per shop → 429 when exceeded. The number is per shop, and yours is shown beside your token in Settings → API access — if 120 is not the right ceiling for what you are building, ask support to change it rather than pacing your integration around it.
  • Errors: { "error": "…" } with 400/401/402/404/405/422/429/500. 401 = bad token or app uninstalled; 402 = plan no longer Advanced.
  • Regenerating or revoking the token in Settings invalidates the old one immediately.

Templates

List

GET /api/v1/templates → { templates: [{ id, name, product_config_id, … }] } (product_config_id set means a per-product option set; null = shared template.)

Create

POST /api/v1/templates — body { "name": "Roller blinds" } → 201 { ok, id }

Read (full tree)

GET /api/v1/templates/{id} → template, sections → options → choices, visibility rules, and the ruleset version list.

Update structure

PUT /api/v1/templates/{id}

{
  "name": "Roller blinds",
  "sections": [
    {
      "title": "Size",
      "options": [
        { "key": "width",  "label": "Width (cm)",  "input_type": "number", "required": true, "default_value": "120" },
        { "key": "height", "label": "Height (cm)", "input_type": "number", "required": true, "default_value": "150" }
      ]
    },
    {
      "title": "Fabric",
      "options": [
        {
          "key": "fabric", "label": "Fabric", "input_type": "single_select",
          "required": true, "display_mode": "swatch",
          "choices": [
            { "label": "Blockout White", "value": "blockout_white", "swatch_color": "#f5f5f0" },
            { "label": "Sunscreen 5%",   "value": "sunscreen_5", "price": 15 }
          ]
        }
      ]
    }
  ]
}

sections replaces the whole structure declaratively (options/choices get new ids). Visibility rules are cleared when sections is provided — pricing rules are unaffected because conditions reference stable option keys. Omit sections to rename only.

Delete

DELETE /api/v1/templates/{id} — removes the template and everything under it.

Pricing rulesets

Read the live ruleset

GET /api/v1/rulesets/{productConfigId} → the compact ruleset the storefront prices with (rules, validations, version, base price).

Replace rules (new version)

PUT /api/v1/rulesets/{productConfigId}

{
  "rules": [
    { "kind": "adder", "config": { "label": "Base price", "amount": 89 } },
    { "kind": "raw_expression", "config": { "label": "Size", "expression": "(width/100)*(height/100)*65" } },
    {
      "kind": "multiplier", "config": { "label": "Motorised", "factor": 1.25 },
      "applies_when": { "combinator": "and", "conditions": [{ "option_key": "control_type", "operator": "eq", "value": "motorised" }] }
    }
  ],
  "validations": [
    {
      "conditions": { "combinator": "or", "conditions": [{ "option_key": "width", "operator": "lt", "value": 30 }] },
      "action": "block", "block_message": "Minimum width is 30 cm."
    }
  ],
  "publish": true
}

Creates a new draft version containing exactly these rules (validated by compiling — bad rules fail with 422). validations, when present, replaces the template's validation rules. publish: true makes it live immediately. Rule kinds: adder, multiplier, dimension_lookup, tier, raw_expression.

Price computation

POST /api/v1/price — evaluate any configuration against the live ruleset (the same engine the storefront uses). No signature is issued; signed cart payloads only come from the storefront flow.

{
  "product_config_id": "…",
  "selections": { "fabric": "sunscreen_5", "control_type": "motorised" },
  "dimensions": { "width": 120, "height": 150 }
}

→ { price, breakdown, validation, ruleset_version }. Batch: { "items": [ …same shape… ] } → { results: […] }.

Programmatic assisted selling — the API twins of the in-app draft-order builder. Prices are always recomputed server-side from the live ruleset; each line's configuration is persisted as a saved config (so orders reconcile normally).

POST /api/v1/draft-orders — create a Shopify draft order with configured lines.

{
  "items": [
    {
      "product_config_id": "…",
      "selections": { "fabric": "sunscreen_5" },
      "dimensions": { "width": 120, "height": 150 },
      "quantity": 2
    }
  ],
  "email": "customer@example.com"
}

→ { "ok": true, "invoice_url": "…", "total": 1240.00 } — a real Shopify invoice link; prices are locked at creation. Items may use shopify_product_id instead of product_config_id.

Nothing is sent. You get the link and decide how it reaches the customer.

total is what the server actually priced, so you can assert on it rather than trust that your payload meant what you thought it did.

What gets rejected

Your configuration is checked the way the storefront would check it, and anything provably wrong returns 422 with the reason rather than a quietly mispriced order:

  • a required option with no answer;
  • a choice that does not exist ("fabric": "unobtainium");
  • a number that is not a number ("width": "abc");
  • an answer to an option this product does not have — usually a typo in a key, which otherwise prices nothing and rides onto the order as a line property;
  • anything outside a min/max you set on a number or slider;
  • anything your own validation rules block.

Options whose visibility depends on other answers are not demanded — whether they apply is a question only the configurator can answer, and refusing them here would reject valid orders. Those are still enforced for shoppers on the storefront.

POST /api/v1/cart-links — same items shape, different vehicle: returns { "ok": true, "url": "…" }, a storefront link that rebuilds these items in the customer's own cart — re-priced and re-signed when opened — where they can edit lines before checking out. Use draft orders for agreed quotes, cart links for editable proposals.

Both endpoints accept an optional preview on each item. This is the same front-view PNG used by the storefront, without the surrounding window. The preview belongs to the exact product and option values; changing those values requires a fresh preview. Item quantity alone does not change the image.

{
  "items": [{
    "product_config_id": "your-product-config-id",
    "selections": { "width_inches": "36", "height_inches": "60" },
    "quantity": 2,
    "preview": {
      "url": "https://cdn.shopify.com/s/files/.../configuration.png",
      "configuration_hash": "the-64-character-hash-returned-by-previews"
    }
  }]
}

POST /api/v1/previews prepares or uploads a preview, using the same API-key authentication as draft orders:

  1. Send { "item": { "product_config_id": "...", "selections": {...}, "dimensions": {...}, "quantities": {...} } }. The response contains render: { url, settings, values, configuration_hash }, resolved from the product's current storefront viewer connection and mappings. A product without an enabled viewer returns render: null.
  2. In a browser, connect to render.url using PPO Viewer Protocol v1. Wait for ready, verify the settings and png-v1 snapshot capability, send the mapped values in configure, wait for that revision's applied, and request capture with a unique request ID. Accept only the matching revision/request/channel from that iframe and origin. The viewer must allow your CRM's origin. The API does not run a server-side browser.
  3. Send { "item": <the same item>, "configuration_hash": "...", "png": "data:image/png;base64,..." } to /api/v1/previews. It uploads to your shop's Shopify Files and returns { "ok": true, "preview": { "url": "https://cdn.shopify.com/...", "configuration_hash": "..." } }. PNGs must be at most 2 MiB and no larger than 2048 × 2048; request bodies are capped at 3 MiB. API access and Shopify write_files permission are required. No customer email is sent.
  4. Pass that preview unchanged with the same item to /draft-orders or /cart-links. If you already host the snapshot on Shopify's CDN, you can pair its URL with the current hash from step 1; you remain responsible for supplying the matching image.

The hash is a configuration fingerprint, not proof of the image's contents. It includes product_config_id, selections, dimensions, and quantities, with recursively sorted object keys and omitted empty/null object values. Preserve number versus string types between requests. Do not reuse a preview after changing an option. Mismatched hashes or non-Shopify-CDN URLs return 422 before a draft is created.

Draft lines carry _ppo_preview and _ppo_preview_config, bound to their new _config_id. Saved cart links retain the image and bind it to the new configuration ID when opened or copied. A storefront edit uses the storefront's usual fresh-capture flow. Themes and notifications can display the private image properties when the configuration IDs match; Shopify's native admin, invoice, and checkout thumbnail still uses the product variant image. This does not modify the catalog product image. Requests without a preview continue to work as before; old quotes and orders are not backfilled. Both link endpoints accept up to 50 items per request.

Reordering

POST /api/v1/reorder — { "order_id": "#1001" } rebuilds a past order as one of those cart links. Give it an order and you get back the same configurations, at today's prices, in a cart the customer can edit before checking out. This is the trade case: last spring's forty blinds, again, without re-entering forty sets of measurements. The order name, its numeric id, or its gid:// are all accepted; an ambiguous name is refused rather than guessed at.

Prices are deliberately not preserved. The link re-prices and re-signs when it is opened, like every other cart link, so nobody is held to a figure you set a year ago.

{ "ok": true,
  "url": "https://shop.example/cart/...",
  "rebuilt": [{ "uid": "…", "quantity": 2, "product_config_id": "…" }],
  "dropped": [{ "uid": "…", "reason": "“Fabric”: “Dune 04” is not one of the available choices." }] }

Read dropped before you present the link. A line whose product has been disconnected, or whose answers no longer pass today's rules, cannot be rebuilt — so it is left out and reported rather than silently skipped. A customer sent a link for forty-eight of their fifty lines will order it and not notice. Say what is missing, or refuse the reorder yourself.

A 422 means nothing at all could be rebuilt; dropped still says why, line by line. At most 50 lines are rebuilt at once, and anything past that is reported in dropped too rather than quietly truncated.

Rebuilding only some of the lines

A reorder is often not the whole order — a customer replacing four of forty blinds wants four. GET /api/v1/reorder?order_id=%231001 lists what there is to pick, and writes nothing:

{ "ok": true, "order_name": "#1001",
  "lines": [{ "uid": "…", "title": "Roller blind", "quantity": 2,
              "display": { "Width (cm)": "120", "Fabric": "Blockout white" } }],
  "dropped": [] }

Then POST with the uids you want:

{ "order_id": "#1001", "uids": ["…", "…"] }

Omit uids for the whole order. An empty array is refused rather than read as "all" — a caller that filtered a list down to nothing did not mean to reorder everything. A uid that is not on that order comes back in dropped rather than being ignored, so a line you asked for can never go missing quietly. The 50-line cap applies to your selection, not to the order, so picking 30 of 60 works.

Products

  • GET /api/v1/products — all configured products.
  • PUT /api/v1/products/{shopifyProductId} — connect/update: { "anchor_variant_id": 45678901234, "template_id": "…", "base_config"?: {…}, "currency_mode"?: "auto_convert" }
  • DELETE /api/v1/products/{shopifyProductId} — disconnect the configurator.

Images that follow the answer

Picking Ivory can change the product photo and the swatch beside it. That is a per-product setting — from is a URL in this product's gallery — so it lives here rather than inside an option set, and a set attached to two products gets its own map on each.

  • GET /api/v1/products/{id}/images → { images: [...] }
  • PUT /api/v1/products/{id}/images
  • DELETE /api/v1/products/{id}/images — clear them all.
{
  "mode": "replace",
  "images": [
    {
      "option_key": "colour",
      "value": "ivory",
      "replace": [
        { "from": "https://cdn.shopify.com/…/shade__black.png",
          "to":   "https://cdn.shopify.com/…/shade__ivory.png" }
      ],
      "hidden": [{ "from": "https://cdn.shopify.com/…/detail-black.png" }]
    }
  ]
}

from is the image the product shows by default — the same one for every answer. to is what that slot becomes when this answer is picked. hidden drops a gallery image entirely for that answer.

Answers are named, not id'd, so this can be written by the same job that ran the import.

mode is replace by default: the body is the whole set, and any answer it does not mention loses its map. Rerun a generator and the product matches the file. Use merge to touch one answer and leave the rest alone. dry_run: true reports what would happen and writes nothing.

Read the warnings. from has to match an image the gallery actually shows — matching is on the filename, so the CDN prefix and ?v= do not matter, but the name does. A from that matches nothing is reported in warnings and the write still succeeds, because one colour whose photo is not uploaded yet should not refuse the other twenty-five. It is the only warning you get: on the storefront an unmatched map replaces nothing, which looks exactly like the feature being switched off.

{ "ok": true, "mode": "replace", "written": 26, "removed": 0, "gallery": 8,
  "warnings": ["colour=ivory: nothing in the gallery matches \"old-hero.png\"."] }

409 means a step was skipped: the product is not connected, or it has no option set attached. Import and attach first.

Design

The storefront resolves a shade's look in four layers:

default  ←  theme  ←  option set  ←  product

The option set's layer travels in its document (settings.design_overrides, and per-section / per-question design_overrides). The other two live here.

Themes

  • GET /api/v1/design-themes → { themes: [{ id, name, config, updated_at }] }
  • POST /api/v1/design-themes — { "name": "Shades", "config": {…} } → 201 { ok, id, config }
  • GET /api/v1/design-themes/{id} → { theme, resolved }
  • PUT /api/v1/design-themes/{id} — { name?, config?, merge? }
  • DELETE /api/v1/design-themes/{id}

config is a partial DesignConfig — send the keys you care about, the rest resolve from the default:

{ "name": "Shades",
  "config": {
    "color":    { "accent": "#1d9e75" },
    "shape":    { "radius": 10, "density": "roomy" },
    "sections": { "mode": "accordion", "boxed": true },
    "layout":   { "choice_style": "swatch" }
  } }

Because it is sparse, reading config back tells you only what you sent — resolved on the read is the theme layered onto the defaults, which is what the storefront would actually start from.

PUT replaces config. Send "merge": true to layer the body over what is already there, for changing one colour without resending the theme.

Deleting a theme that products are using is allowed: they fall back to the default design, and the response says how many changed.

Per product

PUT /api/v1/products/{id} takes two more fields:

{ "template_id": "…", "anchor_variant_id": 123,
  "design_theme_id": "…", "design_overrides": { "color": { "accent": "#185fa5" } } }

null for design_theme_id falls back to the default design. Both fields are left alone when absent, so updating an anchor variant does not mean resending a design.

Hand-written design

Two pieces make a section stylable by hand.

A section's own class. Every section takes a css_class, and it travels in the option-set document:

{ "title": "Measurements", "css_class": "shade-measurements", "options": [ … ] }

Use it rather than the section's id. Ids are minted on insert, so a rule written against [data-ppo-sid="…"] works until the set is rebuilt with mode: "replace" and then quietly stops matching. A class you chose survives every rebuild.

A stylesheet, in custom_css on any design layer — the theme, the option set (settings.design_overrides), or one product:

{ "config": { "custom_css": "#coverly-configurator .shade-measurements .ppo-section__grid { grid-template-columns: 2fr 1fr 1fr; }" } }

It is not scoped. A <style> element inside the configurator still applies to the whole page, so a selector without a #coverly-configurator prefix reaches the merchant's theme — body { display: none } in there takes out the storefront, not just the options. Prefix every rule.

Refused: @import (it would fetch a stylesheet from anywhere on every render), url(javascript:) and expression(). The CSS itself goes into a style element's text, which the browser does not parse as markup, so a </style> inside a string is inert.

custom_js is not accepted over the API. It runs on the storefront when a merchant writes it in the admin, which sits behind Shopify's own login — a person who has proved they own the shop. An API token is a different thing: a string in a config file or a CI variable. Posting arbitrary script into every shopper's browser is a bigger door than a bearer token should open, so that field stays in the admin. It comes back missing from the echoed config rather than silently ignored.

What gets kept

Anything not in the DesignConfig shape is dropped rather than stored, and that is not tidiness. A design becomes CSS custom properties inside a style attribute, so a colour of red;position:fixed;top:0 would write declarations of its own. Colours are checked as colours, enums against their own lists, numbers clamped, unknown keys discarded, and free text (a font stack, a button label) refused if it carries < > { } ;.

A POST and a PUT both echo what survived. If a key you sent is missing from the response, it was not a key this understands — that is the place to notice, rather than on a storefront that ignored it.

Customer groups

  • GET /api/v1/groups
  • POST /api/v1/groups — { "name": "Wholesale", "tag": "wholesale" }
  • DELETE /api/v1/groups?id={uuid}

Saved configurations

GET /api/v1/configs?status=ordered&since=2026-01-01T00:00:00Z&limit=100

The read model for ERP/manufacturing: each row has uid (matches the order line's _config_id property), full selections, computed_price, price_breakdown, ruleset_version, lifecycle status (draft|saved|shared|carted|ordered|abandoned) and shopify_customer_id.

Saved carts

A configured cart that never checked out is the most useful lead this app produces, and the reason to reach it over an API is that the follow-up usually happens somewhere else — a CRM, a call list, a mail sequence.

GET /api/v1/saved-carts

Query Meaning
search Free text over the answers, option names and product titles. Several words must all match.
min_total / max_total What the cart was quoted, at the time it was saved.
pinned 1 for only the carts a merchant marked Keep.
limit 1–250, default 50.
{
  "saved_carts": [
    {
      "id": "…", "token": "…", "label": "Front of house",
      "email": "sam@example.com", "customer_id": "", "country": "GB",
      "pinned": false, "created_at": "2026-07-14T09:12:00Z",
      "item_count": 2, "total": 2410.77,
      "cart_url": "https://your-shop.myshopify.com/cart?ppo_cart=…&ppo_save=1",
      "lines": [
        {
          "title": "Awning Blind", "quantity": 1, "price": 1240.5,
          "options": [["Room", "Master"], ["Fabric", "Sunscreen 5%"]]
        }
      ]
    }
  ]
}

Every line arrives resolved: the answers are labels, not keys — "Fabric: Sunscreen 5%" rather than fab_1: sun5. That is what the shopper saw, and therefore the only version worth putting in front of whoever calls them.

total and each line's price are what was quoted at the time. Opening cart_url re-prices at today's rates, which is worth knowing before quoting from this endpoint.

Saved carts are not kept forever — see Quotes, share links & customer groups for the retention window and the per-shop ceiling.

Option sets as files

An option set — every section, question, answer, price rule and piece of conditional logic — is one JSON document. Generate one, upload it, and attach it to products. Export one to keep it in version control, copy it to another shop, or hand it to somebody as a starting point.

In the app: Templates → Import or export. Over the API:

GET /api/v1/templates/{id}/export one set as a document
GET /api/v1/templates/export every shared set, as { templates: [...] }
POST /api/v1/templates/import a document in, a new set out

Start from the example

Rather than assembling one from this page, take the worked example — it uses every part of the format, and the check suite validates it on every build, so it always imports.

  • In the app: Templates → Import or export → Download an example
  • Over the API: GET /api/v1/templates/example

It covers all the field types, all five pricing rule kinds, both validation actions, all three conditional-logic targets and actions, quantity tiers, percent prices, one-time fees, customer groups and both link-parameter styles. The comment fields in it explain the format in place — they are a real field that renders under a question on the storefront, so the example is for reading and cutting down rather than shipping as it stands.

The document

{
  "format": 1,
  "name": "Roller blind",
  "sections": [
    {
      "title": "Size",
      "options": [
        { "key": "width",  "label": "Width (cm)",  "input_type": "number", "required": true },
        { "key": "height", "label": "Height (cm)", "input_type": "number", "required": true }
      ]
    },
    {
      "title": "Fabric",
      "options": [
        {
          "key": "fabric", "label": "Fabric",
          "input_type": "single_select", "display_mode": "swatch",
          "choices": [
            { "label": "Blockout White", "value": "blockout_white", "swatch_color": "#f5f5f0" },
            { "label": "Sunscreen 5%",   "value": "sunscreen_5",    "price": 15 }
          ]
        }
      ]
    }
  ],
  "visibility": [
    {
      "target": { "type": "choice", "option_key": "fabric", "value": "sunscreen_5" },
      "action": "disable",
      "conditions": [{ "option_key": "width", "operator": "lt", "value": 60 }],
      "reason": "Sunscreen needs at least 60 cm."
    }
  ],
  "pricing": {
    "rules": [
      { "kind": "adder", "config": { "label": "Base price", "amount": 89 } },
      { "kind": "raw_expression",
        "config": { "label": "Size", "expression": "(width/100)*(height/100)*65" } }
    ],
    "publish": true
  },
  "validations": [
    { "conditions": { "combinator": "or",
                      "conditions": [{ "option_key": "width", "operator": "lt", "value": 30 }] },
      "action": "block", "block_message": "Minimum width is 30 cm." }
  ]
}

Nothing is referenced by id

A document never contains a UUID. Conditional-logic rules point at what they affect by name:

{ "type": "section", "section": 0 }
{ "type": "option",  "option_key": "fabric" }
{ "type": "choice",  "option_key": "fabric", "value": "linen" }

That is what makes a file portable — ids are created on insert, so a document carrying them would import as rules pointing at rows that do not exist, in a set that looks correct in the editor and does nothing on the storefront. Option keys are the same names your pricing formulas and conditions already use.

Check before you write

curl -X POST https://<app-host>/api/v1/templates/import \
  -H "Authorization: Bearer cov_…" -H "Content-Type: application/json" \
  -d '{ "validate_only": true, "document": { … } }'

validate_only reports every problem at once and creates nothing — duplicate option keys, a rule pointing at an option that is not in the file, an unknown rule kind, a condition naming an option that does not exist. Worth wiring into whatever generates your documents.

A failed import is a 422 with { "problems": [...] }.

Rebuilding a set in place

For a catalogue you regenerate, mode: "replace" rebuilds an existing set keeping its id — so every product already showing it stays attached and nothing needs re-wiring.

curl -X POST https://<app-host>/api/v1/templates/import \
  -H "Authorization: Bearer cov_…" -H "Content-Type: application/json" \
  -d '{ "mode": "replace", "template_id": "…", "document": { … } }'

Add "dry_run": true to get the difference back and write nothing:

{
  "ok": true, "dry_run": true, "mode": "replace",
  "diff": {
    "options": { "added": ["motor"], "removed": ["chain_side"],
                 "changed": [{ "key": "fabric", "fields": ["label"] }] },
    "choices": { "added": ["fabric:velvet"], "removed": [], "changed": [] },
    "sections": { "added": [], "removed": [], "kept": 3 },
    "counts": { "options": { "before": 9, "after": 9 } },
    "warnings": ["1 question(s) disappear: chain_side. …"]
  }
}

Questions are matched by key and answers by key + value, so reordering a set is not reported as a rewrite. Run the dry run in whatever job generates your documents and stop on warnings — that is where "this quietly removes a question your saved carts answered" shows up.

How a replace behaves while the shop is open:

  • The new structure is built aside and swapped in at the end, so a document that fails halfway leaves the live set exactly as it was.
  • Pricing is never deleted. Products record which ruleset version is live, so a replace adds the next version rather than removing any. It arrives as an unpublished draft unless the document says "pricing": { "publish": true } — and you get a warning saying so, because new questions priced by old rules is a trap.
  • The swap itself is two statements rather than one transaction. The window is milliseconds, but a shopper loading the page inside it can see a set with no questions, so run big regenerations when the shop is quiet.

What import does and does not do

  • Creating is the default. Overwriting only happens when you ask for it by name, because an overwrite on a mistyped name destroys work that cannot be recovered.
  • Pricing arrives unpublished, unless the document says "pricing": { "publish": true }.
  • Attaching to products is separate — PUT /api/v1/products/{productId} with the template_id the import returned.
  • Images are URLs, and they are copied as written. A set exported from one shop and imported into another will point its swatches at the first shop's files. Re-upload them if that matters.
  • Gallery swaps are separate too. Making the product photo follow the chosen answer is per-product, so it is PUT /api/v1/products/{id}/images after the attach — not part of the document. A document that carried them would break on the second product it was attached to.
  • Customer groups travel by name. A document names the group ("Wholesale") rather than carrying its id, because ids belong to one shop. Re-importing into the same shop keeps every group price; importing into a shop with different group names drops the ones it cannot match and tells you which, in warnings on the response.

The 3D viewer hookup

The viewer connection for an option set — the endpoint, the settings it announces, which question drives each one, and which products carry which values — is readable and writable over the API.

Read it

GET /api/v1/viewer/{templateId}

{
  "connection": {
    "enabled": true,
    "url": "https://viewer.example.com/embed",
    "settings": [
      { "key": "widthInches",   "label": "Width",  "type": "number", "required": true },
      { "key": "widthFraction", "label": "Fraction", "type": "string",
        "choices": [{ "label": "1/2", "value": "0.5" }] },
      { "key": "material", "label": "Shutter material", "type": "string",
        "source": "product", "required": true,
        "choices": [{ "label": "Wood", "value": "wood" }] }
    ],
    "mappings": [
      { "target": "widthInches", "source": "width" },
      { "target": "widthFraction", "source": "width_fraction" }
    ],
    "products": [
      { "productId": "123456", "title": "Plantation shutter",
        "values": { "material": "wood" } }
    ]
  },
  "options": [
    { "key": "width", "label": "Width", "input_type": "number", "choices": [] }
  ]
}

options is what this option set offers a mapping to point at, already filtered to the question types a viewer can be driven by — so a script can build mappings without guessing at names.

Write it

PUT /api/v1/viewer/{templateId} with the connection as the body (bare or under connection). DELETE removes the hookup and leaves the questions alone.

The API validates exactly as the admin screen does, through the same code:

  • a mapping may only name a question this option set has — otherwise it would send nothing forever, with no error anywhere;
  • a mapping may not target a source: "product" setting — the merchant fills those in per product and a shopper must not be able to move them;
  • linked products must exist in the shop, checked against Shopify;
  • enabled: true requires every required setting to be satisfied — mapped, or, for a product-sourced one, covered by at least one linked product;
  • the URL must be https, and up to 100 products per connection.

A refusal is a 422 carrying the same sentence the screen would show.

Not part of an export

A viewer connection is deliberately absent from GET /api/v1/templates/{id}/export. The URL is your own endpoint, and a shared option set carrying it would point one shop's customers at another shop's hosting. Move option sets with the export; set the viewer up per shop with this.

Standalone configurators

A standalone configurator is a landing-page viewer: a 3D viewer hosted at your own URL, showing several products, with a Configure product button that hands the shopper's selections to the matching product page. It is set up in Pretty Product Options → Standalone configurators, and over these endpoints.

GET /api/v1/standalone-configurators { configurators, catalog }
POST /api/v1/standalone-configurators { name, config } → 201 { ok, id }
GET /api/v1/standalone-configurators/{id} { configurator, catalog }
PUT /api/v1/standalone-configurators/{id} { name?, config } → { ok, id }
DELETE /api/v1/standalone-configurators/{id} { ok: true }

The catalog

Both reads carry a catalog, and it is the thing that makes this scriptable: every product with an option set attached, and for each of their sets the questions a viewer can drive, with keys, labels, types and choices.

{
  "catalog": {
    "products": [
      { "id": "10645110096174", "title": "Blackout Roller Shades",
        "handle": "blackout-roller-shades", "published": true,
        "templateId": "bfdaf55d-…" }
    ],
    "options": {
      "bfdaf55d-…": [
        { "key": "color", "label": "Color", "input_type": "single_select",
          "choices": [{ "value": "black", "label": "Black" }] }
      ]
    }
  }
}

So a script can build links[].mappings without guessing at names — the same reason GET /api/v1/viewer/{templateId} returns options. It costs one Shopify call per hundred connected products; add ?catalog=0 when you only want the saved records.

The configuration

{
  "name": "Showroom",
  "config": {
    "url": "https://viewer.example.com/visualizer",
    "enabled": true,
    "sources": [
      { "key": "roller-blackout", "name": "Blackout Roller",
        "settings": [{ "key": "fabric", "label": "Fabric", "type": "string",
                       "required": true,
                       "choices": [{ "value": "black", "label": "Black" }] }] }
    ],
    "links": [
      { "key": "roller-blackout", "productId": "10645110096174",
        "templateId": "bfdaf55d-…",
        "mappings": [
          { "source": "fabric", "target": "color",
            "choices": { "black": "black" } },
          { "source": "widthMm", "target": "width_mm", "scale": 1 }
        ] }
    ]
  }
}

sources is the viewer's own catalog, as it announces it over ppo:standalone-catalog. links connects one of those viewer products to a Shopify product and its option set, and mappings says which viewer field drives which question — choices translating the viewer's value to the option set's, scale/offset converting a dimension into the unit that option set uses.

name is optional on PUT: leave it out and the saved one is kept, so a script that only rewrites mappings cannot blank it by forgetting.

What gets rejected

Validated by the same function the admin screen calls, so the two accept exactly the same records. A refusal is a 422 carrying the sentence the screen would show:

  • the viewer URL must be https, and announce 1–100 products with 1–64 settings each;
  • a mapping may only name a question that option set has — otherwise it would send nothing forever, with no error anywhere;
  • linked products must have that option set attached, checked against the shop as it is now, not as it was when the mapping was written;
  • enabled: true requires every linked product to have its required fields mapped — an enabled configurator that cannot complete a handoff is a button that fails in front of a shopper.

Stale mappings are dropped again at request time by the storefront feed, so a product that later loses its option set stops being offered rather than handing over selections nothing will accept.

Deleting one

DELETE removes the row. Worth knowing first: the configuration ID is pasted into the Standalone configurator theme app block, and deleting the row leaves that block asking the app proxy for something that is gone. Setting config.enabled to false takes the same configurator off the storefront and can be undone.

The admin screen has no delete, so this is the only way to remove one.

Before it exists

The feature arrives with supabase/migrations/0077_standalone_configurators.sql. Until that is applied, these endpoints answer 503 naming the file, rather than an empty list that would read as "none set up yet".

Saved-cart webhook

Polling /api/v1/saved-carts tells you what exists. This tells you the moment one appears.

Set an endpoint under Settings → Saved-cart webhook (Advanced plan). Every time a shopper saves a cart we POST this to it:

{
  "event": "cart_saved",
  "occurred_at": "2026-09-01T04:12:55.912Z",
  "shop": "your-store.myshopify.com",
  "cart": {
    "token": "b31f…",
    "url": "https://your-store.myshopify.com/cart?ppo_cart=b31f…&ppo_save=1",
    "item_count": 2,
    "items": [{ "uid": "…", "quantity": 1 }],
    "summary": "1 × Roller blind — Blockout White, 120 × 150\n1 × Roman shade — Linen",
    "total": "612.00"
  },
  "email": "shopper@example.com",
  "customer_id": "7391…",
  "country": "AU"
}

cart.url reopens the exact cart on any device. email is null unless you turned on Require an email address before a cart can be saved, or the shopper asked for the link to be sent. customer_id is null when they were not signed in, which is most of them.

Verify the signature

Every delivery carries X-PPO-Signature: HMAC-SHA256 of the exact request body, hex, using the signing secret shown beside the URL in Settings. Your endpoint is a public URL, so anything that skips this check will happily accept carts invented by whoever finds it.

import crypto from "node:crypto";

app.post("/hooks/saved-cart", express.raw({ type: "application/json" }), (req, res) => {
  const expected = crypto.createHmac("sha256", process.env.PPO_WEBHOOK_SECRET)
    .update(req.body)                       // the raw bytes, before JSON.parse
    .digest("hex");
  const got = req.get("X-PPO-Signature") || "";
  if (expected.length !== got.length
      || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // …
  res.sendStatus(200);
});

What to expect from us

  • https only, and the address must be publicly reachable. Private and loopback addresses are refused when you save the URL and again before every send.
  • Redirects are not followed. A 3xx is recorded as a failure — point us at the final URL.
  • One attempt, no retries yet. Answer 2xx quickly and do your work afterwards; we give up after 5 seconds. If you miss one, GET /api/v1/saved-carts is the backstop.
  • The last result is shown in Settings — status code, time, and the error if there was one. That is the first place to look when nothing arrives.

Typical ERP sync

  1. POST /api/v1/templates → build structure with PUT /api/v1/templates/{id}
  2. PUT /api/v1/products/{productId} → connect a product to the template
  3. PUT /api/v1/rulesets/{productConfigId} with publish: true → price it
  4. Nightly: GET /api/v1/configs?status=ordered&since=… → pull specs into manufacturing; recompute spot checks with POST /api/v1/price.

Publishing a whole catalogue

Generating the option sets somewhere else and pushing them here is four calls per product, and steps 2 and 3 have to be in that order — an image map points at answers that do not exist until the set is imported and attached.

  1. POST /api/v1/templates/import with dry_run: true → read the diff
  2. POST /api/v1/templates/import → template_id
  3. PUT /api/v1/products/{productId} with that template_id → connect it
  4. PUT /api/v1/products/{productId}/images → make the photo follow the colour

Use mode: "replace" on steps 1–2 and the set keeps its id, so products already showing it stay attached and nothing needs re-wiring. Run the dry run every time and stop on warnings: that is where "this quietly removes a question your saved carts answered" shows up.