Skip to content

E-commerce Integration Guide

SmartCut checkout pages support Google Analytics 4 (GA4) and Google Tag Manager (GTM) for tracking customer behaviour. Configure these in your organisation’s e-commerce settings under the Analytics & Tracking tab.

Google Analytics 4 — Enter your GA Measurement ID (format: G-XXXXXXXX). SmartCut injects the gtag.js script and sends events directly to GA4.

Google Tag Manager — Enable GTM and enter your Container ID (format: GTM-XXXXXXX). SmartCut injects the GTM container script and pushes all events to the window.dataLayer. You can then configure triggers and tags within GTM as needed.

You can configure either or both. When only GA4 is configured, events are sent directly via gtag. When GTM is enabled (with or without a GA4 ID), events are pushed to the dataLayer only — to avoid duplicate events, you should forward them to GA4 via a GA4 tag within your GTM container.

The following events are automatically fired on your checkout pages. These use standard GA4 event names, which GA4 recognises and reports on automatically.

Event When Key Parameters
page_view Checkout page loaded store
view_item Optimisation completed store
add_to_cart Item added to basket currency, value, items[]
begin_checkout Customer proceeds to order form currency, value, items[]
purchase Order confirmed transaction_id, value, currency

Each event is pushed to window.dataLayer in the following format:

{
event: 'add_to_cart',
currency: 'GBP',
value: 48.00,
items: [
{ item_name: 'MDF 18mm', quantity: 1, price: 48.00 }
]
}

The purchase event includes a transaction_id matching the order reference:

{
event: 'purchase',
transaction_id: 'A1B2C3',
value: 199.98,
currency: 'GBP'
}

To capture these events in Google Tag Manager, create a Custom Event trigger for each event name:

  1. Go to Triggers > New > Custom Event
  2. Set the event name (e.g. add_to_cart)
  3. Attach it to your GA4 or other tags

For the purchase event, you can access transaction_id, value, and currency as dataLayer variables.

  • Events are not fired in admin preview mode
  • The page_view and view_item events include a store parameter with the checkout slug (or custom_domain for custom domain stores)
  • The purchase event fires on the order confirmation page — it fires once per checkout redirect and will not duplicate on page refresh

SmartCut e-commerce supports a test/production mode system. Test mode lets you configure and preview settings (pricing, shipping, Stripe keys, email, webhooks) independently from your live store, using the same inventory.

  • Production mode is the default. All public checkout pages use production settings.
  • Test mode maintains a separate copy of your operational settings. Inventory (stock, materials, extras, products) is shared between both modes.
  • Toggle between modes using the Production / Test buttons in the admin bar, visible on all e-commerce admin pages.
Setting Separate Notes
Config (pricing, stock filter, part rules, custom fields, etc.) Yes
Stripe keys Yes Use pk_test_ / sk_test_ keys in test mode
Email provider Yes Configure a test email provider independently
Shipping options Yes Test different shipping rates and formulas
Webhooks Yes Point test webhooks at a different URL
Stock & materials No Shared — import is disabled in test mode
Products & categories No Shared
Extras (banding, finish, etc.) No Shared (but extras config like rules/locations is per-mode)
Company profile & branding No Shared
Slug & custom domain No Shared
Published status No Only production can be published

Append ?mode=test to your checkout URL to view the store with test settings applied:

https://smartcut.dev/ecommerce/your-slug?mode=test

The admin settings page shows this URL automatically when in test mode. A yellow Test mode banner appears at the top of the checkout to distinguish it from production.

Orders are not created in test mode. The checkout flow runs normally (customer can fill in the form, select shipping, etc.) but when submitting:

  • No order is saved to the database
  • No emails are sent
  • No webhooks are fired
  • No inventory is decremented
  • No Stripe checkout session is created

The customer sees a message confirming it was a test submission.

A warning is shown in the Stripe settings tab if there is a mismatch between the ecommerce mode and the Stripe key type:

  • Test mode with live keys — warns you to use test keys for safe testing
  • Production mode with test keys — warns that customers cannot make real payments

Use the buttons in the admin bar to copy settings between modes:

  • Apply to Production — overwrites production settings with the current test settings
  • Copy to Test / Reset from Production — overwrites test settings with the current production settings

Only mode-specific settings are copied (config, stripe, email, shipping options, webhooks). Shared fields (company, branding, slug, inventory) are never affected.

When you first switch to test mode, your current production settings are automatically copied as the starting point. You can then modify test settings independently.


