Skip to main content
This guide shows you how to build a one-way sync from Semji to your CMS (WordPress, Contentful, Webflow, a custom backend, etc.). The flow runs on a schedule — for example every 10 minutes — and uses four endpoints:
  1. List drafts with a custom workflow status (e.g. Ready to publish).
  2. Fetch the full content (title, sanitized HTML, meta description) for each draft.
  3. Push to the CMS and immediately transition the draft to a second custom status (e.g. Sent to CMS).
  4. Mark each content as published in Semji once the CMS confirms the article is live.
This main flow is pull-based: your worker polls Semji because no webhook fires on custom workflow status changes. Semji does fire a webhook when a content is marked as published — if your editors manage publication from Semji, see the push-based alternative below.

Why two custom statuses?

Between “the editor approved the draft” and “the article is live on the public site”, the article can sit in the CMS for hours or days — review, scheduled publication, legal moderation… You need a status that means “already handed over to the CMS, don’t push it again” while you wait.
StageSemji statusSet byPurpose
Editorially approved, ready to pushReady to publish (custom)Editor, in the appSignals the worker to pick it up
Pushed to the CMS, awaiting publicationSent to CMS (custom)Worker, right after the CMS accepts itPrevents re-pushing on the next poll
Live on the CMSpublished (system)Worker, via POST /contents/{id}/publishRecords the final URL and publishedAt
Skipping the intermediate status causes one of two bugs:
  • Marking as published right after the pushpublishedAt is wrong (the CMS may schedule for next week) and the URL may still change (slug under review).
  • Leaving the draft in Ready to publish — the next poll re-pushes the same article, creating duplicates in the CMS.

Prerequisites

  • An API key with access to the workspace. See Authentication.
  • The workspace ID you want to sync from. Get it from GET /v1/workspaces.
  • Two custom workflow statuses: Ready to publish and Sent to CMS.

1. Create the two custom statuses

Workflow statuses are configured per workspace in the Semji app under Settings > Workflow. Create two non-system statuses:
  • Ready to publish — editors move drafts here when they’re approved.
  • Sent to CMS — the worker moves drafts here right after the CMS accepts them.
List every status (with its ID) via the API:
const API = "https://api.semji.com/v1";
const HEADERS = { Authorization: `Bearer ${process.env.SEMJI_API_KEY}` };
const WORKSPACE_ID = process.env.WORKSPACE_ID;

const response = await fetch(
  `${API}/workspaces/${WORKSPACE_ID}/content-statuses`,
  { headers: HEADERS },
);
const { data: statuses } = await response.json();

const byLabel = new Map(
  statuses.map((status) => [status.label.toLowerCase(), status.id]),
);
const READY_ID = byLabel.get("ready to publish");
const SENT_ID = byLabel.get("sent to cms");
A successful response looks like this:
200 OK
{
  "data": [
    { "id": "b3d51e92c804", "label": "to do",            "isReadOnly": true,  "position": 1, "color": "#9ca3af" },
    { "id": "29f7c6a1d05e", "label": "in progress",      "isReadOnly": false, "position": 2, "color": "#3498db" },
    { "id": "8a1c4e6f02b9", "label": "ready to publish", "isReadOnly": false, "position": 3, "color": "#f59e0b" },
    { "id": "d6e90b37a512", "label": "sent to cms",      "isReadOnly": false, "position": 4, "color": "#8b5cf6" },
    { "id": "41f8a2c5e7d3", "label": "published",        "isReadOnly": true,  "position": 5, "color": "#10b981" }
  ]
}
System statuses (to do, published) have isReadOnly: true and can’t be renamed or deleted. Cache both custom IDs — you’ll use them in the next steps.
See List content statuses for the full schema.

2. Poll for drafts ready to publish

Call GET /v1/workspaces/{workspaceId}/contents filtered on the Ready to publish status:
const query = new URLSearchParams({
  "contentStatusId[]": READY_ID,
  sort: "-updatedAt",
  limit: "100",
});
const { data: drafts, pagination } = await (
  await fetch(`${API}/workspaces/${WORKSPACE_ID}/contents?${query}`, {
    headers: HEADERS,
  })
).json();
The collection endpoint returns lightweight content records (no HTML body). You fetch the body in the next step. The response is paginated: limit caps at 100 (default 25), and the envelope carries a pagination object (total, page, limit, hasMore). If hasMore is true, request the next page with ?page=2, and so on — the full worker below does this. Don’t assume one page is enough: a backlog (worker downtime, a bulk editorial approval) can easily exceed 100 drafts.
Each API key is limited to 1,000 requests per hour with a burst of 20/s. Polling every 10 minutes (= 6/hour) leaves plenty of headroom even for hundreds of drafts per poll. See Rate limits.

