> ## Documentation Index
> Fetch the complete documentation index at: https://developers.semji.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sync drafts to your CMS

> Poll Semji for drafts that are ready to be published, push them to your CMS, then mark them as published in Semji once they go live. Includes a push-based webhook alternative.

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.

<Note>
  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](#push-based-alternative-with-webhooks) below.
</Note>

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

| Stage                                   | Semji status                | Set by                                    | Purpose                                 |
| --------------------------------------- | --------------------------- | ----------------------------------------- | --------------------------------------- |
| Editorially approved, ready to push     | `Ready to publish` (custom) | Editor, in the app                        | Signals the worker to pick it up        |
| Pushed to the CMS, awaiting publication | `Sent to CMS` (custom)      | Worker, right after the CMS accepts it    | Prevents re-pushing on the next poll    |
| Live on the CMS                         | `published` (system)        | Worker, via `POST /contents/{id}/publish` | Records the final URL and `publishedAt` |

Skipping the intermediate status causes one of two bugs:

* **Marking as published right after the push** — `publishedAt` 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](/api-reference/authentication).
* The **workspace ID** you want to sync from. Get it from [`GET /v1/workspaces`](/api-reference/workspaces/list-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:

<CodeGroup>
  ```typescript TypeScript theme={null}
  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");
  ```

  ```python Python theme={null}
  import os, requests

  WORKSPACE_ID = os.environ["WORKSPACE_ID"]
  headers = {"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}"}

  statuses = requests.get(
      f"https://api.semji.com/v1/workspaces/{WORKSPACE_ID}/content-statuses",
      headers=headers,
  ).json()["data"]

  by_label = {s["label"].lower(): s["id"] for s in statuses}
  READY_ID = by_label["ready to publish"]
  SENT_ID  = by_label["sent to cms"]
  ```

  ```bash cURL theme={null}
  curl https://api.semji.com/v1/workspaces/$WORKSPACE_ID/content-statuses \
    -H "Authorization: Bearer $SEMJI_API_KEY"
  ```
</CodeGroup>

A successful response looks like this:

```json title="200 OK" theme={null}
{
  "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.

<Tip>
  See [List content statuses](/api-reference/workspaces/list-content-statuses) for the full schema.
</Tip>

## 2. Poll for drafts ready to publish

Call [`GET /v1/workspaces/{workspaceId}/contents`](/api-reference/contents/list-contents-in-a-workspace) filtered on the *Ready to publish* status:

<CodeGroup>
  ```typescript TypeScript theme={null}
  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();
  ```

  ```python Python theme={null}
  response = requests.get(
      f"https://api.semji.com/v1/workspaces/{WORKSPACE_ID}/contents",
      headers=headers,
      params={"contentStatusId[]": READY_ID, "sort": "-updatedAt", "limit": 100},
  ).json()
  drafts, pagination = response["data"], response["pagination"]
  ```

  ```bash cURL theme={null}
  curl "https://api.semji.com/v1/workspaces/$WORKSPACE_ID/contents?contentStatusId[]=$READY_ID&sort=-updatedAt&limit=100" \
    -H "Authorization: Bearer $SEMJI_API_KEY"
  ```
</CodeGroup>

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.

<Note>
  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](/api-reference/rate-limits).
</Note>

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

For each draft, fetch the full body with [`GET /v1/contents/{id}`](/api-reference/contents/get-content-details), push it to your CMS, then **immediately** transition the draft to *Sent to CMS* with [`PUT /v1/contents/{id}`](/api-reference/contents/update-a-content).

<CodeGroup>
  ```typescript TypeScript theme={null}
  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}`);
  ```

  ```python Python theme={null}
  content = requests.get(
      f"https://api.semji.com/v1/contents/{draft['id']}",
      headers=headers,
  ).json()

  # Push title, htmlSanitized, metaDescription to the CMS (raises on failure)
  cms_record_id = push_to_cms(content)

  # Mark as handed over so the next poll skips it
  requests.put(
      f"https://api.semji.com/v1/contents/{content['id']}",
      headers=headers,
      json={"contentStatusId": SENT_ID, "version": content["version"]},
  ).raise_for_status()
  ```

  ```bash cURL theme={null}
  # Fetch the body
  CONTENT=$(curl -s https://api.semji.com/v1/contents/$CONTENT_ID \
    -H "Authorization: Bearer $SEMJI_API_KEY")
  VERSION=$(echo "$CONTENT" | jq -r .version)

  # (… push title, htmlSanitized, metaDescription to your CMS …)

  # Transition to "Sent to CMS"
  curl -X PUT https://api.semji.com/v1/contents/$CONTENT_ID \
    -H "Authorization: Bearer $SEMJI_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"contentStatusId\": \"$SENT_ID\", \"version\": $VERSION}"
  ```
</CodeGroup>

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.

```json title="GET /v1/contents/{id} (excerpt)" theme={null}
{
  "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`](/api-reference/contents/mark-a-content-as-published).