SmartCut can send webhook notifications to your server when e-commerce events occur. Configure your webhook endpoint URL and the events you want to receive in your organisation settings.

Webhooks are sent as POST requests with a JSON body. Each request includes the following headers:

Header Description
Content-Type application/json
X-SmartCut-Event The event name, e.g. order.created
X-SmartCut-Delivery Unique delivery ID (UUID). Stable across retries — use it as an idempotency key
X-SmartCut-Attempt Which attempt this is, starting at 1
X-SmartCut-Timestamp Unix timestamp (seconds) of the delivery attempt
X-SmartCut-Signature HMAC signature — see below

Respond with any 2xx status to acknowledge. Anything else counts as a failure.

Failed deliveries are attempted up to 13 times over roughly 26 hours — the first delivery plus 12 retries, with delays of 10s, 30s, 1m, 5m, 15m, 30m, 1h, 2h, 4h, then 6h (×3). Each delay is jittered slightly.

Response Retried?
2xx Delivered — no retry
5xx Yes
429 / 408 Yes — a Retry-After header is honoured (capped at 6 hours)
Other 4xx No — we treat these as “the request is wrong”, and resending it unchanged cannot help
3xx No — redirects are not followed
Timeout (10s) or network error Yes

Because retries reuse the same X-SmartCut-Delivery ID, you should treat that ID as an idempotency key and ignore a delivery you have already processed.

If we cannot deliver to your endpoint for 24 hours, we’ll email you a warning and keep retrying. If it’s still failing after 72 hours, we’ll disable the endpoint and stop sending events to it.

A disabled endpoint has to be re-enabled by hand in your store settings — we never re-enable one automatically. Any successful delivery at any point resets the clock.

Every delivery is signed with a secret unique to your endpoint. You can reveal or rotate it in your store settings under Webhooks.

The X-SmartCut-Signature header looks like:

X-SmartCut-Signature: t=1720000000,v1=5257a869e7b...

v1 is an HMAC-SHA256, in hex, of the string {timestamp}.{raw request body} — using your signing secret as the key. Note the timestamp is inside the signed payload: that’s what lets you reject replayed deliveries.

To verify: take t from the header, concatenate it with a . and the raw request body (before any JSON parsing — re-serialising will change the bytes and break the signature), HMAC it with your secret, and compare against v1 using a constant-time comparison. Reject the request if the timestamp is more than a few minutes old.

import crypto from 'crypto'
function verifySmartCutWebhook( header, rawBody, secret, toleranceSeconds = 300 )
{
const parts = Object.fromEntries( header.split( ',' ).map( p => p.trim().split( '=' ) ) )
const timestamp = Number( parts.t )
// Reject replays: the signature is still valid, but the delivery is stale.
if ( Math.abs( Date.now() / 1000 - timestamp ) > toleranceSeconds ) return false
const expected = crypto
.createHmac( 'sha256', secret )
.update( `${ timestamp }.${ rawBody }`, 'utf8' )
.digest( 'hex' )
const a = Buffer.from( expected )
const b = Buffer.from( parts.v1 || '' )
return a.length === b.length && crypto.timingSafeEqual( a, b )
}

If you have not yet generated a signing secret, deliveries are sent unsigned (no X-SmartCut-Signature header) rather than being dropped.

Event Description
order.created A new order has been placed
order.payment_completed An unpaid (invoice) order has been settled
order.status_changed The order status has been updated
inventory.decremented Stock quantities have been reduced after an order

order.created does not guarantee the order is paid. Always read the paymentStatus field:

  • Card checkout — we hold the order internally until Stripe confirms payment, then send order.created with paymentStatus: "paid".
  • Direct orders — created already paid, so order.created carries paymentStatus: "paid".
  • Pay-by-invoice / on-account (if you’ve enabled it) — the customer orders without paying. You receive order.created with paymentStatus: "unpaid", and later, when you mark the invoice as settled in the admin, you receive order.payment_completed with paymentStatus: "paid".

If your integration must not act on unpaid orders, either check paymentStatus === "paid" on order.created, or subscribe to order.payment_completed and treat that as your go-ahead for invoice orders.

Sent for order.created and order.status_changed events.

