Skip to main content
Webentor Studio

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 the pages collection, by slug, such as en/about.
  • TEXT: The page’s new description. A draft with nothing changed is refused, not published.
  1. 1. Open a draft. Its id is the {draft} of the steps after it. Keep id as DRAFT.

    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. 2. Read the page as it is in the draft, and keep its ETag. Keep the answer as DOC. Keep the ETag header as ETAG.

    curl -sS -X GET "$STUDIO_API/api/v1/projects/$PROJECT/drafts/$DRAFT/content/pages/$PAGE" \
      -H "Authorization: Bearer $STUDIO_TOKEN"
  3. 3. Write it back changed. If-Match refuses 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 change
  4. 4. Publish the draft. It is merged into the live branch, and a production build is queued. Keep build_id as BUILD.

    curl -sS -X POST "$STUDIO_API/api/v1/projects/$PROJECT/drafts/$DRAFT/publish" \
      -H "Authorization: Bearer $STUDIO_TOKEN"
  5. 5. Wait for the build. The live site changes when it has succeeded. Repeat every 10 seconds until status is one of succeeded, 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. 1. Queue a production build of the live branch. Keep build_id as BUILD.

    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. 2. Wait until it has finished. failure says what went wrong when it failed. Repeat every 10 seconds until status is one of succeeded, 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 as en/news.
  1. 1. List the collection. Without limit or after the 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. 2. Create a page. data is 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. 3. Read it back, with its ETag. Keep the answer as DOC. Keep the ETag header as ETAG.

    curl -sS -X GET "$STUDIO_API/api/v1/projects/$PROJECT/drafts/$DRAFT/content/pages/$SLUG" \
      -H "Authorization: Bearer $STUDIO_TOKEN"
  4. 4. Change it. The whole document is sent; If-Match makes 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. 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. 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}`);
}