Help · The API
Recipes
The jobs people script most, step by step. Each step is a curl command reading STUDIO_API and STUDIO_TOKEN from the environment, with a write token; under each recipe is the same as one Node script.
Publish a change from CI
Open a draft, change a page in it, publish it, and wait for the live site to be rebuilt. The route that publishes is the draft’s own publish, not POST …/builds.
You need
PROJECT: The project’s id.PAGE: A page of thepagescollection, by slug, such asen/about.TEXT: The page’s new description. A draft with nothing changed is refused, not published.
1. Open a draft. Its id is the
{draft}of the steps after it. KeepidasDRAFT.curl -sS -X POST "$STUDIO_API/api/v1/projects/$PROJECT/drafts" \ -H "Authorization: Bearer $STUDIO_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"title":"Change from CI"}'2. Read the page as it is in the draft, and keep its
ETag. Keep the answer asDOC. Keep the ETag header asETAG.curl -sS -X GET "$STUDIO_API/api/v1/projects/$PROJECT/drafts/$DRAFT/content/pages/$PAGE" \ -H "Authorization: Bearer $STUDIO_TOKEN"3. Write it back changed.
If-Matchrefuses the write with 412 if somebody saved the page in between.curl -sS -X PUT "$STUDIO_API/api/v1/projects/$PROJECT/drafts/$DRAFT/content/pages/$PAGE" \ -H "Authorization: Bearer $STUDIO_TOKEN" \ -H "If-Match: $ETAG" \ -H 'Content-Type: application/json' \ -d "$DOC" # the document you read, with your change4. Publish the draft. It is merged into the live branch, and a production build is queued. Keep
build_idasBUILD.curl -sS -X POST "$STUDIO_API/api/v1/projects/$PROJECT/drafts/$DRAFT/publish" \ -H "Authorization: Bearer $STUDIO_TOKEN"5. Wait for the build. The live site changes when it has
succeeded. Repeat every 10 seconds untilstatusis one ofsucceeded,failed,canceled,skipped.curl -sS -X GET "$STUDIO_API/api/v1/projects/$PROJECT/builds/$BUILD" \ -H "Authorization: Bearer $STUDIO_TOKEN"
Under the review_required policy a draft must be approved before it is published, and a token acts with its person’s role: an editor’s token cannot publish there. Send Idempotency-Key on the publish so that a retried job does not publish twice.
The same as one Node script
// Publish a change from CI. Node 18 or later, no packages.
// STUDIO_API=https://api.example STUDIO_TOKEN=wct_… PROJECT=… PAGE=… TEXT=… node publish-from-ci.mjs
const { STUDIO_API: api, STUDIO_TOKEN: token, PROJECT: project, PAGE: page, TEXT: text } = process.env;
const headers = { authorization: `Bearer ${token}` };
const vars = { project, page, text };
async function call(method, path, { body, ifMatch } = {}) {
const res = await fetch(api + path, {
method,
headers: {
...headers,
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...(ifMatch ? { 'if-match': ifMatch } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const text = await res.text();
const json = text ? JSON.parse(text) : null;
return { status: res.status, etag: res.headers.get('etag'), json };
}
function expect(answer, statuses) {
if (statuses.includes(answer.status)) return answer;
throw new Error(`${answer.status}: ${answer.json?.detail ?? JSON.stringify(answer.json)}`);
}
const pause = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// 1. Open a draft. Its id is the {draft} of the steps after it.
{
const answer = expect(await call('POST', `/api/v1/projects/${encodeURI(vars.project)}/drafts`, { body: { title: "Change from CI" } }), [201]);
vars.draft = answer.json.id;
console.log(`POST /api/v1/projects/{project}/drafts: ${answer.status}`);
}
// 2. Read the page as it is in the draft, and keep its ETag.
{
const answer = expect(await call('GET', `/api/v1/projects/${encodeURI(vars.project)}/drafts/${encodeURI(vars.draft)}/content/pages/${encodeURI(vars.page)}`), [200]);
vars.doc = answer.json;
vars.etag = answer.etag;
console.log(`GET /api/v1/projects/{project}/drafts/{draft}/content/pages/{page}: ${answer.status}`);
}
// 3. Write it back changed. If-Match refuses the write with 412 if somebody saved the page in between.
{
const answer = expect(await call('PUT', `/api/v1/projects/${encodeURI(vars.project)}/drafts/${encodeURI(vars.draft)}/content/pages/${encodeURI(vars.page)}`, { body: { ...vars.doc, ...{ description: vars.text } }, ifMatch: vars.etag }), [200]);
console.log(`PUT /api/v1/projects/{project}/drafts/{draft}/content/pages/{page}: ${answer.status}`);
}
// 4. Publish the draft. It is merged into the live branch, and a production build is queued.
{
const answer = expect(await call('POST', `/api/v1/projects/${encodeURI(vars.project)}/drafts/${encodeURI(vars.draft)}/publish`), [202]);
vars.build = answer.json.build_id;
console.log(`POST /api/v1/projects/{project}/drafts/{draft}/publish: ${answer.status}`);
}
// 5. Wait for the build. The live site changes when it has succeeded.
for (let attempt = 1; ; attempt++) {
const answer = expect(await call('GET', `/api/v1/projects/${encodeURI(vars.project)}/builds/${encodeURI(vars.build)}`), [200]);
console.log(`GET /api/v1/projects/{project}/builds/{build}: ${answer.json.status}`);
if (["succeeded","failed","canceled","skipped"].includes(answer.json.status)) break;
if (attempt === 60) throw new Error('Still not done after 60 checks.');
await pause(10);
}
Rebuild the live site and wait for it
Build the live branch again and deploy it, for example after a change to the build’s environment. trigger: production is what deploys; a manual build never reaches the live site.
You need
PROJECT: The project’s id. The site must have been published once.
1. Queue a production build of the live branch. Keep
build_idasBUILD.curl -sS -X POST "$STUDIO_API/api/v1/projects/$PROJECT/builds" \ -H "Authorization: Bearer $STUDIO_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"trigger":"production"}'2. Wait until it has finished.
failuresays what went wrong when itfailed. Repeat every 10 seconds untilstatusis one ofsucceeded,failed,canceled,skipped.curl -sS -X GET "$STUDIO_API/api/v1/projects/$PROJECT/builds/$BUILD" \ -H "Authorization: Bearer $STUDIO_TOKEN"
The same as one Node script
// Rebuild the live site and wait for it. Node 18 or later, no packages.
// STUDIO_API=https://api.example STUDIO_TOKEN=wct_… PROJECT=… node rebuild-live.mjs
const { STUDIO_API: api, STUDIO_TOKEN: token, PROJECT: project } = process.env;
const headers = { authorization: `Bearer ${token}` };
const vars = { project };
async function call(method, path, { body, ifMatch } = {}) {
const res = await fetch(api + path, {
method,
headers: {
...headers,
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...(ifMatch ? { 'if-match': ifMatch } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const text = await res.text();
const json = text ? JSON.parse(text) : null;
return { status: res.status, etag: res.headers.get('etag'), json };
}
function expect(answer, statuses) {
if (statuses.includes(answer.status)) return answer;
throw new Error(`${answer.status}: ${answer.json?.detail ?? JSON.stringify(answer.json)}`);
}
const pause = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// 1. Queue a production build of the live branch.
{
const answer = expect(await call('POST', `/api/v1/projects/${encodeURI(vars.project)}/builds`, { body: { trigger: "production" } }), [202]);
vars.build = answer.json.build_id;
console.log(`POST /api/v1/projects/{project}/builds: ${answer.status}`);
}
// 2. Wait until it has finished. failure says what went wrong when it failed.
for (let attempt = 1; ; attempt++) {
const answer = expect(await call('GET', `/api/v1/projects/${encodeURI(vars.project)}/builds/${encodeURI(vars.build)}`), [200]);
console.log(`GET /api/v1/projects/{project}/builds/{build}: ${answer.json.status}`);
if (["succeeded","failed","canceled","skipped"].includes(answer.json.status)) break;
if (attempt === 60) throw new Error('Still not done after 60 checks.');
await pause(10);
}
Add or change a content entry
List a collection in a draft, create an entry, and change it, without the editor.
You need
PROJECT: The project’s id.DRAFT: An open draft’s id.SLUG: A slug for the new entry, such asen/news.
1. List the collection. Without
limitorafterthe answer is every entry, as a plain array.curl -sS -X GET "$STUDIO_API/api/v1/projects/$PROJECT/drafts/$DRAFT/content/pages" \ -H "Authorization: Bearer $STUDIO_TOKEN"2. Create a page.
datais the page as the collection’s schema describes it: a title and its blocks.curl -sS -X POST "$STUDIO_API/api/v1/projects/$PROJECT/drafts/$DRAFT/content/pages" \ -H "Authorization: Bearer $STUDIO_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"slug":"'"$SLUG"'","data":{"title":"News","description":"What is new.","blocks":[]}}'3. Read it back, with its
ETag. Keep the answer asDOC. Keep the ETag header asETAG.curl -sS -X GET "$STUDIO_API/api/v1/projects/$PROJECT/drafts/$DRAFT/content/pages/$SLUG" \ -H "Authorization: Bearer $STUDIO_TOKEN"4. Change it. The whole document is sent;
If-Matchmakes sure nobody saved it in between.curl -sS -X PUT "$STUDIO_API/api/v1/projects/$PROJECT/drafts/$DRAFT/content/pages/$SLUG" \ -H "Authorization: Bearer $STUDIO_TOKEN" \ -H "If-Match: $ETAG" \ -H 'Content-Type: application/json' \ -d "$DOC" # the document you read, with your change
Check the draft against the project’s schemas with GET …/drafts/{draft}/validate before publishing it: a document that fits the envelope but not a block’s schema fails the build.
The same as one Node script
// Add or change a content entry. Node 18 or later, no packages.
// STUDIO_API=https://api.example STUDIO_TOKEN=wct_… PROJECT=… DRAFT=… SLUG=… node content-entry.mjs
const { STUDIO_API: api, STUDIO_TOKEN: token, PROJECT: project, DRAFT: draft, SLUG: slug } = process.env;
const headers = { authorization: `Bearer ${token}` };
const vars = { project, draft, slug };
async function call(method, path, { body, ifMatch } = {}) {
const res = await fetch(api + path, {
method,
headers: {
...headers,
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...(ifMatch ? { 'if-match': ifMatch } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const text = await res.text();
const json = text ? JSON.parse(text) : null;
return { status: res.status, etag: res.headers.get('etag'), json };
}
function expect(answer, statuses) {
if (statuses.includes(answer.status)) return answer;
throw new Error(`${answer.status}: ${answer.json?.detail ?? JSON.stringify(answer.json)}`);
}
const pause = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// 1. List the collection. Without limit or after the answer is every entry, as a plain array.
{
const answer = expect(await call('GET', `/api/v1/projects/${encodeURI(vars.project)}/drafts/${encodeURI(vars.draft)}/content/pages`), [200]);
console.log(`GET /api/v1/projects/{project}/drafts/{draft}/content/pages: ${answer.status}`);
}
// 2. Create a page. data is the page as the collection’s schema describes it: a title and its blocks.
{
const answer = expect(await call('POST', `/api/v1/projects/${encodeURI(vars.project)}/drafts/${encodeURI(vars.draft)}/content/pages`, { body: { slug: vars.slug, data: { title: "News", description: "What is new.", blocks: [] } } }), [201]);
console.log(`POST /api/v1/projects/{project}/drafts/{draft}/content/pages: ${answer.status}`);
}
// 3. Read it back, with its ETag.
{
const answer = expect(await call('GET', `/api/v1/projects/${encodeURI(vars.project)}/drafts/${encodeURI(vars.draft)}/content/pages/${encodeURI(vars.slug)}`), [200]);
vars.doc = answer.json;
vars.etag = answer.etag;
console.log(`GET /api/v1/projects/{project}/drafts/{draft}/content/pages/{slug}: ${answer.status}`);
}
// 4. Change it. The whole document is sent; If-Match makes sure nobody saved it in between.
{
const answer = expect(await call('PUT', `/api/v1/projects/${encodeURI(vars.project)}/drafts/${encodeURI(vars.draft)}/content/pages/${encodeURI(vars.slug)}`, { body: { ...vars.doc, ...{ title: "Latest news" } }, ifMatch: vars.etag }), [200]);
console.log(`PUT /api/v1/projects/{project}/drafts/{draft}/content/pages/{slug}: ${answer.status}`);
}
Invite a member to work on a project
Invite somebody into a team as an editor of one project; they get an email with a link that works for 14 days.
You need
TEAM: The team’s slug.PROJECT: The project they will work on.EMAIL: Their email address.
1. Send the invitation.
curl -sS -X POST "$STUDIO_API/api/v1/accounts/$TEAM/invitations" \ -H "Authorization: Bearer $STUDIO_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"email":"'"$EMAIL"'","role":"editor","projects":[{"id":"'"$PROJECT"'"}]}'2. See it among the team’s pending invitations.
curl -sS -X GET "$STUDIO_API/api/v1/accounts/$TEAM/members" \ -H "Authorization: Bearer $STUDIO_TOKEN"
Inviting needs the team-members permission, which owners and admins hold.
The same as one Node script
// Invite a member to work on a project. Node 18 or later, no packages.
// STUDIO_API=https://api.example STUDIO_TOKEN=wct_… TEAM=… PROJECT=… EMAIL=… node invite-member.mjs
const { STUDIO_API: api, STUDIO_TOKEN: token, TEAM: team, PROJECT: project, EMAIL: email } = process.env;
const headers = { authorization: `Bearer ${token}` };
const vars = { team, project, email };
async function call(method, path, { body, ifMatch } = {}) {
const res = await fetch(api + path, {
method,
headers: {
...headers,
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...(ifMatch ? { 'if-match': ifMatch } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const text = await res.text();
const json = text ? JSON.parse(text) : null;
return { status: res.status, etag: res.headers.get('etag'), json };
}
function expect(answer, statuses) {
if (statuses.includes(answer.status)) return answer;
throw new Error(`${answer.status}: ${answer.json?.detail ?? JSON.stringify(answer.json)}`);
}
const pause = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// 1. Send the invitation.
{
const answer = expect(await call('POST', `/api/v1/accounts/${encodeURI(vars.team)}/invitations`, { body: { email: vars.email, role: "editor", projects: [{ id: vars.project }] } }), [201]);
console.log(`POST /api/v1/accounts/{team}/invitations: ${answer.status}`);
}
// 2. See it among the team’s pending invitations.
{
const answer = expect(await call('GET', `/api/v1/accounts/${encodeURI(vars.team)}/members`), [200]);
console.log(`GET /api/v1/accounts/{team}/members: ${answer.status}`);
}