{
"event": "order.created",
"orderId": "abc123",
"organisationId": "org456",
"timestamp": "2026-03-06T12:00:00.000Z",
"data": {
"status": "pending",
"previousStatus": "draft",
"paymentStatus": "paid",
"customer": {
"id": "cust_9f2a1b",
"name": "Jane Smith",
"email": "[email protected]",
"phone": "+44 7700 900000",
"notes": "Please call before delivery",
"address": {
"line1": "123 High Street",
"line2": "Flat 4",
"city": "London",
"state": "Greater London",
"postalCode": "SW1A 1AA",
"country": "GB"
}
},
"pricing": {
"total": 199.98,
"currency": "GBP",
"breakdown": [
{ "label": "Items Subtotal", "amount": 129.99, "type": "subtotal" },
{ "label": "Minimum Order Fee", "amount": 20.01, "type": "charge" },
{ "label": "Shipping", "amount": 10.00, "type": "charge" },
{ "label": "VAT (20%)", "amount": 39.98, "type": "tax" },
{ "label": "Total", "amount": 199.98, "type": "total" }
]
},
"shipping": {
"method": "standard",
"cost": 10.00,
"address": {
"line1": "123 High Street",
"line2": "Flat 4",
"city": "London",
"state": "Greater London",
"postalCode": "SW1A 1AA",
"country": "GB"
}
},
"itemCount": 3,
"partsCount": 24,
"createdAt": "2026-03-06T11:55:00.000Z",
"items": [
{
"itemId": "item-uuid-1",
"jobId": "12345",
"includeOffcuts": true,
"files": {
"pdf": "https://example.s3.amazonaws.com/exports/12345/layout.pdf",
"zip": "https://example.s3.amazonaws.com/exports/12345/files.zip"
},
"parts": [
{ "id": "1", "l": 600, "w": 400, "quantity": 2, "material": "MDF", "thickness": 18, "stock": { "id": "1.0", "code": "WM-18" } },
{ "id": "2", "l": 580, "w": 350, "quantity": 3, "material": "MDF", "thickness": 18, "stock": { "id": "1.0", "code": "WM-18" } }
]
}
],
"results": [
{
"jobId": 12345,
"saw": {},
"stock": [],
"parts": [],
"cuts": [],
"offcuts": [],
"unusableParts": [],
"metadata": {}
}
]
}
}
Field Type Description
event string One of order.created, order.status_changed
orderId string Unique order identifier
organisationId string Your organisation ID
timestamp string ISO 8601 timestamp of when the webhook was sent
Field Type Required Description
status string Yes Current order status
previousStatus string No Previous status (present on order.status_changed)
paymentStatus string No Payment status
customer object Yes The customer’s details — see data.customer
pricing object Yes Order total, currency, and a line-item breakdown
shipping object No The chosen shipping option and delivery address — see data.shipping. Absent for collection/no-shipping orders
itemCount number Yes Number of line items in the order
partsCount number Yes Total number of individual parts
createdAt string Yes ISO 8601 timestamp of order creation
items array Yes Per-job breakdown of parts, files, and stock references — use with the ecommerce API (see below)
results array No Full V3 API optimisation results for each job, including part placement coordinates and stock references. See V3 API docs

Each entry corresponds to one basket item (job) in the order. Items and results share the same order — items[0] corresponds to results[0], etc. Use itemId with the ecommerce API endpoints to track cutting progress.

Field Type Description
itemId string Basket item ID — use as itemId in mark-cut / mark-complete / adjust-cut requests
jobId string Optimisation job ID
includeOffcuts boolean Whether the customer opted in to receive offcuts for this job (false if not opted in)
files object Export file URLs (if available)
files.pdf string Layout PDF URL
files.zip string ZIP archive URL (contains machining PDF, CSV, PTX, DXF as configured)
parts array Parts for this item (one entry per unique part, with quantity for count)
Field Type Description
id string Part identifier (e.g. "1", "2") — derived from the integer portion of the part ID. Use to match parts across results[].parts[] and in mark-cut / mark-complete requests
l number Part length (long side)
w number Part width (short side)
quantity number Number of this part required
material string Material name (if specified)
thickness number Material thickness (if specified)
stock object Stock/material reference (if available)
stock.id string Stock ID — references the corresponding results[].stock[].id
stock.code string Stock code

Each entry is a full V3 API response containing the optimisation result for one job in the order. See the V3 API documentation for the complete response schema.

Field Type Description
jobId number Job identifier
saw object Saw configuration used
stock array Stock items
parts array Parts with coordinates and properties
cuts array Cut instructions
offcuts array Remaining offcut pieces
unusableParts array Parts that could not be placed
metadata object Complete analysis and metrics (efficiency, waste, costs, material summary)
Field Type Required Description
id string No Registered customer-account id — present when the buyer was signed in. Use it to match/dedupe the customer in your own system across their orders
name string Yes Customer full name
email string Yes Customer email address
phone string No Customer phone number
notes string No Free-text note the buyer left on the order
address object No The customer’s address — their saved account address when available, otherwise the address entered for this order. Same fields as data.shipping.address. Absent when no address is on file
Field Type Required Description
total number Yes Order total
currency string Yes ISO 4217 currency code
breakdown array Yes Ordered list of pricing line items (see below)