3. Push to the CMS and transition to Sent to CMS

For each draft, fetch the full body with GET /v1/contents/{id}, push it to your CMS, then immediately transition the draft to Sent to CMS with PUT /v1/contents/{id}.
const content = await (
  await fetch(`${API}/contents/${draft.id}`, { headers: HEADERS })
).json();

// Push title, htmlSanitized, metaDescription to the CMS (throws on failure)
const cmsRecordId = await pushToCms(content);

// Mark as handed over so the next poll skips it
const update = await fetch(`${API}/contents/${content.id}`, {
  method: "PUT",
  headers: { ...HEADERS, "Content-Type": "application/json" },
  body: JSON.stringify({ contentStatusId: SENT_ID, version: content.version }),
});
if (!update.ok) throw new Error(`PUT failed: ${update.status}`);
A few important details:
  • Push htmlSanitized, not html. The html field is the body as authored in the Semji editor and may contain editor-only markers (comments, fact-check annotations). htmlSanitized is the same body with all annotations stripped — that’s the one that is safe to publish externally.
  • version is required on every update, even when you only change the status. Reuse the version from your GET. If anything modified the content between your GET and your PUT (an editor saving, for instance), the API returns 409 Conflict. A 409 doesn’t mean your transition is invalid — refetch to get the fresh version and retry the same update.
  • Transition only on CMS success. If the CMS call fails, leave the draft in Ready to publish and the next poll will retry it.
  • Persist the CMS record ID (e.g. WordPress post ID) on your side, keyed by Semji content.id. You’ll need it in step 4 to know when the article goes live.
GET /v1/contents/{id} (excerpt)
{
  "id": "7c4a1f08b29d",
  "title": "How to choose a CRM in 2026",
  "html": "<h1>How to choose a CRM in 2026</h1><p>…</p><!-- semji editor annotations -->",
  "htmlSanitized": "<h1>How to choose a CRM in 2026</h1><p>…</p>",
  "metaDescription": "A practical guide to picking the right CRM…",
  "page": { "id": "5e8d203c7f1a", "url": "https://example.com/blog/choose-a-crm" },
  "contentStatus": { "id": "8a1c4e6f02b9", "label": "ready to publish" },
  "version": 7
}

4. Mark as published when the CMS goes live

The CMS publication can happen seconds or days after the push. You need a separate trigger that fires when the article actually goes live. Pick the option that fits your CMS:
  • CMS webhook (recommended when available) — WordPress, Contentful, Webflow, and most modern CMSes can fire a webhook on publish. Wire it to a small endpoint that looks up the Semji content.id from the CMS record ID and calls Semji.
  • Second poll loop — every 10 min, list all your Sent to CMS drafts, check each one against the CMS API to see if it’s now public, and mark the ones that are.
In both cases, the call is the same: POST /v1/contents/{id}/publish.
const publish = await fetch(`${API}/contents/${contentId}/publish`, {
  method: "POST",
  headers: { ...HEADERS, "Content-Type": "application/json" },
  body: JSON.stringify({ url: cmsPublicUrl }),
});
if (!publish.ok) throw new Error(`publish failed: ${publish.status}`);
The request body is optional:
  • url (optional) — the public URL where the article is now live. If omitted, Semji reuses the URL of the associated page. Pass it explicitly when you’re publishing a new page or when the CMS slug differs from the original page URL.
  • publishedAt (optional, ISO 8601) — the publication date recorded in Semji. If omitted, the current server time is used. Pass it when the publication is detected with a delay — e.g. your poll loop discovers an article that went live earlier — so performance tracking starts from the real publication date.
If you omit url and the associated page has no URL, the call fails with 422 unprocessable_entity. This is the typical case for contents created in Semji without a pageId — their auto-created page starts with an empty URL. When in doubt, pass url explicitly.
Once this call returns, the draft transitions from Sent to CMS to the system published status.

