{"openapi":"3.1.0","info":{"title":"3D Jewelry Designer Public API","version":"1.0.0","description":"Read and manage jewelry projects and credits. Authenticate with a Bearer API key (Dashboard → API Keys). Per-key rate limit: 120 req/min (429 + Retry-After). Mutations accept an Idempotency-Key header."},"servers":[{"url":"https://3djewelrydesigner.com/api/public"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"rmj_live_…"}},"schemas":{"ContentDoc":{"type":"object","properties":{"kind":{"type":"string","enum":["post","doc","answer","tool","format","compare","glossary"]},"slug":{"type":"string"},"url":{"type":"string","format":"uri"},"title":{"type":"string"},"description":{"type":"string"},"published":{"type":"string","format":"date"},"updated":{"type":["string","null"],"format":"date"},"tags":{"type":"array","items":{"type":"string"}},"readingMinutes":{"type":"integer"},"wordCount":{"type":"integer"},"excerpt":{"type":"string"}}},"ContentDocDetail":{"type":"object","properties":{"kind":{"type":"string","enum":["post","doc","answer","tool","format","compare","glossary"]},"slug":{"type":"string"},"url":{"type":"string","format":"uri"},"title":{"type":"string"},"description":{"type":"string"},"published":{"type":"string","format":"date"},"updated":{"type":["string","null"],"format":"date"},"tags":{"type":"array","items":{"type":"string"}},"readingMinutes":{"type":"integer"},"wordCount":{"type":"integer"},"excerpt":{"type":"string"},"answer":{"type":"string","description":"The direct answer that opens the page."},"body":{"type":"string","description":"Raw MDX body, frontmatter stripped."},"headings":{"type":"array","items":{"type":"object","properties":{"depth":{"type":"integer"},"text":{"type":"string"},"id":{"type":"string"}}}},"sources":{"type":["array","null"],"items":{"type":"object","properties":{"claim":{"type":"string"},"label":{"type":"string"},"url":{"type":"string","format":"uri"},"retrieved":{"type":"string","format":"date"}}}},"faq":{"type":["array","null"],"items":{"type":"object","properties":{"q":{"type":"string"},"a":{"type":"string"}}}}}},"Project":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"config":{"type":"object","additionalProperties":true},"source_format":{"type":["string","null"]},"thumbnail_url":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"DesignShare":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"project_id":{"type":"string","format":"uuid"},"public_slug":{"type":"string","description":"The link is /d/<public_slug>"},"access":{"type":"string","enum":["public","private"]},"has_password":{"type":"boolean"},"expires_at":{"type":["string","null"],"format":"date-time"},"allow_download":{"type":"boolean"},"is_active":{"type":"boolean"},"view_count":{"type":"integer"},"last_viewed_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"RingQuoteSpecs":{"type":"object","description":"Measured metal weight and accent stones of the build, at the size the band was modelled at (`baseUsSize`). Every figure comes from the 3D parts and is exact, never rounded. A figure that cannot be measured reliably is `null` and `flags` says why; nothing is estimated. Carries no prices or rates.","properties":{"metalGrams":{"type":["number","null"],"description":"Net metal in grams: band plus head minus the seat overlap, each at its alloy density."},"bandGrams":{"type":["number","null"],"description":"Band metal in grams at the band alloy, net of the seat overlap."},"headGrams":{"type":["number","null"],"description":"Head metal in grams at the head alloy. null on a band-only build."},"sideStoneCount":{"type":["integer","null"],"description":"Accent stones on the ring. The centre stone is not counted."},"sideStoneCtw":{"type":["number","null"],"description":"Total carat weight of the accent stones: trade chart for rounds, measured shape formula for fancy shapes. Show it truncated to the precision you display, never rounded up."},"sideStoneCtwMin":{"type":["number","null"],"description":"Low end of the accent-stone carat total across the standard size classes."},"sideStoneCtwMax":{"type":["number","null"],"description":"High end of the accent-stone carat total across the standard size classes."},"sideStoneGroups":{"type":"array","description":"The accent stones grouped by shape and size (to 0.01 mm). Empty when the stones cannot be counted.","items":{"type":"object","properties":{"shape":{"type":"string","enum":["round","oval","pear","marquise","princess","cushion","emerald","asscher","radiant","baguette","trillion","heart","unknown"]},"sizeMm":{"type":"number"},"count":{"type":"integer"},"ctEach":{"type":"number"},"ctTotal":{"type":"number"}}}},"grossGrams":{"type":["number","null"],"description":"metalGrams plus the accent stones at 0.2 g per carat."},"sizeBasis":{"type":"string","enum":["modelled","derived"],"description":"`modelled`: the figures describe the size the band was modelled at. A quote always uses it."},"usSize":{"type":["number","null"],"description":"The US ring size the figures describe. On a quote it equals `baseUsSize`."},"baseUsSize":{"type":["number","null"],"description":"The US ring size the band was modelled at."},"bandAlloy":{"type":["string","null"],"description":"Preset id of the band metal, e.g. `yellow_gold_14k`."},"headAlloy":{"type":["string","null"],"description":"Preset id of the head metal. null on a band-only build."},"alloyConfirmed":{"type":"boolean","description":"false until the density of every alloy used has been confirmed. The weights are provisional until then."},"flags":{"type":"array","items":{"type":"string"},"description":"Codes that say why a figure is null or approximate, e.g. `no_metrics`, `alloy_unconfirmed`, `overlap_not_subtracted`. A figure that cannot be published is null, so test the figure for null rather than matching flags."}}},"MeasuredStone":{"type":"object","properties":{"role":{"type":"string","enum":["centre","side"]},"shape":{"type":"string","enum":["round","oval","pear","marquise","princess","cushion","emerald","asscher","radiant","baguette","trillion","heart","unknown"]},"lMm":{"type":"number","description":"Length in mm."},"wMm":{"type":"number","description":"Width in mm."},"dMm":{"type":"number","description":"Depth in mm."},"ct":{"type":"number","description":"Carat. Show it truncated to the precision you display, never rounded up."},"ctSource":{"type":"string","enum":["chart","formula","volume","manual"],"description":"How the carat was found: the trade chart (rounds), the measured shape formula, or the mesh volume."}}},"MetalWeight":{"type":"object","description":"Weight and stones of a piece, measured from its 3D model. Every figure is exact, never rounded: round for display only. A figure that cannot be measured reliably is `null` (or an empty list) and `flags` says why; nothing is estimated.","properties":{"units":{"type":"object","properties":{"mmPerUnit":{"type":["number","null"],"description":"Millimetres per model unit the piece was measured at."},"source":{"type":"string","enum":["declared","file","inferred","unknown"],"description":"`declared`: the `units` you passed. `file`: stated by the file. `inferred`: from the finger hole and stone sizes. `unknown`: no weight is published."}}},"metal":{"type":"object","properties":{"volumeMm3":{"type":["number","null"],"description":"Metal volume in mm³; overlapping metal objects are counted once."},"weights":{"type":"array","description":"The metal at every alloy in the shop's metal sheet. Empty when the volume is null.","items":{"type":"object","properties":{"alloy":{"type":"string","description":"Metal preset id, e.g. `yellow_gold_14k`."},"label":{"type":"string"},"karat":{"type":["integer","null"]},"grams":{"type":"number"},"confirmed":{"type":"boolean","description":"false until the shop confirmed this alloy's density; the grams are provisional until then."}}}}}},"stones":{"type":"object","properties":{"count":{"type":"integer","description":"Every stone found, including ones the flags keep from being weighed."},"centre":{"oneOf":[{"$ref":"#/components/schemas/MeasuredStone"},{"type":"null"}],"description":"The centre stone (kind=piece only). null when there is none or it is unclear."},"side":{"type":"object","properties":{"count":{"type":["integer","null"]},"ctw":{"type":["number","null"],"description":"Total carat weight of the stones that are not the centre."},"ctwMin":{"type":["number","null"],"description":"Low end of that total across the standard size classes."},"ctwMax":{"type":["number","null"],"description":"High end of that total across the standard size classes."}}},"totalCtw":{"type":["number","null"],"description":"Side ctw plus the centre stone."},"list":{"type":"array","items":{"$ref":"#/components/schemas/MeasuredStone"}}}},"flags":{"type":"array","items":{"type":"string"},"description":"Codes that say why a figure is null or approximate, e.g. `units_unknown`, `geometry_open`, `units_inferred`, `no_gems`. Test the figure for null rather than matching flags."}}},"EmbedAlloyRate":{"type":"object","required":["presetId","pricePerGramCents"],"additionalProperties":false,"properties":{"presetId":{"type":"string","minLength":1,"maxLength":80,"description":"The metal preset id, e.g. `yellow_gold_14k`."},"pricePerGramCents":{"type":"integer","minimum":0,"maximum":100000000,"description":"Price per gram in cents, in the platform currency."}}},"EmbedMeleeTier":{"type":"object","required":["minMm","maxMm","pricePerCaratCents"],"additionalProperties":false,"properties":{"id":{"type":"string","format":"uuid","description":"Accepted and ignored on a write: tiers are replaced as a set, so their ids change."},"meleePresetId":{"type":["string","null"],"maxLength":80,"default":null,"description":"The accent-stone preset this tier prices. null means every accent stone. A tier tied to a preset wins over a general one."},"minMm":{"type":"number","minimum":0,"description":"Lower bound in mm, inclusive."},"maxMm":{"type":"number","exclusiveMinimum":0,"description":"Upper bound in mm, exclusive. Must exceed minMm."},"pricePerCaratCents":{"type":"integer","minimum":0,"maximum":100000000,"description":"Price per carat of the stones in this tier, in cents, in the platform currency."},"settingCentsPerStone":{"type":"integer","minimum":0,"maximum":100000000,"default":0,"description":"Flat setting charge per stone, in cents."},"sortOrder":{"type":"integer","minimum":0,"maximum":100000,"description":"Defaults to the tier's position in the array."}}}}},"paths":{"/content":{"get":{"summary":"List published content","description":"Every published article, answer, doc, glossary term, tool page, format page and comparison, newest first. Drafts are never included.","security":[],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"count":{"type":"integer"},"items":{"type":"array","items":{"$ref":"#/components/schemas/ContentDoc"}}}}}}}}}},"/content/{kind}":{"get":{"summary":"List published content of one kind","security":[],"parameters":[{"name":"kind","in":"path","required":true,"schema":{"type":"string","enum":["post","doc","answer","tool","format","compare","glossary"]}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string"},"count":{"type":"integer"},"items":{"type":"array","items":{"$ref":"#/components/schemas/ContentDoc"}}}}}}}}}},"/content/{kind}/{slug}":{"get":{"summary":"Get one document, including its raw MDX body","security":[],"parameters":[{"name":"kind","in":"path","required":true,"schema":{"type":"string","enum":["post","doc","answer","tool","format","compare","glossary"]}},{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContentDocDetail"}}}},"404":{"description":"Not found"}}}},"/projects":{"get":{"summary":"List projects","description":"Live designs by default. `trash=1` lists the ones in Trash — a deleted design is restorable for 30 days (PATCH { deleted: false }) before it is purged for good.","x-scope":"models:read","parameters":[{"name":"trash","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"List trashed designs instead of live ones"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200},"description":"Page size — passing limit/page/offset opts into pagination and adds total/limit/offset to the response"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1},"description":"1-based page number (alternative to offset)"},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0},"description":"Row offset (newest first)"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Project"}},"total":{"type":"integer","description":"Total matching rows (paginated requests only)"},"limit":{"type":"integer","description":"Echoed page size (paginated requests only)"},"offset":{"type":"integer","description":"Echoed offset (paginated requests only)"}}}}}},"401":{"description":"Missing/invalid key"},"429":{"description":"Rate limited"}}},"post":{"summary":"Create a project","x-scope":"models:write","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"config":{"type":"object","additionalProperties":true},"thumbnailUrl":{"type":"string","format":"uri"}}}}}},"responses":{"201":{"description":"Created"},"403":{"description":"Project limit reached for plan"},"422":{"description":"Invalid input"}}}},"/projects/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"summary":"Get a project (incl. config)","x-scope":"models:read","responses":{"200":{"description":"OK"},"404":{"description":"Not found"}}},"patch":{"summary":"Update a project (name / config / thumbnail / trash / restore)","description":"`deleted: true` moves the design to Trash — restorable with `deleted: false`, and purged for good 30 days later. A trashed design stops serving its share link immediately, and stops counting against your plan's design limit.","x-scope":"models:write","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"config":{"type":"object"},"thumbnailUrl":{"type":"string","format":"uri"},"deleted":{"type":"boolean","description":"true = move to Trash, false = restore"}}}}}},"responses":{"200":{"description":"Updated"}}},"delete":{"summary":"Permanently delete a project","description":"Irreversible — this is the Trash's 'delete forever'. Any share link pointing at the design goes with it. For the undoable delete, PATCH { deleted: true }.","x-scope":"models:write","responses":{"200":{"description":"Deleted"}}}},"/projects/{id}/metal-weight":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"units","in":"query","required":false,"schema":{"type":"string","enum":["auto","mm","cm","in","m"],"default":"auto"},"description":"The model's unit. `auto` takes the unit the file states, else infers it from the geometry (flag `units_inferred`)."},{"name":"kind","in":"query","required":false,"schema":{"type":"string","enum":["piece","ring"],"default":"piece"},"description":"`piece` identifies a centre stone; `ring` counts every stone as a side stone (a band)."}],"post":{"summary":"Measure a saved project's model: metal weight at every alloy, and its stones","description":"Measures the project's stored model server-side (it must be a GLB). Needs the Playground · Material Lab plan feature. The same answer as POST /metal-weight.","x-scope":"models:read","responses":{"200":{"description":"OK — { data: MetalWeight }","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MetalWeight"}}}}}},"403":{"description":"Your plan does not include the Material Lab — { error: 'feature_unavailable' }"},"404":{"description":"Project not found, or it has no stored model — { error: 'not_found' | 'model_unavailable' }"},"413":{"description":"The model is over 50 MB — { error: 'model_too_large' }"},"415":{"description":"The model is not a GLB — { error: 'unsupported_format' }"},"422":{"description":"Invalid units/kind, or the GLB could not be read — { error: 'invalid_input' | 'invalid_model' }"}}}},"/projects/{id}/versions":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"summary":"List a project's version history (newest first)","x-scope":"models:read","responses":{"200":{"description":"OK"},"404":{"description":"Not found"}}},"post":{"summary":"Snapshot a version","description":"Save a version of the project. Provide `name` for a kept named version, or omit it for an autosave snapshot (auto-pruned past your plan's per-project version cap). Omit `config` to snapshot the project's current stored config.","x-scope":"models:write","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"Named version; omit for an autosave snapshot"},"config":{"type":"object"},"thumbnailUrl":{"type":"string","format":"uri"}}}}}},"responses":{"201":{"description":"Created"},"404":{"description":"Not found"}}}},"/projects/{id}/versions/{vid}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"vid","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"delete":{"summary":"Delete a version from a project's history","x-scope":"models:write","responses":{"200":{"description":"Deleted"}}}},"/projects/{id}/versions/{vid}/restore":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"vid","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"summary":"Restore a project to a version","description":"Rolls the project's config/thumbnail back to the version. The live state is snapshotted as a new version first, so a restore is itself reversible.","x-scope":"models:write","responses":{"200":{"description":"Restored"},"404":{"description":"Not found"}}}},"/shares":{"get":{"summary":"List design-share links (optionally ?projectId=)","x-scope":"shares:read","parameters":[{"name":"projectId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DesignShare"}}}}}}},"401":{"description":"Missing/invalid key"}}},"post":{"summary":"Create the share link for a saved project (one per project — repeat calls return the existing link)","x-scope":"shares:write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["projectId"],"properties":{"projectId":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"Existing share returned"},"201":{"description":"Created"},"404":{"description":"Project not found"},"422":{"description":"Invalid input"}}}},"/shares/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"summary":"Get a design share","x-scope":"shares:read","responses":{"200":{"description":"OK"},"404":{"description":"Not found"}}},"patch":{"summary":"Update a share (active / access / password / expiry / downloads / rotate-slug / viewer controls)","description":"The link itself is free; password, expiry and access:'private' require a plan with embed access (Starter+) — 403 feature_locked otherwise. Switching access to 'public' always clears the stored password. `viewer` sets what a VISITOR can do on /d/<slug> — it is free, but silently clamped to the org's entitlements (try-on needs AR), so read the response back rather than assuming the request applied verbatim.","x-scope":"shares:write","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"isActive":{"type":"boolean","description":"Kill-switch — false makes /d/<slug> unavailable"},"access":{"type":"string","enum":["public","private"]},"password":{"type":"string","nullable":true,"description":"Plain password (hashed server-side); null/\"\" clears it"},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"null = never expires"},"allowDownload":{"type":"boolean"},"rotateSlug":{"type":"boolean","description":"Regenerate the public slug — invalidates the old share URL"},"viewer":{"type":"object","description":"Visitor-facing viewer controls — same shape as a `viewer` embed's config. Plan-clamped on write and on read.","properties":{"features":{"type":"object","properties":{"materials":{"type":"boolean","description":"Quick metal/gem swatch bar (visitor's change is a preview; never saved)"},"ar":{"type":"boolean","description":"Live webcam try-on button — requires the AR entitlement, else clamped to false"},"autoRotate":{"type":"boolean"},"zoom":{"type":"boolean"},"pan":{"type":"boolean"},"fullscreen":{"type":"boolean"},"snapshot":{"type":"boolean","description":"Let the visitor download a PNG of the current frame"},"rotate":{"type":"boolean","description":"Orientation widget: the visitor turns the piece by exact X/Y/Z degrees (a preview; never saved)"}}}}}}}}}},"responses":{"200":{"description":"Updated"},"403":{"description":"Plan does not include the paid share controls"},"422":{"description":"Invalid input"}}},"delete":{"summary":"Delete a design share (the project is untouched)","x-scope":"shares:write","responses":{"200":{"description":"Deleted"}}}},"/ring-builder/catalog":{"get":{"summary":"Get the published ring catalog (groups, parts, default ring)","description":"Also carries `sectionOrder` (the order builder steps render in), `defaultView` — the platform opening camera view `{ azimuth, elevation, roll, zoom }` in degrees, same convention as a locked angle or a gallery tile, where `zoom` multiplies each surface's own fitted distance rather than being an absolute distance — and `bandOptions`, which maps each band slug to the attribute values it can actually be built in (`bandSlug -> head group code -> values`), so a filter UI never has to scan every head. Pass `?band=<slug>` to narrow the response to one band's heads and seats; the band list stays complete either way. Rate limited to 30 requests per minute per API key.","x-scope":"ringbuilder:read","parameters":[{"name":"band","in":"query","required":false,"schema":{"type":"string"},"description":"Narrow the response to one band: its heads and its seats only. Every band is still listed, so a picker stays complete. An unknown slug returns the default band rather than an error."}],"responses":{"200":{"description":"OK"},"401":{"description":"Missing/invalid key"},"429":{"description":"Rate limited — 30 requests per minute per key. Retry after the `Retry-After` header."},"502":{"description":"Catalog unavailable"}}}},"/ring-builder/embeds":{"get":{"summary":"List ring-builder embeds","description":"Every row carries a top-level `surface` mirroring `config.surface` — 'configurator' (the full builder, default) or 'gallery' (a Media Only product-page set: a 360° hero plus captured still tiles, no options panel or cart). Filter with `?surface=configurator|gallery`; a bare call returns both.","x-scope":"ringbuilder:read","parameters":[{"name":"surface","in":"query","required":false,"schema":{"type":"string","enum":["configurator","gallery"]},"description":"Filter by embed mode"}],"responses":{"200":{"description":"OK — { data: [{ …embed, surface }] }"},"401":{"description":"Missing/invalid key"}}},"post":{"summary":"Create a ring-builder embed","description":"`config.surface: \"gallery\"` creates a Media Only embed and seeds it with the recommended 6-tile set (4 camera angles + On hand + In box) so it publishes something the moment it exists — the same seed the dashboard's create flow uses. Omit `surface` (or send \"configurator\") for the full interactive builder.","x-scope":"ringbuilder:write","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"allowedDomains":{"type":"array","items":{"type":"string"}},"platform":{"type":"string","enum":["shopify","woocommerce","wix","custom","other"]},"config":{"type":"object","additionalProperties":true,"description":"RingBuilderEmbedConfig (clamped to plan)","properties":{"surface":{"type":"string","enum":["configurator","gallery"],"default":"configurator"},"features":{"type":"object","additionalProperties":true,"description":"Shopper-facing switches, such as priceDisplay, arTryOn, zoom and share.","properties":{"specDisplay":{"type":"boolean","default":false,"description":"Show each build's metal weight, side-stone count and carat total as a line under the price. The embed's own quote carries `specs` only while this is on, and only for builds whose figures can be published."}}},"scene":{"type":"object","description":"Presentation of the 3D stage — never plan-clamped","properties":{"autoRotate":{"type":"boolean"},"autoRotateSpeed":{"type":"number"},"exposure":{"type":"number"},"gemBrilliance":{"type":"number"},"envIntensity":{"type":"number"},"defaultView":{"type":"object","nullable":true,"description":"This embed's opening camera view. null (the default) inherits the platform default from /ring-builder/catalog; an object overrides it. NOT a locked angle — the ring stays orbitable, zoomable and auto-rotating. Note a PATCH re-parses the whole config, so read-modify-write must round-trip this field.","properties":{"azimuth":{"type":"number","minimum":-360,"maximum":360,"default":38,"description":"Degrees around the ring"},"elevation":{"type":"number","minimum":-89,"maximum":89,"default":24,"description":"Degrees above the horizon — the tilt"},"roll":{"type":"number","minimum":-180,"maximum":180,"default":0},"zoom":{"type":"number","minimum":0.6,"maximum":1.6,"default":1,"description":"Multiplies the surface's own fitted distance"}}}}},"gallery":{"type":"object","description":"Media Only presentation — pure config, no separate write path","properties":{"enabled":{"type":"boolean","description":"Forced true whenever surface is 'gallery'"},"hero":{"type":"object","properties":{"mode":{"type":"string","enum":["spin","locked"]},"azimuth":{"type":"number"},"elevation":{"type":"number"},"roll":{"type":"number"},"distance":{"type":"number"},"views":{"type":"object","properties":{"hand":{"type":"boolean"},"box":{"type":"boolean"}}},"caption":{"type":"string","description":"Template with {carat}/{shape}/{metal}/{gem}/{band}/{sku}/{price} tokens"}}},"tiles":{"type":"array","description":"Camera-angle tiles are capped by the plan's maxCaptureAngles; pose tiles (on hand / in box) are capped at 2","items":{"oneOf":[{"type":"object","properties":{"type":{"const":"angle"},"id":{"type":"string"},"label":{"type":"string"},"caption":{"type":"string"},"azimuth":{"type":"number"},"elevation":{"type":"number"},"roll":{"type":"number"},"distance":{"type":"number"}}},{"type":"object","properties":{"type":{"const":"pose"},"id":{"type":"string"},"label":{"type":"string"},"caption":{"type":"string"},"pose":{"type":"string","enum":["hand","box"]}}}]}},"banners":{"type":"array","items":{"type":"object","properties":{"imageUrl":{"type":"string"},"alt":{"type":"string"},"href":{"type":"string"}}}},"share":{"type":"object","properties":{"enabled":{"type":"boolean"},"label":{"type":"string"}}},"zoom":{"type":"object","properties":{"enabled":{"type":"boolean"},"magnifier":{"type":"boolean"}}},"quality":{"type":"string","enum":["standard","high"]},"verifiedAt":{"type":"string","description":"Set only by a real test capture; read-only presentation of when this embed's install was last verified"}}}}}}}}}},"responses":{"201":{"description":"Created — { data: { …embed, surface } }"},"403":{"description":"Plan does not include embeds / ring builder, or the per-kind embed cap is reached — { error: 'embed_limit', kind, limit, used, message }. A Ring Builder and a Media Only embed are one per account on every plan; saved-design embeds scale with the plan."},"422":{"description":"Invalid input"}}}},"/ring-builder/embeds/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"summary":"Get a ring-builder embed","x-scope":"ringbuilder:read","responses":{"200":{"description":"OK — { data: { …embed, surface } }"},"404":{"description":"Not found"}}},"patch":{"summary":"Update a ring-builder embed (active / domains / platform / require-allowlist / rotate-slug / config)","description":"The whole `config` blob is re-parsed on every write — an omitted key resets to its schema default, so a caller reading then writing back should round-trip the full object it received from GET, not a partial patch.","x-scope":"ringbuilder:write","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"isActive":{"type":"boolean"},"allowedDomains":{"type":"array","items":{"type":"string"}},"platform":{"type":"string","enum":["shopify","woocommerce","wix","custom","other"],"nullable":true},"requireAllowlist":{"type":"boolean","description":"When true, an empty allow-list blocks framing everywhere"},"embedOnly":{"type":"boolean","description":"When true the embed renders only inside an iframe — opening the public URL directly in a browser is refused"},"rotateSlug":{"type":"boolean","description":"Regenerate the public slug — invalidates the old embed URL"},"config":{"type":"object","additionalProperties":true,"description":"Same shape as POST's config, including surface/gallery — switching surface here is how Convert works over the API"}}}}}},"responses":{"200":{"description":"Updated — { data: { …embed, surface } }"},"403":{"description":"Switching `surface` converts the embed to the other kind; refused when that kind's cap is already used — { error: 'embed_limit', kind, limit, used, message }"}}},"delete":{"summary":"Delete a ring-builder embed","x-scope":"ringbuilder:write","responses":{"200":{"description":"Deleted"}}}},"/ring-builder/embeds/{id}/shopify-catalog":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"summary":"Read a builder's Shopify catalogue sync settings and last-run report","description":"When a Shopify store is connected, a builder can be pointed at products the merchant already sells: variants matching an SKU prefix and/or a collection are pulled in, their SKUs parsed back into ring configurations, and matched rings then take the store's title and price. `variantsMatched` vs `variantsSeen` plus `unparsedSamples` is how a merchant diagnoses a prefix or SKU-pattern mismatch — a wrong prefix and a broken sync look identical from an empty builder otherwise.","x-scope":"ringbuilder:read","responses":{"200":{"description":"OK — { connected, shopDomain, sync: { enabled, skuPrefix, collectionId, mode, lastSyncedAt, lastStatus, variantsSeen, variantsMatched, unparsedSamples } }"},"404":{"description":"Not found"}}},"patch":{"summary":"Update a builder's Shopify catalogue settings","description":"`mode: decorate` (default) — matched combinations take the store's title and price, everything else keeps the embed's own price book, so nothing disappears from the builder. `mode: filter` — the builder offers ONLY combinations the store sells; check `variantsMatched` before switching, since a partial variant catalogue will empty most of the builder. Running a sync is a dashboard action, not an API one: it is a long, rate-limited call against the merchant's store.","x-scope":"ringbuilder:write","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"boolean"},"skuPrefix":{"type":"string","nullable":true,"description":"Only variants whose SKU starts with this are pulled in, e.g. \"RB-\""},"collectionId":{"type":"string","nullable":true},"collectionTitle":{"type":"string","nullable":true},"mode":{"type":"string","enum":["decorate","filter"]}}}}}},"responses":{"200":{"description":"Updated — { sync }"},"404":{"description":"Not found"}}}},"/ring-builder/embeds/{id}/shopify-catalog/render-profile":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"summary":"Read how this builder's Shopify product photos are shot","description":"A catalogue still and a live configurator are two different pictures — a merchant can want a white sweep at a fixed angle in their Shopify gallery while their builder keeps its branded background. This is the override layer over the builder's own presentation. Every look field is nullable and `null` means INHERIT; `\"\"` is a MEANINGFUL value (no ground / runtime default), never a second spelling of inherit. `inherited` is what the nulls resolve to and `resolved` is the two composed by the same overlay the renderer uses, so a client never has to reimplement the precedence.","x-scope":"ringbuilder:read","responses":{"200":{"description":"OK — { profile, inherited, resolved: { presentation, shot }, updatedAt }"},"403":{"description":"Plan does not include the pricing manager"},"404":{"description":"Not found, or the embed is Media Only"}}},"put":{"summary":"Replace this builder's Shopify photo profile","description":"Whole-object on purpose: `background`, `render` and `metalEnvByMetal` each override ALL-OR-NOTHING (a half-inherited post-processing chain or a per-key-merged environment map are states the schema is shaped to prevent), so a merge-style PATCH would produce exactly those. `enabled: false` is pure inheritance — every field stays stored and editable while off, so switching it on is not a cliff. Clamped to your plan's studio entitlements: a paywalled HDR, ground, background, metal/gem preset, bloom or shadow tier falls back to inherit rather than being accepted and silently ignored at render time.","x-scope":"ringbuilder:write","requestBody":{"content":{"application/json":{"schema":{"type":"object","description":"@3jd/shared shopifyRenderProfileSchema. {} inherits everything.","properties":{"enabled":{"type":"boolean","description":"false = ignore every field below and match the builder exactly."},"camera":{"type":"object","description":"mode \"preset\" keeps whatever zoom the preview had (fine for one ring, inconsistent across thousands); \"orbit\" is a reproducible saved framing.","properties":{"mode":{"type":"string","enum":["preset","orbit"]},"preset":{"type":"string","enum":["3d","front","side","top"]},"azimuth":{"type":"number","minimum":-360,"maximum":360},"elevation":{"type":"number","minimum":-89,"maximum":89},"roll":{"type":"number","minimum":-180,"maximum":180},"distance":{"type":"number","nullable":true,"minimum":1.6,"maximum":12,"description":"In normalised space, so one value frames every ring alike. null = auto-fit each."},"fov":{"type":"number","minimum":15,"maximum":70}}},"output":{"type":"object","properties":{"size":{"type":"string","enum":["1024","1600","2048"]},"aspect":{"type":"string","enum":["1:1","4:5","3:2"]},"format":{"type":"string","enum":["webp","jpeg","png"]},"quality":{"type":"number","minimum":0.6,"maximum":1},"matte":{"type":"string","description":"Painted under alpha-less formats — a transparent stage plus JPEG would otherwise composite onto black."},"altTemplate":{"type":"string","description":"Shopify media alt text; {product} {variant} {metal} {shape} {carat} {sku}. \"\" = none."}}},"environment":{"type":"string","nullable":true},"exposure":{"type":"number","nullable":true,"minimum":0.4,"maximum":2},"envIntensity":{"type":"number","nullable":true,"minimum":0,"maximum":3},"gemBrilliance":{"type":"number","nullable":true,"minimum":0,"maximum":3},"toneMapping":{"type":"string","nullable":true,"enum":["aces","reinhard","linear","cineon"]},"gemEnvId":{"type":"string","nullable":true},"metalEnvId":{"type":"string","nullable":true},"metalEnvByMetal":{"type":"object","nullable":true,"description":"REPLACES the builder's map wholesale, never merged per key. Keys are a metal preset id or a colour-family prefix."},"metalPresetId":{"type":"string","nullable":true},"gemPresetId":{"type":"string","nullable":true,"description":"Wins over the stone named in a variant's SKU, so every photo shows the same diamond."},"groundPresetId":{"type":"string","nullable":true,"description":"\"\" = deliberately no ground; null = inherit."},"background":{"type":"object","nullable":true,"description":"Overrides as one object (colour + preset + transparent are mutually exclusive)."},"render":{"type":"object","nullable":true,"description":"The whole post-processing block or none of it — effects interact."},"engraving":{"type":"object","description":"mode: inherit | off | custom. \"off\" exists because a builder's default engraving was otherwise baked into every catalogue still with no way to say none."},"extraSettleFrames":{"type":"integer","minimum":0,"maximum":60,"description":"Extra quiet frames before each capture — raise if stills come out flatter than the live builder."}}}}}},"responses":{"200":{"description":"Saved — { profile, updatedAt }. The profile returned is the CLAMPED one actually stored."},"404":{"description":"Not found"},"422":{"description":"Invalid profile"}}}},"/ring-builder/embeds/{id}/shopify-specs":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"summary":"Push each variant's exact weight and accent-stone specs to the connected Shopify store","description":"Writes seven typed variant metafields under the `rmj` namespace of the store connected to this builder: `metal_weight_g`, `side_stone_count`, `side_stone_ctw`, `side_stone_ctw_range`, `gross_weight_g`, `size_basis` and `spec_provenance`. With `updateShippingWeight: true` it also sets each variant's shipping weight in grams: the net metal weight, or metal plus stones when `weightBasis` is `gross`. Shopify's CSV import only takes whole grams, so exact decimals reach the store through this push. The server recomputes every value from the measured parts; the request carries no numbers. A variant is skipped, with its reason, when its weight cannot be published (no measurement, several matching heads that disagree, a metal or size that cannot be read) and when it already holds the values that would be sent. Without `rowIds` it sends the next batch of variants whose values changed, up to 250 variants across at most 5 products, starting after `cursor` when you send one. While `more` is true, call it again with the answer's `nextCursor` as `cursor`, until `more` is false. Each answer of such a walk lists the variants it sent and, once per walk, every variant it passed over that cannot be published (`skipped`, with the reason) or whose shipping weight the store refused (`skipped` as `weight_not_permitted`, with `weightAccess` `denied`); variants that are already up to date are not listed, and an answer can list none. With `rowIds` every named variant is listed, `unchanged` ones included. `rowIds` takes the `id`s a previous answer listed in `results`, for retrying a few variants; `cursor` is not used with it. One push runs per builder at a time. Needs a connected Shopify store and the Shopify app on your plan. Unlike most endpoints the body is not wrapped in `data`.","x-scope":"ringbuilder:write","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"rowIds":{"type":"array","minItems":1,"maxItems":250,"items":{"type":"string","format":"uuid"},"description":"Variants to push, by the `id`s earlier answers listed. Omit to send the next batch of variants whose specs changed."},"cursor":{"type":["string","null"],"minLength":1,"maxLength":1024,"description":"Where to carry on a next-batch run: the previous answer's `nextCursor`, which is opaque. Leave it out, or send null, on the first call. Not used with `rowIds`. A cursor a push did not give you is refused with 422 `invalid_cursor`."},"updateShippingWeight":{"type":"boolean","default":false,"description":"Also set each variant's shipping weight in grams."},"weightBasis":{"type":"string","enum":["net","gross"],"default":"net","description":"Which grams become the shipping weight: `net` metal only, or `gross` metal plus accent stones at 0.2 g per carat. Used only with updateShippingWeight."}}}}}},"responses":{"200":{"description":"OK — one batch pushed. While `more` is true, call again with `nextCursor` as `cursor`.","content":{"application/json":{"schema":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","required":["id","status"],"properties":{"id":{"type":"string","format":"uuid","description":"The catalog row to send back in rowIds."},"status":{"type":"string","enum":["pushed","skipped","failed","deferred"],"description":"A deferred variant ran out of time and is sent on the next call."},"reason":{"type":"string","description":"Why a variant was skipped, as a code: a measurement flag such as `no_metrics` or `head_ambiguous`, `unchanged`, `size_unparsed`, `shopify_unavailable`, `weight_not_permitted` (the store refused the shipping weight earlier under the same permissions, so nothing was sent) or `not_found`. `not_recorded` marks a variant that was pushed but could not be marked as pushed."},"error":{"type":"string","description":"Shopify's own words for a failure."},"weightSent":{"type":["number","null"],"description":"The shipping weight sent, in grams."},"weightStored":{"type":["number","null"],"description":"The shipping weight Shopify stored, read back after the write."},"weightUnit":{"type":["string","null"]}}}},"counts":{"type":"object","properties":{"pushed":{"type":"integer"},"skipped":{"type":"integer"},"failed":{"type":"integer"},"deferred":{"type":"integer"}}},"lastError":{"type":["string","null"],"description":"Shopify's last error during this call, if any."},"more":{"type":"boolean","description":"True exactly when `nextCursor` is set: variants that still need a push remain. Call again with `nextCursor` as `cursor` until it is false."},"nextCursor":{"type":["string","null"],"description":"Send this as `cursor` in the next call to carry on where this one stopped. null when nothing remains."},"weightAccess":{"type":["string","null"],"enum":["ok","denied",null],"description":"`ok`: the shipping weight was written. `denied`: the store's app grant cannot write shipping weight, so only the metafields were sent, or a variant this call covers was skipped as `weight_not_permitted`. null otherwise."},"stampFailed":{"type":"boolean","description":"True when Shopify took the values but they could not be marked as pushed, so the same variants are offered again next time."},"apiVersion":{"type":"object","description":"The Shopify Admin API version the push asked for, and the one that answered.","properties":{"requested":{"type":"string"},"served":{"type":["string","null"]}}},"definitions":{"type":"object","properties":{"conflicts":{"type":"array","items":{"type":"string"},"description":"Metafield keys the store already defines with another type. Their values were not sent."},"warnings":{"type":"array","items":{"type":"string"},"description":"Problems creating a definition. The values were still sent with their type."}}}}}}}},"401":{"description":"Missing/invalid key"},"403":{"description":"Your plan does not include the Shopify app — { error: 'feature_locked', feature: 'shopifyApp' }"},"404":{"description":"Embed not found, or it is a Media Only embed"},"409":{"description":"{ error: 'push_in_progress' }: another push for this builder is running. Or { error: 'shopify_not_connected' }."},"422":{"description":"Invalid input, or { error: 'invalid_cursor' } for a `cursor` a push did not give you."},"429":{"description":"Rate limited: 60 pushes a minute per builder, and the per-key limit. Retry after the `Retry-After` header."},"500":{"description":"{ error: 'load_failed' }"},"503":{"description":"{ error: 'migration_required' }: weight and stone specs are not available on this deployment yet. Or 'specs_unavailable' / 'unavailable'."}}}},"/ring-builder/embeds/{id}/duplicate":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"post":{"summary":"Duplicate a ring-builder embed with its per-embed commerce data","description":"The copy keeps the source's mode (`config.surface` rides through verbatim) — duplicating a Media Only embed never carries along price books, stone sets or a designed page, since it has none. A full builder's copy clones its price books (with entries), stone sets and designed page, so it quotes and renders exactly like the original. The copy gets a fresh slug and starts public — a stored password cannot carry over (its hash is salted with the source slug). `warnings` names any related table that could not be copied (price_books | stone_sets | cost_rates | page); stone FEEDS (provider credentials) are never copied.","x-scope":"ringbuilder:write","responses":{"201":{"description":"Created — { data: embed, warnings: string[] }"},"403":{"description":"Plan does not include embeds / ring builder, or the copy would exceed the per-kind embed cap — { error: 'embed_limit', message }"},"404":{"description":"Not found"}}}},"/ring-builder/pricing":{"parameters":[{"name":"embedId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"summary":"List one ring-builder embed's price books, each with its per-part price overrides, plus the embed's cost-pricing overrides","description":"Per-EMBED pricing: currency, making charge, markup, FX, rounding, SKU pattern and mode. Each book row also carries `cost_mode`: true or false overrides the platform's cost-based pricing switch for this embed, null inherits it. `costOverrides` is the embed's own cost-pricing sheet, shared by all of its currency books: `alloyRates` (price per gram by alloy) and `meleeTiers` (accent-stone price tiers). Both are in the platform currency, because cost lines go through each book's fx rate and markup like every inherited price. An empty list means the embed uses the platform's rates. These are your own settings and are never part of a quote, the embed or the catalog. Requires the `pricingManager` entitlement.","x-scope":"ringbuilder:read","responses":{"200":{"description":"OK — { data: [{ …book, cost_mode, entries: [] }], costOverrides: { alloyRates, meleeTiers } }","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true,"properties":{"cost_mode":{"type":["boolean","null"],"description":"true or false overrides the platform's cost-based pricing switch for this embed. null inherits it."},"entries":{"type":"array","items":{"type":"object","additionalProperties":true},"description":"The book's per-part price overrides."}}}},"costOverrides":{"type":"object","properties":{"alloyRates":{"type":"array","items":{"$ref":"#/components/schemas/EmbedAlloyRate"}},"meleeTiers":{"type":"array","items":{"$ref":"#/components/schemas/EmbedMeleeTier"}}}}}}}}},"403":{"description":"Plan does not include the pricing manager"},"404":{"description":"Embed not found"},"422":{"description":"Invalid input"}}},"post":{"summary":"Create the embed's price book for a currency, or update the existing one","description":"Upserts on embed + currency. `costMode`, `alloyRates` and `meleeTiers` are the embed's own cost-based pricing overrides, saved exactly as the dashboard saves them. `alloyRates` and `meleeTiers` are per embed, not per currency, and each REPLACES the embed's whole set: leave one out to keep it as it is, send `[]` to clear it. Both are in the platform currency. A sheet that lists an alloy twice, or two tiers that start at the same size for the same stone preset, is refused before anything is saved. Requires the `pricingManager` entitlement.","x-scope":"ringbuilder:write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["embedId","currency"],"properties":{"embedId":{"type":"string","format":"uuid"},"currency":{"type":"string","minLength":3,"maxLength":3},"isDefault":{"type":"boolean"},"makingCents":{"type":"integer","nullable":true},"markupBps":{"type":"integer","description":"Basis points applied to every inherited base price"},"fxRate":{"type":"number","nullable":true},"rounding":{"type":"string","enum":["none","nearest_1","nearest_5","up_10","up_99","up_00"]},"skuPattern":{"type":"array","items":{"type":"string"},"nullable":true},"skuSep":{"type":"string","nullable":true},"priceMode":{"type":"string","enum":["overlay","absolute","store_sku"]},"status":{"type":"string","enum":["active","archived"],"description":"Update only"},"costMode":{"type":["boolean","null"],"description":"Cost-based pricing for this embed: true or false overrides the platform's switch, null inherits it. Omit to leave it as it is. Has no effect on an absolute or store_sku book, which price only what you set."},"alloyRates":{"type":"array","maxItems":200,"items":{"$ref":"#/components/schemas/EmbedAlloyRate"},"description":"This embed's own price per gram by alloy, one entry per alloy. Replaces the embed's whole set."},"meleeTiers":{"type":"array","maxItems":500,"items":{"$ref":"#/components/schemas/EmbedMeleeTier"},"description":"This embed's own accent-stone tiers. Replaces the embed's whole set."}}}}}},"responses":{"200":{"description":"Updated — { data: book, warnings? }. `warnings: ['migration_required']` means the cost-pricing part could not be stored on this deployment yet; the rest of the book was saved."},"201":{"description":"Created — { data: book, warnings? }"},"403":{"description":"Plan does not include the pricing manager"},"404":{"description":"Embed not found"},"422":{"description":"Invalid input — or { error: 'duplicate_alloy_rate' } / { error: 'duplicate_melee_tier' } for a sheet refused before anything was saved, or { error: 'invalid_rates' } for a rate or tier the database refused"},"500":{"description":"{ error: 'update_failed' | 'save_failed' | 'insert_failed' }"}}}},"/ring-builder/pages":{"parameters":[{"name":"embedId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"summary":"Get the designed page for a ring-builder embed (draft + what is live)","description":"The drag-and-drop page around the builder. `doc` is the working draft; `publishedDoc` is what shoppers see (null = the embed renders the built-in layout). Requires the `pageBuilder` entitlement.","x-scope":"ringbuilder:read","responses":{"200":{"description":"OK — { data: { doc, publishedDoc, version, status, issues } }"},"403":{"description":"Plan does not include the page builder"},"404":{"description":"Embed not found"},"422":{"description":"Invalid input"}}},"post":{"summary":"Save the page draft, and optionally publish or unpublish it","description":"Author HTML/CSS is sanitised and the block structure repaired on write, exactly as the dashboard editor does. Publishing snapshots a restore point and busts the embed's edge cache.","x-scope":"ringbuilder:write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["embedId"],"properties":{"embedId":{"type":"string","format":"uuid"},"name":{"type":"string"},"template":{"type":"string","enum":["steps","split","carousel","compact"]},"doc":{"type":"object","description":"A BuilderDoc: { version, theme, root, blocks, responsive, customCss }"},"publish":{"type":"boolean","description":"Make the saved draft live in the same call"},"unpublish":{"type":"boolean","description":"Revert to the built-in layout, keeping the draft"}}}}}},"responses":{"200":{"description":"OK — { data: page }"},"403":{"description":"Plan does not include the page builder"},"404":{"description":"Embed not found"},"422":{"description":"Invalid input, or a page with no ring viewer"}}}},"/ring-builder/stone-sets":{"parameters":[{"name":"embedId","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"summary":"List one ring-builder embed's centre-stone tiers","description":"The embed's OWN tiers (incl. archived) — never the platform defaults, so an empty list means the embed inherits them. Requires the `pricingManager` entitlement.","x-scope":"ringbuilder:read","responses":{"200":{"description":"OK — { data: [{ id, code, label, spec, carat_min, carat_max, base_cents, per_carat_cents, … }] }"},"403":{"description":"Plan does not include the pricing manager"},"404":{"description":"Embed not found"},"422":{"description":"Invalid input"}}},"post":{"summary":"Create a centre-stone tier on the embed","x-scope":"ringbuilder:write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["embedId","code","label"],"properties":{"embedId":{"type":"string","format":"uuid"},"code":{"type":"string","maxLength":40},"label":{"type":"string","maxLength":80},"spec":{"type":"object","properties":{"color":{"type":"string"},"clarity":{"type":"string"},"cut":{"type":"string"},"cert":{"type":"string"},"lab":{"type":"boolean"},"polish":{"type":"string"},"symmetry":{"type":"string"}}},"caratMin":{"type":"number"},"caratMax":{"type":"number"},"caratSteps":{"type":"array","items":{"type":"number"},"nullable":true},"baseCents":{"type":"integer"},"perCaratCents":{"type":"integer"},"caratMultipliers":{"type":"object","additionalProperties":{"type":"number"},"nullable":true,"description":"Per-carat-band multiplier keyed by the carat the band starts at"},"thumbUrl":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"badge":{"type":"string","nullable":true},"skuCode":{"type":"string","nullable":true},"sortOrder":{"type":"integer"}}}}}},"responses":{"201":{"description":"Created — { data: set }"},"403":{"description":"Plan does not include the pricing manager"},"404":{"description":"Embed not found"},"409":{"description":"A tier with this code already exists on the embed"},"422":{"description":"Invalid input"}}}},"/ring-builder/price":{"post":{"summary":"Compute the authoritative price for a ring selection","x-scope":"ringbuilder:read","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["embedId"],"properties":{"embedId":{"type":"string","format":"uuid"},"selection":{"type":"object","properties":{"shape":{"type":"string"},"setting":{"type":"string"},"carat":{"type":"number"},"metal":{"type":"string"}}}}}}}},"responses":{"200":{"description":"OK — { data: { price, currency, enabled } }"},"404":{"description":"Embed not found"},"422":{"description":"Invalid input"}}}},"/ring-builder/quote":{"post":{"summary":"Compose the SKU + total for a full ring selection (sum-of-parts catalog pricing)","description":"Besides the SKU and total, the answer carries the build's measured metal weight and accent stones as `specs`, taken at the size the band was modelled at. `specs` is absent when the band has not been measured; inside it, a figure that cannot be measured reliably is `null` rather than estimated. A two-tone build needs `bandMetalPresetId` and `headMetalPresetId` for the weights to be right. No prices, rates or margins are ever part of `specs` or `flags`.","x-scope":"ringbuilder:read","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["selection"],"properties":{"selection":{"type":"object","properties":{"bandSlug":{"type":"string"},"headSelection":{"type":"object","description":"{ groupCode: valueCode | number } e.g. { shape: 'Round', carat: 1.5 }"},"headVariantId":{"type":"string"},"metalPresetId":{"type":"string"},"bandMetalPresetId":{"type":"string","description":"Band metal (two-tone). Omit to inherit metalPresetId."},"headMetalPresetId":{"type":"string","description":"Setting/head metal (two-tone). Omit to inherit metalPresetId."},"meleeGemPresetId":{"type":"string","description":"Accent (pavé) stone colour. Omit to inherit gemPresetId."},"gemPresetId":{"type":"string"}}}}}}}},"responses":{"200":{"description":"OK — { data: { sku, priceCents, currency, breakdown, labels, shopify?, specs?, flags? } }","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","required":["sku","priceCents","currency","breakdown","labels"],"properties":{"sku":{"type":"string"},"priceCents":{"type":"integer"},"currency":{"type":"string"},"breakdown":{"type":"array","description":"Where the total comes from: a `setting` line, derived from the total so the lines agree with it, plus a line for each priced add-on.","items":{"type":"object","properties":{"key":{"type":"string"},"cents":{"type":"integer"}}}},"labels":{"type":"object","additionalProperties":{"type":"string"},"description":"Group code to the customer-facing name of the value chosen, e.g. `{ metal: \"10K Yellow Gold\" }`. Show these, not preset ids or option codes."},"shopify":{"type":"object","description":"Present when a connected Shopify store already sells this exact build. Its price replaces the computed one and its title is the name to show.","properties":{"variantId":{"type":"string"},"productId":{"type":["string","null"]},"title":{"type":["string","null"]},"imageUrl":{"type":["string","null"]},"available":{"type":"boolean"}}},"specs":{"$ref":"#/components/schemas/RingQuoteSpecs"},"flags":{"type":"array","items":{"type":"string"},"description":"Quote-level notes about how the price was reached. Absent when there is nothing to report."}}}}}}}},"422":{"description":"Invalid input"},"503":{"description":"Catalog unavailable"}}}},"/ring-builder/cart":{"post":{"summary":"Add a configured ring to the storefront cart","description":"Headless parity with the embed's add-to-cart. Recomputes the price server-side, then resolves the best instruction the merchant's storefront can take (match → mint → create → draft → hosted → event). Target the embed with `embedId` or `embedSlug` (it must belong to your org). Send an Idempotency-Key so a retry can't mint a second variant.","x-scope":"commerce:write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["selection"],"properties":{"embedId":{"type":"string","format":"uuid","description":"Ring-builder embed to buy through (or embedSlug)"},"embedSlug":{"type":"string","description":"Public slug of the ring-builder embed (alternative to embedId)"},"selection":{"type":"object","properties":{"bandSlug":{"type":"string"},"headSelection":{"type":"object","description":"{ groupCode: valueCode | number } e.g. { shape: 'Round', carat: 1.5 }"},"headVariantId":{"type":"string"},"metalPresetId":{"type":"string"},"bandMetalPresetId":{"type":"string","description":"Band metal (two-tone). Omit to inherit metalPresetId."},"headMetalPresetId":{"type":"string","description":"Setting/head metal (two-tone). Omit to inherit metalPresetId."},"meleeGemPresetId":{"type":"string","description":"Accent (pavé) stone colour. Omit to inherit gemPresetId."},"gemPresetId":{"type":"string"},"engravingText":{"type":"string"}}},"currency":{"type":"string","minLength":3,"maxLength":3},"ringSize":{"type":"string"},"stone":{"type":"object","description":"{ kind: 'set', stoneSetId } or { kind: 'feed', diamondId }"},"customer":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string"}}},"intent":{"type":"string","enum":["cart","buy"],"description":"'buy' goes straight to checkout where the mode allows"}}}}}},"responses":{"200":{"description":"OK — { data: { instruction, sku, priceCents, currency, token } }"},"404":{"description":"Ring-builder embed not found for this org"},"422":{"description":"Invalid input / unpriced selection"},"503":{"description":"Catalog unavailable"}}}},"/ring-builder/concierge":{"post":{"summary":"AI concierge — a chat turn + current selection returns a reply, a validated selection and its authoritative price","description":"Runs a bounded tool-loop that configures + prices the ring. Picks only real catalog options and never guesses a price. Returns 503 until the server has an ANTHROPIC_API_KEY. **This call CONSUMES CREDITS** and can incur billed overage on plans with overage enabled. **Scope change:** it now requires `ringbuilder:write`; `ringbuilder:read` is still accepted until 2026-12-01 and those responses carry `Deprecation`/`Sunset` headers. Reissue affected keys with `ringbuilder:write` before then.","x-scope":"ringbuilder:write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["messages"],"properties":{"messages":{"type":"array","description":"Chat history; each { role: 'user'|'assistant', content }.","items":{"type":"object","required":["role","content"],"properties":{"role":{"type":"string"},"content":{"type":"string"}}}},"selection":{"type":"object","description":"The current ring selection (same shape as /ring-builder/quote).","properties":{"bandSlug":{"type":"string"},"headSelection":{"type":"object"},"headVariantId":{"type":"string"},"metalPresetId":{"type":"string"},"bandMetalPresetId":{"type":"string","description":"Band metal (two-tone). Omit to inherit metalPresetId."},"headMetalPresetId":{"type":"string","description":"Setting/head metal (two-tone). Omit to inherit metalPresetId."},"meleeGemPresetId":{"type":"string","description":"Accent (pavé) stone colour. Omit to inherit gemPresetId."},"gemPresetId":{"type":"string"}}}}}}}},"responses":{"200":{"description":"OK — { data: { reply, changed, selection, quote: { sku, priceCents, currency } } }"},"422":{"description":"Invalid input"},"503":{"description":"Concierge unconfigured / catalog unavailable"}}}},"/diamonds/search":{"get":{"summary":"Search the live loose-diamond feed (retail-marked-up)","description":"Query params: shape, caratMin, caratMax, color, clarity, cut (comma lists), priceMinCents, priceMaxCents, lab, sort (price_asc|price_desc|carat_asc|carat_desc), limit, offset. Retail markup applied server-side. 503 until a supplier feed (The Diamond Port / sample) is configured.","x-scope":"diamonds:read","responses":{"200":{"description":"OK — { data: { diamonds: [{ id, shape, carat, color, clarity, cut, certLab, certNumber, priceCents, currency, image, video }], total, hasMore } }"},"403":{"description":"Your plan does not include the Diamond Feed — { error: 'feature_unavailable' }. The key's scope authorises the key; the plan authorises the account, and both are required."},"503":{"description":"Diamond feed not configured"}}}},"/integrations/shopify":{"get":{"summary":"Shopify connection status + attributed order revenue for your org","description":"Read-only. Returns { data: { connected, shopDomain, shopName, scopes, installedAt, orders: { count, totalCents } } }. The OAuth connect/disconnect is interactive (dashboard). 503 until the Shopify app is configured on the deployment.","x-scope":"integrations:read","responses":{"200":{"description":"OK — connection status + order totals"},"503":{"description":"Shopify app not configured"}}}},"/lifestyle":{"post":{"summary":"Stage a ring render into a branded lifestyle/product scene","description":"Body: { image (transparent PNG data URL of the ring), sceneId, prompt?, count? }. The ring stays a real render; the model only makes the scene. Returns { data: { images: [dataUrl], provider, sceneId } }. 503 until an image-gen provider is configured.","x-scope":"lifestyle:write","responses":{"200":{"description":"OK — generated images (data URLs)"},"422":{"description":"Invalid input / image"},"502":{"description":"Generation failed"},"503":{"description":"Lifestyle shots not configured"}}}},"/metal-weight":{"parameters":[{"name":"units","in":"query","required":false,"schema":{"type":"string","enum":["auto","mm","cm","in","m"],"default":"auto"},"description":"The model's unit. `auto` takes the unit the file states, else infers it from the geometry (flag `units_inferred`)."},{"name":"kind","in":"query","required":false,"schema":{"type":"string","enum":["piece","ring"],"default":"piece"},"description":"`piece` identifies a centre stone; `ring` counts every stone as a side stone (a band)."}],"post":{"summary":"Measure a GLB: metal volume, weight at every alloy, and its stones","description":"Send the GLB (Draco-compressed or not) as the raw body, or as the `file` field of multipart/form-data, up to 4 MB (the request size the platform accepts). For a larger model, save it as a project and use POST /projects/{id}/metal-weight, which reads it server-side (up to 50 MB). Metal and gem objects are told apart the way the studio does. Needs the Playground · Material Lab plan feature.","x-scope":"models:read","requestBody":{"required":true,"content":{"model/gltf-binary":{"schema":{"type":"string","format":"binary"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary"}}}}}},"responses":{"200":{"description":"OK — { data: MetalWeight }","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MetalWeight"}}}}}},"403":{"description":"Your plan does not include the Material Lab — { error: 'feature_unavailable' }"},"413":{"description":"The body is over 4 MB — { error: 'model_too_large' }; measure it from a saved project instead"},"415":{"description":"Not a GLB — { error: 'unsupported_format' }"},"422":{"description":"Invalid units/kind, an empty body, or a GLB that could not be read — { error: 'invalid_input' | 'invalid_model' }"}}}},"/checkout/session":{"post":{"summary":"Create a Stripe Checkout session for a ring selection (authoritatively priced)","description":"Body: { selection, customer? }. Recomputes the price server-side and returns { data: { url, sku, priceCents, currency } } — a Checkout URL on the org's connected Stripe account. 409 if the org hasn't connected Stripe; 503 until checkout is configured.","x-scope":"checkout:write","responses":{"200":{"description":"OK — { data: { url, sku, priceCents, currency } }"},"403":{"description":"Your plan does not include Checkout & Payments — { error: 'feature_unavailable' }"},"409":{"description":"Stripe not connected for this org"},"422":{"description":"Invalid selection / unpriced"},"503":{"description":"Checkout not configured"}}}},"/ring-builder/quotes":{"get":{"summary":"List captured leads/quotes (filter by ?status= saved|enquiry|cart or ?leadStatus= new|contacted|won|lost)","x-scope":"ringbuilder:read","responses":{"200":{"description":"OK — { data: [{ id, sku, priceCents, currency, selection, customer, status, lead_status, notes, created_at }] }"}}},"post":{"summary":"Save a quote/enquiry/cart lead (server recomputes SKU + price; supports Idempotency-Key)","x-scope":"ringbuilder:write","responses":{"201":{"description":"Created"},"422":{"description":"Invalid input"}}}},"/ring-builder/quotes/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"summary":"Get one lead/quote","x-scope":"ringbuilder:read","responses":{"200":{"description":"OK"},"404":{"description":"Not found"}}},"patch":{"summary":"Update a lead's CRM state (leadStatus: new|contacted|won|lost, notes)","x-scope":"ringbuilder:write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"leadStatus":{"type":"string","enum":["new","contacted","won","lost"]},"notes":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"OK"},"404":{"description":"Not found"}}}},"/embeds/{id}/analytics":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"range","in":"query","required":false,"schema":{"type":"integer","enum":[7,30,90]}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Start of an explicit date range (YYYY-MM-DD, UTC), inclusive. Alternative to range; from/to together override it."},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"End of an explicit date range (YYYY-MM-DD, UTC), inclusive. Alternative to range; from/to together override it."}],"get":{"summary":"Embed analytics — funnel, daily time-series and top selections for one of your embeds","x-scope":"analytics:read","responses":{"200":{"description":"OK — { data: { embed, range, from, to, funnel, timeseries, topSelections, currency, quoteValueCents, quickOptions: { openSessions, picks, pickSessions, previews, topMetals, topShapes } } }"},"404":{"description":"Not found"}}}},"/credits":{"get":{"summary":"Get plan + credit balance","x-scope":"usage:read","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"plan":{"type":"string"},"creditsBalance":{"type":"integer","description":"Live credit balance. Negative means credits are owed (overage plans) — it does NOT mean unlimited."},"unlimitedCredits":{"type":"boolean","description":"True only when credits are genuinely unlimited (enterprise / platform admin)."},"monthlyCredits":{"type":"integer"}}}}}}}}}}}