Each entry represents a line in the pricing summary. Only non-zero charges are included. The final entry is always the total.

Field Type Description
label string Display label (e.g. “Items Subtotal”, “VAT (20%)”, “Total”)
amount number Amount in the order currency
type string One of subtotal, charge, tax, total

Possible line items (in order, when applicable):

Label Type When present
Items Subtotal subtotal Always
Samples Subtotal subtotal Material samples ordered
Minimum Order Fee charge Order below minimum and store charges the difference
Shipping charge Shipping cost > 0
VAT / GST / Sales Tax (rate%) tax Tax enabled on the store
Total total Always

Optional. Present when shipping details are available.

Field Type Required Description
method string No The shipping option the customer selected
cost number No The price of the selected shipping option (in the order currency; 0 for free shipping)
address.line1 string Yes Street address
address.line2 string No Additional address line
address.city string Yes City
address.state string No State / county / region
address.postalCode string Yes Postal/ZIP code
address.country string Yes ISO 3166-1 alpha-2 country code

Sent when stock quantities are decremented after an order is processed.

{
"event": "inventory.decremented",
"orderId": "abc123",
"organisationId": "org456",
"timestamp": "2026-03-06T12:01:00.000Z",
"data": {
"changes": [
{
"stockId": "stock789",
"stockName": "18mm White Melamine",
"material": "Melamine",
"code": "WM-18",
"thickness": 18,
"dimensions": {
"length": 2440,
"width": 1220
},
"previousQuantity": 50,
"newQuantity": 47,
"decrementedBy": 3,
"deleted": false
}
]
}
}
Field Type Required Description
stockId string Yes Stock item ID
stockName string No Human-readable stock name
material string No Material name
code string No Material code
thickness number No Material thickness
dimensions.length number No Stock length
dimensions.width number No Stock width
previousQuantity number Yes Quantity before decrement
newQuantity number Yes Quantity after decrement
decrementedBy number Yes Number of units consumed
deleted boolean No Whether the stock item was fully consumed

Use the test endpoint to verify your webhook URL is reachable. A test webhook sends a simple payload:

{
"event": "test",
"timestamp": "2026-03-06T12:00:00.000Z",
"message": "This is a test webhook from SmartCut"
}

These endpoints allow you to track orders and parts through the cutting and completion workflow. All requests require your API key in the Authorization header.

Order status progression: pending → cut → complete → dispatched

PATCH /ecommerce/orders/:id

Updates the status of an order.

Request body:

Field Type Required Description
status string Yes One of pending, cut, complete, dispatched, cancelled
updateInventory boolean No Decrement inventory stock when marking as cut (default: false)
forceOverwrite boolean No Skip partial progress warning when marking as cut (default: false)
resetCuts boolean No Reset all part cut counts when reverting to pending (default: false)

Example:

{ "status": "complete" }

Responses:

Status Description
200 Order updated — returns the updated order document
400 Invalid status value
403 No active e-commerce subscription — body carries code: "SUBSCRIPTION_REQUIRED"
404 Order not found. Also returned for an order belonging to another organisation, and for an order in the other (test/production) mode — a foreign order is never reported as 403
409 Partial cut progress detected — pass forceOverwrite: true to proceed

When a 409 is returned, the response body contains:

{
"error": "Order has parts with partial cut progress.",
"code": "PARTIAL_PROGRESS",
"data": {
"partsWithProgress": [
{ "itemName": "Item abc", "partIndex": 0, "current": 2, "total": 5 }
]
}
}
  • Customer status email (optional): if email is configured and the per-status notification toggle is enabled (under email settings → Notifications → Order Status Changes), the customer is sent a short email when the status moves to pending, cut, complete, dispatched, or cancelled. Each status has its own toggle. Defaults: complete, dispatched and cancelled are on; pending and cut are off.
  • Order webhook (optional): if an order.status_changed webhook is configured, a webhook fire-and-forget is dispatched on every status change.

PATCH /ecommerce/orders/parts/mark-cut

Increments the numberCut count for one or more parts. When all parts in an order reach their full quantity, the order is automatically set to cut.

Request body:

{
"updates": [
{ "orderId": "66abc123", "itemId": "item-uuid", "id": "1", "count": 1 }
]
}
Field Type Required Description
orderId string Yes Order ID
itemId string Yes Basket item ID within the order
id string Yes Part identifier — matches id from items[].parts[]
count number Yes Number of additional instances to mark as cut (minimum: 1)

Responses:

Status Description
200 Request processed — returns per-order results and any auto-promoted order IDs. An unknown order, item or part does not change the status code; it is reported per entry (see below)
400 Missing or invalid request body — updates not a non-empty array, an entry missing orderId/itemId/id/count, or count below 1
403 No active e-commerce subscription — body carries code: "SUBSCRIPTION_REQUIRED"
404 No organisation found for the authenticated user. Not returned for an unknown or foreign order, item or part
{
"success": true,
"data": {
"results": [{ "orderId": "66abc123", "success": true }],
"autoMarkedOrders": ["66abc123"]
}
}

autoMarkedOrders contains IDs of orders automatically promoted to cut because all parts reached full quantity.

The parts endpoints (mark-cut, mark-complete, adjust-cut, adjust-complete) are batch endpoints. An unknown order, unknown basket item or unknown part is not an HTTP error — the response is still 200 with a top-level "success": true, and the failure appears as an entry in data.results:

{
"orderId": "66abc123",
"success": false,
"error": "Order not found"
}
Field Type Description
orderId string The order the entry refers to
success boolean false when that entry failed
error string Present only when success is false

error is one of Order not found, Item <itemId> not found, Part <id> not found, or the message of an unexpected error raised while saving that order.

Do not branch on the HTTP status code alone. Always walk data.results and check each entry’s success flag — a batch in which every update failed still returns 200 with "success": true at the top level. Note also that a single orderId can produce more than one entry: a failed part within an order pushes a success: false entry while the order’s other successful parts still push a success: true entry.


PATCH /ecommerce/orders/parts/mark-complete

Increments the numberComplete count for one or more parts. When all parts in an order reach their full quantity and the order is in cut status, the order is automatically set to complete.

Accepts the same request body format as Mark Parts Cut.

Invariant: numberComplete ≤ numberCut ≤ partQuantity. If a request would cause numberComplete to exceed numberCut, the value is silently clamped to numberCut rather than rejected. This means parts must be cut before they can be completed.

Responses:

Status Description
200 Request processed — returns per-order results and any auto-promoted order IDs. numberComplete may be clamped to numberCut. An unknown order, item or part is reported per entry, not by status code
400 Missing or invalid request body — updates not a non-empty array, an entry missing orderId/itemId/id/count, or count below 1
403 No active e-commerce subscription — body carries code: "SUBSCRIPTION_REQUIRED"
404 No organisation found for the authenticated user. Not returned for an unknown or foreign order, item or part

PATCH /ecommerce/orders/parts/adjust-cut

Decrements cut counts for individual parts, or resets all cut counts for a basket item to zero. When a decrement causes any part to fall below its full quantity on an order with status cut, the order is automatically reverted to pending.

When numberCut is reduced, numberComplete is automatically clamped down to maintain the invariant numberComplete ≤ numberCut. The resetAll mode resets both numberCut and numberComplete to zero.

Request body — decrement:

{
"updates": [
{ "orderId": "66abc123", "itemId": "item-uuid", "id": "1", "count": -1 }
]
}

Request body — reset all cuts for an item:

{
"resetAll": { "orderId": "66abc123", "itemId": "item-uuid" }
}

Responses:

Status Description
200 Request processed — returns per-order results and any orders reverted to pending
400 Missing or invalid request body — neither updates nor resetAll supplied, resetAll missing orderId or itemId, updates not a non-empty array, or an entry missing orderId/itemId/id/count
403 No active e-commerce subscription — body carries code: "SUBSCRIPTION_REQUIRED"
404 No organisation found for the authenticated user. In resetAll mode only, also Order not found or Item not found. A foreign order is reported as 404, never 403

The two modes report failures differently:

  • resetAll mode — an unknown order or unknown basket item is a genuine 404. An item that exists but has no parts returns 200 with a results entry { "success": false, "error": "No parts found in item" }.
  • updates mode — an unknown order, item or part returns 200 and is reported per entry, exactly as for mark-cut.
{
"success": true,
"data": {
"results": [{ "orderId": "66abc123", "success": true }],
"revertedOrders": ["66abc123"]
}
}

revertedOrders contains IDs of orders automatically reverted to pending because one or more parts fell below full quantity.


