Read and edit Weaverse content (projects, pages, theme settings, translations) outside the Studio editor, over an authenticated REST API. Use it for bulk edits, AI/automation content pipelines, staged draft→publish flows, and scheduled or versioned content changes.
- Base URL:
https://studio.weaverse.io/api/v1/content - Machine spec:
GET https://studio.weaverse.io/api/v1/content/openapi.json(OpenAPI 3.1, no auth) — the authoritative contract. Fetch it for exact field-level schemas; this skill is the workflow guide. - Full endpoint details:
references/endpoints.md. Rich-text/Portable Text details:references/portable-text.md. - Helper script:
scripts/weaverse_content_api.mjs(zero-dependency, Node 18+).
The one thing you must understand first
Not every write goes live. The API has three write models:
- Live edits —
PATCH .../pages/:type/*handle(item data),PATCH .../theme-settings, and translation writes apply immediately and invalidate caches, going live throughapi.weaverse.io— the same path a Studio save takes. - Staged writes + explicit promotion — page drafts (
GET/PUT/DELETE .../page-drafts/:pageId), global-section drafts (op=save-draft/discard-draft), and pending-version replacement (PUT /versions/{versionId}, CAS-fenced; drafts refused) don't change live by themselves. Promotion is a live write:POST /publish-versions/{versionId}(publish a saved draft),POST /versions/{versionId}/publish-now(publish a scheduled version immediately), orPOST /versions/{versionId}/restore(immediately overwrites live content — a confirm-gated rollback).POST /versions/{versionId}/scheduleis not inert either: it commits an automatic live change atpublishAtwith no further call — so aPUTreplacement of an already-scheduled version also goes live at that time. All of these needcontent:publish(restore alsocontent:delete). - Project lifecycle —
POST /projectswithop=create|copy|delete|set-settings|purge-cache(scopeproject:manage; delete also needscontent:delete+confirm). Projects can be created through the API — a blank or theme-seeded project, or a background copy of this shop's own or a public demo-store project.
So the lifecycle is:
Create project → POST /projects (op=create) — or start from Weaverse Builder / demo store
Create initial pages → import a project JSON into Studio (generating-weaverse-project-json)
OR create pages one by one via POST /projects/:projectId/pages
Update content → live PATCH (item edits, theme settings) when immediacy is wanted
OR draft → review → publish / schedule when it must be gated
Fix a bad publish → restore a version (confirm-gated) or re-PATCH
When to Use
- Push AI-generated or translated copy into an existing live project
- Bulk-edit content across many pages/items
- Read current page content/items to diff or round-trip
- Add a new typed item to a page (via
PATCHwith atypeon the new id) or relinkchildren - Create a
CUSTOMpage or a resource-backed template page (POST /projects/:projectId/pages) - Stage edits as a draft, then publish now, on a schedule, or discard
- Restore a previous page/theme version, or inspect publish history
- Read/write per-locale translations for a page, or project-wide translation units
- Create/copy/delete a project or change its name/default locale (
POST /projectslifecycle ops) - Delete pages in bulk
- Upload an image/video to Shopify and reference its CDN URL in a Weaverse item
Authentication
Every endpoint except openapi.json needs a bearer token:
Authorization: Bearer <WEAVERSE_API_KEY>
- Get a shop key from Weaverse Studio → Dashboard → Account/Settings → API Keys. Store it in an env var (
WEAVERSE_API_KEY); never hardcode it, never pass it as a?apiKey=query param outside local testing — query params leak into server/CDN logs. - A shop key is scoped to one shop. Requests for a project owned by another shop fail (403/404 — the API never discloses other tenants).
- The same token also authorizes the Shopify proxy (see "Upload resources to Shopify").
Scopes and token kinds
The bearerAuth scheme accepts a shop API key, a scoped API key, a delegated MCP OAuth token, or (translation-unit routes only) a project-bound translation agent token. Scopes:
| Scope | Grants |
|---|---|
content:read |
Read projects, pages, theme settings, versions, drafts, translation units |
content:write |
Save drafts and non-destructive edits (items, metadata, SEO, assignments, languages, glossary). Cannot call the explicit publish/schedule/restore/delete endpoints — but its live edits (PATCH .../pages/..., page create) reach the storefront immediately |
content:publish |
Publish drafts, schedule/reschedule/cancel publications, restore versions, publish global sections |
content:delete |
Delete pages/global sections/languages/static keys, override reverts; required with project:manage for project deletion |
project:manage |
Create/copy/delete projects, settings, variants, hierarchy, backups, previews, hostname policy |
translations:read / translations:write |
Translation-unit routes only; never general content access |
- A missing scope on a scoped credential returns
403 INSUFFICIENT_SCOPEwith nothing written. - A legacy unscoped key keeps read/write/translation baseline access and every pre-scope endpoint, but never gains publish/delete/project-manage implicitly — except its historical bulk page-delete access (
DELETE .../pagesstill works for keys minted before scopes existed). Treat such a key as destructive-capable and replace it with a scoped key. - Delegated (MCP OAuth) tokens must send revision tokens on writes:
expectedUpdatedAton page patches,expectedRevisionon theme-settings — omitted →428 PRECONDITION_REQUIRED. API keys may omit them, but sending them is still the safe default. - A scoped key can be minted task-tight (e.g.
content:read+translations:writefor a translation agent) so a leaked or runaway script cannot delete or publish. Prefer that for automation.
The spec accepting delegated MCP OAuth tokens is an auth fact only. Hosting an MCP server or listing a Claude/ChatGPT connector is a separate activation this skill pack does not perform.
Core live-update workflow
Read before you edit. To change existing content you must target real item ids, so read the page first — you cannot patch blindly. New items are the one exception: they use a fresh id plus a type, and must fit the page tree (a children reference must point at an id already on the page or created in the same request).
- Find the project —
GET /projects, match by name, keep itsid. - Pick a locale —
GET /projects/:projectId/languages. Keep theisDefault: truecode (e.g.en-us). An emptydata: []is a valid state: the project has no configured languages, everything lives on the base locale — don't guess or invent a code; an explicitlocale=en-usread reaches the base page through the fallback chain. - Read the page —
GET /projects/:projectId/pages/:type/*handle?locale=<code>. Always passlocale. Resolution falls back requested locale → project default →en-default, so a missing locale can still succeed while returning a different locale's page — the edit then lands on the wrong page. The defaultweaverseformat already returns every item with itsid— that id is exactly what the patch needs (?meta=trueonly matters forportable-textreads). Keep the response'supdatedAt; it is the optimistic-concurrency token for the patch. - Build the patch — for each item, send only the fields that change inside
data(it shallow-merges; nested objects are replaced wholesale). Every entry needs bothidanddata—datais not optional, even for a topology-only relink; use"data": {}. To create a new item, give it a freshidand supply itstype. Include the samelocaleyou read with, and the read'supdatedAtasexpectedUpdatedAt:{ "locale": "en-us", "expectedUpdatedAt": "2026-10-21T12:00:00.000Z", "items": [ { "id": "itm1", "data": { "heading": "New heading" } }, { "id": "itm-new", "type": "Hero", "data": { "heading": "Fresh section" }, "children": [{ "id": "itm1" }] } ] } - Patch —
PATCH /projects/:projectId/pages/:type/*handle(POST accepted if your client/proxy can't send aPATCHbody). Max 100 items per request — chunk larger edits. Add"exact": trueto refuse locale/handle fallback and write only the assignment whose stored(type, handle, locale)is exactly the one addressed (delegated writes are always exact). - Check the response —
{ object: "page_update", requested, updated, created, notFound, failed, updatedIds, createdIds, notFoundIds, failedIds }. Non-emptynotFoundIds= ids not on the resolved page (wrong page/locale, stale ids, or a new id sent withouttype) — re-read with the right locale, don't retry blind.failedIds= items whose outcome is unconfirmed after a mid-request stale detection — re-read and reapply those; don't assume they applied.
Stale refusal: if the page changed since your read (e.g. a concurrent Studio save), the whole request is rejected 409 STALE_PAGE before anything is written. Re-read and reapply on top of the newer content — never retry the same patch. Theme settings behave the same with expectedRevision (409 STALE_PROJECT).
A successful live patch invalidates caches and is served through api.weaverse.io — the same path a Studio save takes. When the response is ambiguous (failedIds, cacheDelivery: "degraded" on publish/delete), re-read the resource to confirm the actual state before reporting success.
Populate a new or empty page in one request
A page created with POST /pages (or an empty template) has only a root main item. To build the whole page from an import-style JSON:
- Read the page and take the root
mainitem id. - Send one PATCH (≤100 items) with: the root
{ "id": <rootId>, "data": {}, "children": [...] }(datarequired, even empty), plus every section/block as a new item withid,type,data,children. - Expect
updated: 1, created: N, notFound: 0.
Use fresh ids per page (don't reuse ids from another page or an earlier test render). Content sent this way skips Studio's import, so schema presets are not applied — every non-default value must be in data.
Theme settings
PATCH /projects/:projectId/theme-settings with { "theme": { ...changedKeys } } shallow-merges top-level theme keys (nested objects replaced wholesale — read them first), writes the project's own config only, and records a restorable ThemeVersion. Send the GET response's revision as expectedRevision. Theme keys are global: a product-card key restyles every product card on the site. The response returns the full merged theme and updatedKeys.
Page metadata (name, SEO)
GET/POST /projects/:projectId/page-settings/:pageId — op=rename (name/prefix), op=rename-custom (name + handle, assignmentId), op=set-seo (seo object). SEO is writable here; it is not part of the page-content PATCH.
Draft → publish workflow
When an edit must be reviewed or gated instead of going live immediately, use drafts. Addressing is by pageId — no locale/handle fallback, so a draft can never land on another assignment.
- Read the basis —
GET /projects/:projectId/page-drafts/:pageId. Returns the current draft head when one exists, else live content, plus the revision tokens a save must replay:headVersionId,pageUpdatedAt,projectUpdatedAt. With a draft head, the page/project tokens are the revisions that draft was built on — a save is refused once live moved past them. - Save the draft —
PUT .../page-drafts/:pageIdwith the full item list (≤200 items, eachid+ optionaltype/data/children; malformed graphs are 400) plus the threeexpected*tokens exactly as GET returned them (nullincluded verbatim). Refused409 STALE_PAGEwhen the draft head, live page, or project moved — nothing saved, re-read. Never touches live. - Publish —
POST /publish-versions/:versionIdwith{ "projectId", "pageId" }(optionallyreplaceScheduledVersionIdto atomically replace an existing scheduled version). Same promotion and cache fan-out as the Studio publish; needscontent:publish. A failed cache invalidation after a committed publish reportscacheDelivery: "degraded"— content changed, delivery queued. - Or discard —
DELETE .../page-drafts/:pageIdby exact CAS on the head.
Global sections
GET /projects/:projectId/global-sections lists sections with usages. POST .../global-sections creates one ({ itemId, name }). Per section: GET .../global-sections/:sectionId reads the draft head (null when none); POST .../global-sections/:sectionId runs op=save-draft / discard-draft / publish-draft / rename / delete. save-draft/discard-draft are content:write; publish-draft needs content:publish; delete needs content:delete + confirm="<sectionId>". save-draft takes the full item list and the expectedHeadVersionId CAS token.
Versions: history, schedule, restore
- List —
GET /projects/:projectId/versions?pageId=<id>(that page's versions),?kind=theme(theme versions),?kind=retired-pages(retired page versions, recovery), default?kind=scheduled(current schedule, page + theme). - Read one —
GET /versions/:versionId?type=page|theme&projectId=<id>. - Schedule —
POST /versions/:versionId/schedule{ projectId, type, publishAt }(+replaceScheduledVersionIdfor page versions; rejected 400 for theme). The version goes live automatically atpublishAt— no further call; treat scheduling as approving a live change.publishAtmust be future; a page/project already having a scheduled version is409, nothing changed. - Move / cancel —
PATCH /versions/:versionId(reschedule) andDELETE /versions/:versionId(cancel) on scheduled publications only — a version no longer scheduled is409/404. - Publish now —
POST /versions/:versionId/publish-now{ projectId, type }. - Restore —
POST /versions/:versionId/restore{ projectId, type, confirm: "<versionId>" }. Restoring overwrites live content, so it needs bothcontent:publishandcontent:deleteplus the confirm string. Treat it as a merchant-approved rollback, not a routine edit. On409(concurrent claim, nothing changed) never replay: re-read the version and live target, and reconfirm with the merchant for the current state first.
Translations
Two protocols; both write only Translation rows — base page content, items, and every other locale stay untouched, and the project default language is rejected as a target (it is the source others translate from).
- Per page —
GET /projects/:projectId/page-translations/:pageId?locale=<code>returns every translatable key with itssource, currenttranslation, and thetargetGenerationa writeback must replay.POSTthe same URL with entries echoed verbatim excepttranslation(≤250 per request). Each entry is compare-and-set on both thesourceit answers for and thetargetGenerationit read — a delayed agent can never overwrite a merchant's newer source text or another writer's newer translation; refused entries come back instaleand were not written.translation: ""clears a key (kept distinct from never-translated). After writing, GET again to read back. - Project-wide units —
GET /projects/:projectId/translation-units?targetLocale=<code>(kind=text|image,status, cursor paging) inventories every unit the stored project schema approves — components without a stored schema or fields of other input types are never listed or writable.POST /projects/:projectId/translations/validateruns every check with zero writes (dry run);POST .../translations/applywrites the batch atomically under the project lock. Any stale source/target refuses the whole batch (409 STALE_TRANSLATION) and writes nothing; an identical retry converges to unchanged. These routes also accept project-bound translation agent tokens (translations:read/translations:writeonly).
Project lifecycle
POST /projects with an op (scope project:manage):
op |
Body | Effect |
|---|---|---|
create |
name?, projectId?, config.themeName? |
Blank or theme-seeded project |
copy |
sourceProjectId, name? |
Background clone (202 + targetProjectId); source must be this shop's own or a public demo-store project; completion observed by listing the target's pages |
delete |
projectId, confirm (= projectId) |
Soft-delete with a pre-delete backup; also needs content:delete |
set-settings |
projectId, expectedUpdatedAt, name?, defaultLocale? |
Revision-fenced non-secret settings |
purge-cache |
projectId |
Studio "Clear cache" |
PATCH /projects/:projectId renames a project (name only, unknown fields 400). Ask the merchant before create/copy/delete — these change account-level state, not just one page's content.
What the Content API cannot do
- Publish Shopify resources to a sales channel — the proxy token lacks
read_publications/write_publications. - Set market settings over an API key —
PATCH /projects/:projectIdacceptsnameonly. (Hierarchy — variants, parent link/detach, page overrides — is writable through the dedicatedPOST /projects/:projectId/hierarchyroute underproject:manage, destructive ops confirm-gated; seereferences/endpoints.md.)
Page addressing
Pages are addressed by page type + handle:
INDEX, PRODUCT, ALL_PRODUCTS, COLLECTION, COLLECTION_LIST, PAGE, BLOG,
ARTICLE, CART, CUSTOMER, NOT_FOUND, PASSWORD, SEARCH, CUSTOM
- Singletons (
INDEX,ALL_PRODUCTS,COLLECTION_LIST,CART,CUSTOMER,NOT_FOUND,PASSWORD,SEARCH) — one page per project, omit the handle. - CUSTOM — addressed by its path (the splat may contain slashes, e.g.
blogs/news). - Templated (
PRODUCT,COLLECTION,PAGE,BLOG,ARTICLE) — keep a shared default template at the empty handle, so a missing handle is rejected (it won't silently edit the template). Pass the real handle. - A real handle does not guarantee a per-resource page. Reading
…/pages/PRODUCT/<real-handle>succeeds (200) even when no per-resource override exists — the resolver falls back to the type's shared default template, and the returnedididentifies that shared page. A PATCH then edits the template every resource of that type renders. Inspect the returnedidbefore patching; create the per-resource override first (POST /pagesclones the shared default) when the change is meant for one resource. Deliberate shared-template edits are fine — make them knowingly.
Locale always matters. On reads/updates, always pass a real locale code (from List languages). In list-pages responses, a row's locale may be null for market-first projects (rows are keyed by market, not locale) — don't echo null back; pass a real code and let resolution map it to the market (e.g. locale=en-us resolves market us).
See references/endpoints.md for the full endpoint list, query params, and response shapes.
Upload resources to Shopify
The Content API itself has no upload endpoint. To get media into a Weaverse item, upload it to Shopify first, then reference the returned CDN URL.
Upload goes through the Weaverse Shopify proxy, which accepts the same Weaverse token:
POST https://studio.weaverse.io/api/admin-graphql
Authorization: Bearer <WEAVERSE_API_KEY>
Content-Type: application/json
The body is a normal Shopify Admin GraphQL request ({ "query": "...", "variables": {...} }). Upload is the standard two-step Shopify flow:
stagedUploadsCreate→ get aurl+parameters(a presigned target) and aresourceUrl.- Upload the file bytes to that staged
urlwith the returnedparameters(multipart POST, not through the proxy). fileCreatewithoriginalSource: <resourceUrl>→ Shopify ingests it and returns the permanent CDN file.- Read back the file's
image.url/sourcesand put that CDN URL into the Weaverse itemdatavia the update workflow above.
Reference implementation in the builder repo: app/backend/admin/file.server.ts (generateStagedUploadLinks → stagedUploadsCreate, then fileCreate). When in doubt, mirror its mutations and field selections.
Alternatively, when a connected Shopify MCP is available, its image-upload /
graphql_mutationtools do the same job without the proxy. Use whichever is connected.
The helper script's upload command runs all four steps and prints the CDN URL, id and size per file. Send a User-Agent header on proxy calls — requests without one are rejected with 403. The proxy is a normal Admin GraphQL endpoint, so other admin mutations the token allows (e.g. menuUpdate for navigation menus, fileDelete) also work through it.
Helper script
scripts/weaverse_content_api.mjs wraps auth and the common calls. It reads WEAVERSE_API_KEY from the environment.
export WEAVERSE_API_KEY=...
node scripts/weaverse_content_api.mjs projects
node scripts/weaverse_content_api.mjs languages <projectId>
node scripts/weaverse_content_api.mjs theme <projectId>
node scripts/weaverse_content_api.mjs theme-update <projectId> <theme.json> # flat { "key": value }; the script wraps it in { theme } and replays the current revision as expectedRevision (409 STALE_PROJECT on a concurrent change)
node scripts/weaverse_content_api.mjs pages <projectId> [type]
node scripts/weaverse_content_api.mjs page <projectId> <type> [handle] [locale] # reads with ?locale
node scripts/weaverse_content_api.mjs create-page <projectId> <type> <handle> [name]
node scripts/weaverse_content_api.mjs update <projectId> <type> [handle] <patch.json> # omit handle for INDEX & other singletons
node scripts/weaverse_content_api.mjs delete <projectId> <type> <handle...>
node scripts/weaverse_content_api.mjs delete-ids <projectId> <pageId...> # projects without languages
node scripts/weaverse_content_api.mjs upload <file...> # → Shopify Files, prints CDN URLs
Use it to inspect a project quickly and to apply patch files. For anything the script doesn't cover — drafts, publishing, versions, translations, lifecycle ops — call the REST endpoints directly (see references/endpoints.md) or read openapi.json.
Red Flags
- Assuming a write is staged — only draft saves (
PUT page-drafts, global-sectionsave-draft) and pending-versionPUT /versions/{id}stay off live by themselves (a replaced scheduled version still goes live at itspublishAt). Live itemPATCHandPOST /pagesunder plaincontent:writechange the storefront immediately;schedulegoes live automatically atpublishAt;publish-versions/publish-now/restorego live at once. - Creating/copying/deleting a project without the merchant's go-ahead — lifecycle ops (
project:manage, delete +content:delete+confirm) change account-level state.op=copyreturns202and keeps running in the background; observe completion by listing the target's pages. - Restoring a version casually —
POST /versions/:id/restoreoverwrites live content (needscontent:publish+content:delete+confirm="<versionId>"). Merchant-approved rollback only. - Retrying a
409 STALE_PAGE/STALE_PROJECT/STALE_TRANSLATIONrefusal verbatim — the resource moved. Re-read, rebuild the edit on top of the newer content, then write.failedIdsin apage_updatemeans unconfirmed outcomes — re-read those items before assuming anything. - A delegated (MCP OAuth) token omitting
expectedUpdatedAt/expectedRevision—428 PRECONDITION_REQUIRED. API keys may omit them; sending them anyway is the safe default. - Omitting
localeon a page read/update — resolution falls back requested → default →en-default, so the request can succeed on a different locale's page and the edit looks "missing" (distinct fromPAGE_NOT_FOUND). Always pass a real code from List languages; use"exact": trueon writes when fallback must be impossible. - Sending a new item id without a
typein a PATCH — unknown ids are created only whentypeis supplied; otherwise they land innotFoundIds.childrenids must already be on the page or be created in the same request. - Patching an existing item without reading its id first — you must target a real item id. Read the page first (with the right
locale); theweaverseread already includes every itemid(?meta=truenot needed). - Sending a children-only entry without
data— everyitemsentry must carry adataobject; topology-only changes must still send"data": {}(item.data must be an object). - Reading
data: []from List languages as an error (or guessing a language) — an empty list means no configured languages (base-locale layout). Don't invent a locale; expliciten-usreaches the base page through the fallback chain. - Echoing back
locale: null— list-pages rows can benullfor market-first projects. Don't sendnull; pass a real code and let resolution map it to the market. - Adding
?meta=truefor normal edits — it only affectsportable-textreads. On aweaverse-format read it changes nothing. - Ignoring
notFoundIds/stalein a response — your ids aren't on the resolved page, or the source/target moved. Re-read with the right locale / re-run GET; don't retry the same payload. - Sending more than 100 items in one update (200 in a draft save, 250 translations, 500 delete targets) — chunk the request.
- Editing a templated type with an empty handle — rejected by design. Pass the real handle.
- Hardcoding the token or using
?apiKey=— useAuthorization: Bearerfrom an env var; mint a task-scoped key for automation instead of a full shop key. - Replacing whole
dataobjects — updates shallow-merge. Send only changed fields; don't resend the entiredataand risk wiping nested values you didn't read. - Putting a non-Shopify URL into a media field after "upload" — finish the
fileCreatestep and use the returned Shopify CDN URL, not the staged/temporaryresourceUrl. - Sending
locale: ""to bulk delete — rejected ("locale" cannot be an empty string). For a project with no languages, delete bypageIds(delete-ids). - Trusting a local dev render right after a PATCH — a running Hydrogen dev server can keep a cached copy of an item id it rendered before, so the page still shows old data. Restart the dev server (or verify on the deployed storefront) before concluding the patch didn't apply.
- Patching a live page with sections the deployed code doesn't have yet — new section types or settings render broken on production until the code is deployed. Deploy first, or patch right before deploying and say so.
- Changing global theme keys without a backup — save the current values from
GET theme-settingsbefore atheme-update(itsrevisiondoubles as the CAS token). ThemeVersions are restorable in Studio / via version restore. - Wrapping the theme-update file in
{ "theme": … }— the script already wraps it, so this writes a straythemekey instead of your settings. CheckupdatedKeysin the response.
Related skills
generating-weaverse-project-json— creates the import JSON that establishes the structure this API then updates. The item ids you patch here come from that JSON (or from a page read back with the rightlocale).cloning-websites-to-weaverse/figma-to-weaverse— produce the section plan that feeds the JSON generator.hydrogen-markets-localization— end-to-end markets/localization workflow this API's translation routes serve.