Putting it all together

A minimal worker that polls every 10 minutes for both transitions:
const API = "https://api.semji.com/v1";
const HEADERS = {
  Authorization: `Bearer ${process.env.SEMJI_API_KEY}`,
  "Content-Type": "application/json",
};
const WORKSPACE_ID = process.env.WORKSPACE_ID;

// Implement these against your CMS and storage:
declare function pushToCms(content: { htmlSanitized: string | null }): Promise<string>;
declare function rememberCmsRecord(contentId: string, cmsRecordId: string): Promise<void>;
declare function lookupCmsRecord(contentId: string): Promise<string>;
declare function fetchCmsState(cmsRecordId: string): Promise<{ isPublic: boolean; publicUrl: string }>;

class ApiError extends Error {
  constructor(readonly status: number, message: string) {
    super(message);
  }
}

async function api(path: string, init: RequestInit = {}) {
  const response = await fetch(`${API}${path}`, { ...init, headers: HEADERS });
  if (!response.ok) {
    throw new ApiError(response.status, `${init.method ?? "GET"} ${path}${response.status}`);
  }
  return response.json();
}

// --- status lookup -----------------------------------------------------------

async function loadStatusIds() {
  const { data: statuses } = await api(`/workspaces/${WORKSPACE_ID}/content-statuses`);
  const byLabel = new Map(statuses.map((s) => [s.label.toLowerCase(), s.id]));
  return { readyId: byLabel.get("ready to publish"), sentId: byLabel.get("sent to cms") };
}

// --- helpers -----------------------------------------------------------------

async function listByStatus(statusId: string) {
  const drafts = [];
  for (let page = 1; ; page++) {
    const query = new URLSearchParams({
      "contentStatusId[]": statusId,
      sort: "-updatedAt",
      limit: "100",
      page: String(page),
    });
    const { data, pagination } = await api(`/workspaces/${WORKSPACE_ID}/contents?${query}`);
    drafts.push(...data);
    if (!pagination.hasMore) return drafts;
  }
}

async function transitionTo(contentId: string, statusId: string) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    const { version } = await api(`/contents/${contentId}`);
    try {
      await api(`/contents/${contentId}`, {
        method: "PUT",
        body: JSON.stringify({ contentStatusId: statusId, version }),
      });
      return;
    } catch (error) {
      // 409: someone saved the draft between our GET and PUT — refetch and retry
      if (!(error instanceof ApiError) || error.status !== 409 || attempt === 3) throw error;
    }
  }
}

// --- step 2 & 3: ready to publish → sent to CMS ------------------------------

async function pushReadyDrafts(readyId: string, sentId: string) {
  for (const draft of await listByStatus(readyId)) {
    const content = await api(`/contents/${draft.id}`);
    let cmsRecordId: string;
    try {
      cmsRecordId = await pushToCms(content); // push htmlSanitized, not html
    } catch (error) {
      console.error(`CMS push failed for ${draft.id}:`, error);
      continue; // stays in "ready to publish" — retried next tick
    }
    await rememberCmsRecord(content.id, cmsRecordId);
    await transitionTo(content.id, sentId);
  }
}

// --- step 4: sent to CMS → published -----------------------------------------

async function finalizePublished(sentId: string) {
  for (const draft of await listByStatus(sentId)) {
    const cmsRecordId = await lookupCmsRecord(draft.id);
    const cmsState = await fetchCmsState(cmsRecordId);
    if (!cmsState.isPublic) continue;
    await api(`/contents/${draft.id}/publish`, {
      method: "POST",
      body: JSON.stringify({ url: cmsState.publicUrl }),
    });
  }
}

// --- main loop ---------------------------------------------------------------

while (true) {
  const { readyId, sentId } = await loadStatusIds();
  await pushReadyDrafts(readyId, sentId);
  await finalizePublished(sentId);
  await new Promise((resolve) => setTimeout(resolve, 600_000)); // 10 minutes
}
If your CMS supports webhooks, replace finalizePublished with a webhook handler that calls POST /v1/contents/{id}/publish directly when it receives a “post published” event.

Push-based alternative with webhooks