PATCH /ecommerce/orders/parts/adjust-complete

Adjusts completion counts for individual parts (increment or decrement). Each part’s completion count is clamped between 0 and min( partQuantity, numberCut ). The invariant numberComplete ≤ numberCut ≤ partQuantity is enforced — you cannot complete a part that has not been cut.

Request body:

{
"updates": [
{ "orderId": "66abc123", "itemId": "item-uuid", "id": "1", "count": 1 }
]
}

Use a positive count to increment and a negative count to decrement. The value is clamped to the valid range [0, min( partQuantity, numberCut )].

Responses:

Status Description
200 Request processed — returns per-order results. An unknown order, item or part is reported per entry, not by status code
400 Missing or invalid request body — updates not a non-empty array, or an entry missing orderId/itemId/id/count
403 No active e-commerce subscription — body carries code: "SUBSCRIPTION_REQUIRED"
404 No organisation found for the authenticated user. Not returned for an unknown or foreign order, item or part
{
"success": true,
"data": {
"results": [{ "orderId": "66abc123", "success": true }]
}
}

SmartCut checkout pages can accept payments via Stripe. Each organisation connects their own Stripe account — SmartCut never handles funds directly.

  1. Create or log in to your Stripe account at dashboard.stripe.com
  2. Get your API keys from Developers → API Keys. We recommend using Restricted Keys with the following permissions:
    • Checkout Sessions — Write
    • Webhook Endpoints — Write (for automatic webhook setup)
    • Account — Read (optional, used for Test Connection)
  3. Enter your keys in SmartCut under your e-commerce settings → Stripe tab:
    • Publishable key (pk_live_... or pk_test_...)
    • Secret key (sk_live_..., sk_test_..., or a restricted key rk_live_... / rk_test_...)
  4. Save — SmartCut will automatically create the webhook endpoint in your Stripe account and store the signing secret

If automatic setup fails (e.g. your restricted key doesn’t have webhook permissions), you can manually add a webhook endpoint in Developers → Webhooks → Add endpoint:

  • Endpoint URL: https://api.smartcut.dev/ecommerce-stripe-webhook
  • Events to listen for: select checkout.session.completed
  • Copy the Signing Secret (whsec_...) and enter it in the Stripe tab

When Stripe is enabled, the checkout flow changes:

  1. Customer completes the order form and clicks Pay Now
  2. SmartCut creates a pending order and redirects the customer to a Stripe-hosted checkout page
  3. After successful payment, Stripe sends a checkout.session.completed event to your webhook endpoint
  4. SmartCut verifies the webhook signature, marks the order as paid, generates the PDF, sends confirmation emails, and fires any configured order webhooks

Without the webhook, SmartCut has no way to confirm that payment succeeded — orders will remain in an unpaid state.

Symptom Cause Fix
Orders stay unpaid after payment Webhook not reaching SmartCut Check your endpoint URL in Stripe and ensure the signing secret is saved
“Webhook not configured” in server logs Signing secret missing for this organisation Add the whsec_... secret in the Stripe tab
“Signature verification failed” in server logs Signing secret mismatch Re-copy the secret from your Stripe webhook endpoint settings
Test connection warns about webhook Signing secret not saved yet Save your Stripe settings including the webhook signing secret

3D Product Visualiser — Schema Reference

Section titled “3D Product Visualiser — Schema Reference”

Formula products can carry an optional assembly spec alongside their formula spec. The assembly spec describes how the formula’s resolved panels are arranged in 3D space — what joins what, where mirrors live, how shelves repeat — and drives the storefront’s interactive 3D preview.

The spec is JSON. Edit it via the product editor’s “Edit 3D assembly” dialog, or pre-populate by clicking “Load example” to start from the bundled cabinet or shaker door.

Right-handed, mm units, world origin at the centre of the product.

Axis + direction − direction
x right left
y top bottom
z front (toward camera) back

Every part has six faces named by world-relative direction: top, bottom, left, right, front, back. The face naming is fixed regardless of the part’s local orientation.

{
"name": "Optional spec name",
"templates": { /* … see Sub-assemblies … */ },
"instances": [
/* one entry per physical part */
]
}

An assembly spec is a flat list of instances — each represents one physical part in the resolved scene. Sub-assemblies (templates + instantiations) are flattened into instances at parse time; the resolver only ever sees concrete instances.