<CodeGroup>
  ```typescript TypeScript theme={null}
  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}`);
  ```

  ```python Python theme={null}
  requests.post(
      f"https://api.semji.com/v1/contents/{content_id}/publish",
      headers=headers,
      json={"url": cms_public_url},
  ).raise_for_status()
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.semji.com/v1/contents/$CONTENT_ID/publish \
    -H "Authorization: Bearer $SEMJI_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"url": "https://www.example.com/blog/choose-a-crm"}'
  ```
</CodeGroup>

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.

<Warning>
  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.
</Warning>

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:

<CodeGroup>
  ```typescript sync-drafts.ts expandable theme={null}
  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
  }
  ```

  ```python sync_drafts.py expandable theme={null}
  import os
  import time
  import requests

  API = "https://api.semji.com/v1"
  HEADERS = {"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}"}
  WORKSPACE_ID = os.environ["WORKSPACE_ID"]

  # --- status lookup -----------------------------------------------------------

  def load_status_ids():
      statuses = requests.get(
          f"{API}/workspaces/{WORKSPACE_ID}/content-statuses", headers=HEADERS,
      ).json()["data"]
      by_label = {s["label"].lower(): s["id"] for s in statuses}
      return by_label["ready to publish"], by_label["sent to cms"]

  # --- helpers -----------------------------------------------------------------

  def list_by_status(status_id):
      drafts, page = [], 1
      while True:
          response = requests.get(
              f"{API}/workspaces/{WORKSPACE_ID}/contents",
              headers=HEADERS,
              params={
                  "contentStatusId[]": status_id,
                  "sort": "-updatedAt",
                  "limit": 100,
                  "page": page,
              },
          ).json()
          drafts += response["data"]
          if not response["pagination"]["hasMore"]:
              return drafts
          page += 1

  def transition_to(content_id, status_id):
      for attempt in range(3):
          content = requests.get(f"{API}/contents/{content_id}", headers=HEADERS).json()
          response = requests.put(
              f"{API}/contents/{content_id}",
              headers=HEADERS,
              json={"contentStatusId": status_id, "version": content["version"]},
          )
          if response.status_code != 409:
              response.raise_for_status()
              return
          # 409: someone saved the draft between our GET and PUT — refetch and retry
      raise RuntimeError(f"version conflict persisted for {content_id}")

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

  def push_ready_drafts(ready_id, sent_id):
      for draft in list_by_status(ready_id):
          content = requests.get(f"{API}/contents/{draft['id']}", headers=HEADERS).json()
          try:
              cms_record_id = push_to_cms(content)  # push htmlSanitized, not html
          except Exception as e:
              print(f"CMS push failed for {draft['id']}: {e}")
              continue  # stays in "ready to publish" — retried next tick
          remember_cms_record(content["id"], cms_record_id)  # your storage
          transition_to(content["id"], sent_id)

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

  def finalize_published(sent_id):
      for draft in list_by_status(sent_id):
          cms_record_id = lookup_cms_record(draft["id"])  # your storage
          cms_state = fetch_cms_state(cms_record_id)      # your CMS client
          if not cms_state["is_public"]:
              continue
          requests.post(
              f"{API}/contents/{draft['id']}/publish",
              headers=HEADERS,
              json={"url": cms_state["public_url"]},
          ).raise_for_status()

  # --- main loop ---------------------------------------------------------------

  def run_once():
      ready_id, sent_id = load_status_ids()
      push_ready_drafts(ready_id, sent_id)
      finalize_published(sent_id)

  if __name__ == "__main__":
      while True:
          run_once()
          time.sleep(600)  # 10 minutes
  ```
</CodeGroup>

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 publication | Your CMS                           | Semji (*Mark as published*)    |
| Publication URL                 | Discovered after the CMS publishes | Already known (existing pages) |
| Latency                         | Up to one polling interval         | Seconds                        |
| Custom statuses needed          | Two                                | None                           |
| You host                        | A scheduled worker                 | A 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](https://help.semji.com/en/en/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:

```json title="content_published webhook payload (excerpt)" theme={null}
{
  "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}`](/api-reference/contents/get-content-details).
* Delivery is a **single POST** — no signature, no retry on failure. Respond with a 2xx immediately and process asynchronously.

<Warning>
  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.
</Warning>

### A minimal webhook handler

<CodeGroup>
  ```typescript webhook-handler.ts expandable theme={null}
  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);
  ```

  ```python webhook_handler.py expandable theme={null}
  import os
  import threading

  import requests
  from flask import Flask, request

  API = "https://api.semji.com/v1"
  HEADERS = {"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}"}

  app = Flask(__name__)

  @app.post("/webhooks/semji")
  def semji_webhook():
      event = request.get_json(silent=True) or {}
      if event.get("event_type") == "content_published":
          threading.Thread(
              target=handle_content_published, args=(event["data"]["id"],)
          ).start()
      return "", 204  # ack immediately — Semji does not retry failed deliveries

  def handle_content_published(content_id):
      # Re-fetch with your API key — the webhook payload itself is not signed
      response = requests.get(f"{API}/contents/{content_id}", headers=HEADERS)
      if not response.ok:
          return
      content = response.json()
      if not content["publishedAt"]:
          return
      upsert_cms_article(  # your CMS client — create or update by URL (idempotent)
          title=content["title"],
          html=content["htmlSanitized"],
          meta_description=content["metaDescription"],
          url=content["page"]["url"],
      )
  ```
</CodeGroup>

<Tip>
  Since deliveries are not retried, add a safety net: periodically list recently published contents with [`GET /v1/workspaces/{workspaceId}/contents?publishedAt[after]=…`](/api-reference/contents/list-contents-in-a-workspace) and reconcile any article your endpoint missed while it was down.
</Tip>

## Reference

* [List content statuses](/api-reference/workspaces/list-content-statuses)
* [List contents in a workspace](/api-reference/contents/list-contents-in-a-workspace)
* [Get content details](/api-reference/contents/get-content-details)
* [Update a content](/api-reference/contents/update-a-content)
* [Mark a content as published](/api-reference/contents/mark-a-content-as-published)
* [Rate limits](/api-reference/rate-limits)
* [Automate your content publishing with webhooks (Help Center)](https://help.semji.com/en/en/automate-your-content-publishing-with-webhooks)