The main flow assumes your CMS decides when an article goes live. Sometimes it’s the other way around: your pages already exist in Semji with their URLs (you optimize existing content, or you create the page in the CMS first), and your editors want to drive publication from Semji by clicking Mark as published. In that scenario you don’t need polling or custom statuses at all — Semji notifies you.
Pull (polling worker)Push (webhook)
Source of truth for publicationYour CMSSemji (Mark as published)
Publication URLDiscovered after the CMS publishesAlready known (existing pages)
LatencyUp to one polling intervalSeconds
Custom statuses neededTwoNone
You hostA scheduled workerA public HTTPS endpoint

How it works

  1. In the Semji app, go to your workspace Settings > General and click Add a Webhook — paste the URL of your endpoint. See Automate your content publishing with webhooks in the Help Center, which also provides ready-made Make and N8n templates for WordPress if you’d rather not write code.
  2. The editor finishes the draft and clicks Mark as published, filling in the publication URL and date (backdating works both in the app and via the API’s publishedAt field).
  3. Semji sends a POST request to your endpoint with a content_published event.
  4. Your endpoint creates or updates the article in the CMS.
The payload looks like this:
content_published webhook payload (excerpt)
{
  "event_type": "content_published",
  "occurred_at": "2026-06-10T14:32:05+00:00",
  "data": {
    "id": "7c4a1f08b29d",
    "title": "How to choose a CRM in 2026",
    "html": "<h1>How to choose a CRM in 2026</h1><p>…</p>",
    "meta_description": "A practical guide to picking the right CRM…",
    "content_score": 82,
    "words_count": 1450,
    "published_at": "2026-06-10T14:32:04+00:00",
    "published_by": { "id": "f2a81c05d943", "first_name": "Jane", "last_name": "Doe", "email": "jane@example.com" },
    "content_status": { "id": "41f8a2c5e7d3", "label": "published", "color": "#10b981" },
    "page": {
      "id": "5e8d203c7f1a",
      "url": "https://example.com/blog/choose-a-crm",
      "is_existing_content": true,
      "last_status_code": 200
    },
    "page_focus_keyword": { "keyword": "best crm for small business", "search_volume": 5400, "position": 8 },
    "workspace": { "id": "89b0f07aade2", "name": "Acme Blog", "website_url": "https://example.com" },
    "organization": { "id": "c61d3e84f207", "name": "Acme" }
  }
}
Three things to know about the payload:
  • data.html is already sanitized — editor annotations are stripped, it’s equivalent to the htmlSanitized field of the API. You can push it to your CMS as-is.
  • data.id is the content ID, directly usable with GET /v1/contents/{id}.
  • Delivery is a single POST — no signature, no retry on failure. Respond with a 2xx immediately and process asynchronously.
Because the webhook is not signed, don’t trust the payload blindly: anyone who discovers your endpoint URL could forge it. Treat the webhook as a trigger, and re-fetch the content by ID with your API key before touching the CMS — if the content doesn’t exist or isn’t published, drop the event.

A minimal webhook handler

import express from "express";

const API = "https://api.semji.com/v1";
const HEADERS = { Authorization: `Bearer ${process.env.SEMJI_API_KEY}` };

// Implement against your CMS — create or update by URL (idempotent)
declare function upsertCmsArticle(article: {
  title: string | null;
  html: string | null;
  metaDescription: string | null;
  url: string;
}): Promise<void>;

const app = express();
app.use(express.json());

app.post("/webhooks/semji", (req, res) => {
  res.sendStatus(204); // ack immediately — Semji does not retry failed deliveries
  if (req.body?.event_type !== "content_published") return;
  handleContentPublished(req.body.data.id).catch(console.error);
});

async function handleContentPublished(contentId: string) {
  // Re-fetch with your API key — the webhook payload itself is not signed
  const response = await fetch(`${API}/contents/${contentId}`, { headers: HEADERS });
  if (!response.ok) return;
  const content = await response.json();
  if (!content.publishedAt) return;

  await upsertCmsArticle({
    title: content.title,
    html: content.htmlSanitized,
    metaDescription: content.metaDescription,
    url: content.page.url,
  });
}

app.listen(3000);
Since deliveries are not retried, add a safety net: periodically list recently published contents with GET /v1/workspaces/{workspaceId}/contents?publishedAt[after]=… and reconcile any article your endpoint missed while it was down.

Reference