{
"id": "leftSide", // unique identifier for this part
"from": "side", // formula panel key — supplies l/w/t/material
"take": 0, // which of the q copies (default 0)
"axes": { "l": "y", "w": "z", "t": "x" }, // map panel dims to world axes
/* exactly one positioning verb: */
"anchor": "origin",
"join": { /* … */ },
"mirrorOf": "leftSide",
"repeat": { /* … */ },
/* optional transforms applied after positioning: */
"rotation": { /* … */ },
"translate": { /* … */ },
/* optional visibility gate: */
"when": "= inputs.hasDoor"
}

axes maps the panel’s local l (length), w (width) and t (thickness) onto world x/y/z. All three must be distinct. A back panel lying in the XY plane uses { l: "x", w: "y", t: "z" }; a vertical side panel with thickness on x uses { l: "y", w: "z", t: "x" }.

from is required for every panel-backed instance. The resolver looks up the matching panel in the formula spec; its l/w/t become the part’s bounding-box extents. If the formula spec computes q: 0 for the panel, the instance is silently skipped (and dependents cascade-skip too).

when is an optional formula expression. If it evaluates to falsy (zero), the instance is skipped — same cascade as q: 0. Use it to gate hardware on a customer input independently of any panel quantity.

{ "anchor": "origin" } // centre at (0, 0, 0)
{ "anchor": { "at": [ 100, 0, 0 ] } } // centre at (100, 0, 0)
{ "anchor": { "at": [ "= inputs.x", 0, 0 ] } } // formulas allowed

Anchor instances are roots — every other instance positions itself relative to one. There must be at least one anchor in any spec.

{
"join": {
"to": "leftSide", // reference instance id (must exist)
"myFace": "left", // this instance's face that touches…
"theirFace": "right", // …the reference instance's face
"align": [ /* up to 2 alignment rules — see below */ ]
}
}

The contact face pair pins one axis (the one perpendicular to both faces). Faces must be on the same axis (e.g. left/right) and oppose (+1 and -1 signs) — my-right touches their-left, not my-right touches their-right. The remaining two axes are pinned by align rules.

Three discriminated shapes:

{ "my": "top", "their": "top" } // edge-to-edge
{ "center": "y" } // axis centres match
{ "axis": "z", "offset": 12 } // my centre = their centre + N

Edge alignment names two faces; the resolver pins the relevant axis so my-face meets their-face. Same-edge pairs (top/top) and cross-edge pairs (back/front) both work — the signs do the work.

If you don’t supply a rule for an axis, the centres match by default.

{
"id": "rightSide",
"mirrorOf": "leftSide",
"axis": "x", // reflect along this axis
"center": 0 // optional mirror plane (default 0)
}

Reflects an already-placed instance across an axis. The mirrored part inherits the source’s geometry (panel reference, rotation, translate). If the source is a repeat group (id <base>@n), the mirror produces a parallel group <mirrorId>@n.

Three flavours:

between — even spacing between two anchors

Section titled “between — even spacing between two anchors”
{
"repeat": {
"count": "= inputs.numShelves",
"between": [ "bottom.top", "top.bottom" ],
"spacing": "even",
"offset": { "x": 0, "z": "= inputs.depth - 50" }, // override non-spacing axes
"shift": "= 0 - inputs.thickness / 2" // additive on spacing axis
}
}

Each anchor is <instanceId>.<face>. Copies are evenly spaced between the two anchors with count + 1 gaps (so count = 1 lands a single copy at the midpoint). Default position on non-spacing axes is the midpoint between the anchor parts.

{
"repeat": {
"count": 3,
"axis": "y",
"spacing": 200,
"startAt": -200,
"offset": { "x": 0, "z": "= inputs.depth" } // optional
}
}

Stamps count copies starting at startAt, separated by spacing. Useful for stacked drawers, fixed shelves, etc.

around — symmetric spacing around a centreline

Section titled “around — symmetric spacing around a centreline”
{
"repeat": {
"count": 12,
"around": { "axis": "x", "center": 0 },
"spacing": 90
}
}

Centres copies symmetrically on the centreline. Odd counts get one copy on the centre, then pairs at ±spacing, ±2·spacing, …; even counts get pairs at ±½·spacing, ±1½·spacing, … Useful for slats and balusters.

Repeated parts get ids like <id>@0, <id>@1, … etc.

{
"rotation": {
"axis": "y",
"degrees": "= 0 - inputs.doorOpenAngle",
"pivot": [ "left", "front" ] // edge formed by two faces
}
}

Applied after positioning. pivot defaults to the part centre. A single face name (e.g. "left") pivots around that face’s centre. A pair of faces names the edge where they meet — the right model for door hinges. The two faces must lie on different non-rotation axes.

