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. 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:
A successful response looks like this:
200 OK
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:
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}.
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)

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.
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:
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.

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)
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

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