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 →
429when 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": "…" }with400/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: […] }.
Draft orders & cart links
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.
Configuration images on drafts and cart links
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:
- Send
{ "item": { "product_config_id": "...", "selections": {...}, "dimensions": {...}, "quantities": {...} } }. The response containsrender: { url, settings, values, configuration_hash }, resolved from the product's current storefront viewer connection and mappings. A product without an enabled viewer returnsrender: null. - In a browser, connect to
render.urlusing PPO Viewer Protocol v1. Wait forready, verify the settings andpng-v1snapshot capability, send the mappedvaluesinconfigure, wait for that revision'sapplied, and requestcapturewith 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. - 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 Shopifywrite_filespermission are required. No customer email is sent. - Pass that
previewunchanged with the same item to/draft-ordersor/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}/imagesDELETE /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/groupsPOST /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 thetemplate_idthe 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}/imagesafter 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
warningson 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: truerequires 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: truerequires 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-cartsis 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
POST /api/v1/templates→ build structure withPUT /api/v1/templates/{id}PUT /api/v1/products/{productId}→ connect a product to the templatePUT /api/v1/rulesets/{productConfigId}withpublish: true→ price it- Nightly:
GET /api/v1/configs?status=ordered&since=…→ pull specs into manufacturing; recompute spot checks withPOST /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.
POST /api/v1/templates/importwithdry_run: true→ read the diffPOST /api/v1/templates/import→template_idPUT /api/v1/products/{productId}with thattemplate_id→ connect itPUT /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.