Bounding boxes use the unrotated AABB, so dimensions and click-selection use the part’s pre-rotation extents.

{
"translate": {
"axis": "z",
"distance": "= inputs.depth * inputs.drawerOpen / 100"
}
}

Linear offset along one axis, baked into the part’s world position before rotation. Use it for “drawer slid forward by N mm” effects.

Define a reusable group of instances once, instantiate it many times.

{
"templates": {
"drawerBox": {
"params": [ "drawerHeight" ], // optional declared param names
"instances": [
// The instance with `anchor` is the template's ROOT.
{ "id": "front", "from": "drawerFront", "anchor": "origin", /* … */ },
{ "id": "leftSide", "from": "drawerSide",
"join": { "to": "front", "myFace": "front", "theirFace": "back", /* … */ } },
{ "id": "rightSide", "mirrorOf": "leftSide", "axis": "x" },
// … back, bottom …
]
}
},
"instances": [
{
"id": "drawer",
"instantiate": "drawerBox",
"params": { "drawerHeight": "= inputs.drawerHeight" },
"join": { /* placement of the template root */ },
"translate": { "axis": "z", "distance": "= inputs.depth * inputs.drawerOpen / 100" }
}
]
}
  • Id namespacing. Inner instances expand to <outerId>/<localId>drawer/front, drawer/leftSide, etc. Local references inside the template (join.to: "front") are rewritten to the prefixed form automatically.
  • Cross-scope refs. Inner instances can reference an outer-scope instance by prefixing with outer/: "to": "outer/leftSide". The prefix is stripped at expansion.
  • Root inheritance. The instantiation’s anchor / join / mirrorOf replaces the template root’s anchor. Optional — if you don’t supply positioning the template’s own anchor stands.
  • Forwarded fields. translate, rotation, when on the instantiate block are forwarded to the root, so the whole sub-tree slides / rotates / gates as a unit.
  • Parameters. Template formulas can reference params.<name>. The instantiator supplies values; param values can themselves be formulas that reference inputs.X.
  • Nested templates. A template can instantiate another template. Expansion iterates until no instantiations remain.
  • Repeat-of-instantiate. Use repeat-axis or repeat-around to stamp out N copies of a template (each becomes its own sub-tree under id <outerId>@<n>/<localId>). repeat-between of an instantiate is not supported — the resolver would need to interleave with expansion.
{
"templates": {
"drawerBox": { /* 5 inner instances as shown above */ }
},
"instances": [
/* … carcass: back, sides, top, bottom … */
{
"id": "drawer",
"instantiate": "drawerBox",
"repeat": {
"count": "= inputs.numDrawers",
"axis": "y",
"spacing": "= inputs.drawerHeight + 2",
"startAt": "= inputs.thickness + inputs.drawerHeight / 2 - inputs.height / 2",
"offset": { "x": 0, "z": "= inputs.depth" }
},
"translate": { "axis": "z", "distance": "= inputs.depth * inputs.drawerOpen / 100" }
}
]
}

A numDrawers: 3 cabinet expands to 15 instances (5 per drawer × 3) with ids drawer@0/front, drawer@0/leftSide, …, drawer@2/bottom. The translate is forwarded through the repeat so all three drawers respect drawerOpen together.

The assembly spec has no separate hardware kind — every instance is a panel backed by a formula entry. Pricing-only items (hinges, slides, shelf pins) live in the formula spec’s hardware block and are summed into the cart total without rendering in the 3D preview. Built-in primitives weren’t realistic enough to justify visualising; if you need visible fittings later, import them as separate panels with appropriate dimensions.

The dialog’s live preview parses each edit against the schema and runs the resolver. Surface errors include:

Message Cause
\from` (formula panel key) is required …` Non-mirrored instance missing from
Edge alignment faces must share an axis … { my: "top", their: "left" } — top is on y, left on x
Contact faces must oppose … myFace: "right" and theirFace: "right" (same sign)
No formula panel "X" from: "X" doesn’t exist in the paired formula spec
Unresolved instances (cyclic or unknown reference) … Two instances joining to each other, or a reference to an unknown id
Unknown template "X" instantiate: "X" with no template by that name
Template … must have exactly one instance with \anchor`` Template missing or has multiple roots
Template expansion did not converge A template instantiates itself directly or indirectly
Repeat-between of an \instantiate` is not supported` Use repeat-axis or repeat-around

When two parts overlap, the resolver flags them in the preview (red edge outlines) without throwing — the author can iterate without the preview disappearing.