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

# Send to CMS webhook

> The content_staged webhook contract: how Semji pushes a draft to your CMS endpoint before publication, and how your endpoint responds — synchronously or later through the tokenised callback.

When an editor clicks **Send to CMS** in the Semji editor, Semji sends the draft
to your endpoint through a `content_staged` webhook. Your endpoint decides where
the content lands in your CMS and returns a review link. This page is the
**exchange contract** — everything you need to implement the receiving side
without access to Semji's code.

<Note>
  `content_staged` is separate from `content_published` (the event that fires
  when a content is *marked as published*). A `content_staged` send happens
  **before** publication: the content stays a draft in Semji, and your CMS
  holds it for review. The two can be configured independently on the same
  workspace.
</Note>

## Prerequisites

* An HTTPS endpoint, reachable from the public internet, that accepts a `POST`
  with a JSON body and replies in JSON.
* A `content_staged` integration configured for your workspace (name + endpoint
  URL) by a **workspace owner** under **Settings**, and enabled.
* The ability to call back to `api.semji.com` if you answer asynchronously.

## How it works

<Steps>
  <Step title="Semji POSTs the draft to your endpoint">
    A JSON payload carrying the content, an `idempotency_key`, and a `callback`
    block is sent to the URL you configured for the `content_staged` event.
  </Step>

  <Step title="Your endpoint answers">
    Either **synchronously** — return the result in the HTTP response
    (`completed` or `failed`) — or, if you need more time, return `accepted`
    and finish later through the callback.
  </Step>

  <Step title="(async only) You POST the result to the callback URL">
    When the work is done, `POST` the outcome to the `callback.url` from the
    payload, authenticated with the `callback.token`.
  </Step>
</Steps>

## The request Semji sends

Semji issues a single `POST` (with `Content-Type: application/json`) to your
configured URL. The request times out after **10 seconds** — see
[responding](#responding-to-the-request).

```json title="Outbound payload" theme={null}
{
  "event_type": "content_staged",
  "occurred_at": "2026-07-27T09:25:36+00:00",
  "idempotency_key": "7c4a1f08b29d",
  "content_version": 1,
  "data": {
    "title": "How to brew better espresso",
    "meta_description": "A practical guide to dialing in your espresso at home.",
    "html": "<html><body><p>…sanitized content…</p></body></html>",
    "text": "…plain text version…",
    "content_score": 0.82,
    "words_count": 640,
    "page": {
      "url": "https://your-site.example.com/blog/better-espresso",
      "is_existing_content": false
    },
    "workspace": {
      "id": "bc94dd0702c5",
      "name": "Acme",
      "website_url": "https://your-site.example.com"
    },
    "organization": { "id": "84b83bbca1c2", "name": "Acme Inc." }
  },
  "callback": {
    "url": "https://api.semji.com/webhooks/calls/7c4a1f08b29d/callback",
    "token": "cbk_9f2c…",
    "expires_at": "2026-07-28T09:25:36+00:00"
  }
}
```

<ResponseField name="event_type" type="string">
  Always `content_staged` for this webhook.
</ResponseField>

<ResponseField name="idempotency_key" type="string">
  Stable 12-char identifier of this send. If you receive the same key twice (a
  retry), do not create a second draft — return the result you already produced.
</ResponseField>

<ResponseField name="content_version" type="integer">
  Version of the content at send time. A later send of the same content carries a
  higher version — the latest send wins.
</ResponseField>

<ResponseField name="data" type="object">
  The content itself: `title`, `meta_description`, sanitized `html`, plain `text`,
  `content_score`, `words_count`, plus `page`, `workspace` and `organization`
  context. IDs are 12-char public IDs.
</ResponseField>

<ResponseField name="callback" type="object">
  `url` to POST the async result to, a one-shot `token` (see
  [the callback](#the-async-callback)), and `expires_at` — the callback is
  rejected after this time (24 hours after the send).
</ResponseField>

## Responding to the request

Reply with **HTTP 2xx** as soon as you have received and understood the webhook,
and describe the outcome in a JSON body. The HTTP status reflects transport, the
`status` field reflects the business outcome — a non-2xx response is read as your
endpoint being unreachable, not as a business failure. There is **one result
format** everywhere (sync response, callback, and the integration test):

<ResponseField name="status" type="string" required>
  `completed`, `failed`, or `accepted`.
</ResponseField>

<ResponseField name="result" type="object">
  Required when `status` is `completed`. Must contain `cms_id` **and at least one**
  of `preview_url` / `back_office_url`.

  <Expandable title="result">
    <ResponseField name="cms_id" type="string" required>
      Your CMS's identifier for the created/updated entry.
    </ResponseField>

    <ResponseField name="preview_url" type="string">
      Public preview link an editor can open to review the content.
    </ResponseField>

    <ResponseField name="back_office_url" type="string">
      Link to the entry in your CMS admin.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="error" type="string">
  Optional human-readable reason when `status` is `failed`.
</ResponseField>

### Synchronous — you finish within 10 seconds

Return the terminal result directly in the HTTP response:

```json title="200 — completed" theme={null}
{
  "status": "completed",
  "result": {
    "cms_id": "42",
    "preview_url": "https://your-site.example.com/preview/42",
    "back_office_url": "https://your-cms.example.com/admin/posts/42"
  }
}
```

```json title="200 — failed" theme={null}
{ "status": "failed", "error": "Category \"blog\" does not exist" }
```

<Warning>
  A `completed` response **without** `cms_id`, or without any URL, is a contract
  violation — Semji records the send as failed.
</Warning>

### Asynchronous — you need more time

If your pipeline can't finish within the 10-second window (moderation, scheduled
jobs, a no-code flow…), acknowledge immediately and finish later:

```json title="200 — accepted" theme={null}
{ "status": "accepted" }
```

The content stays in a *loading* state in Semji until you post the result to the
callback. If no callback arrives within **24 hours**, the send is marked failed.

## The async callback

Post the terminal result to the `callback.url` from the payload (of the form
`https://api.semji.com/webhooks/calls/{publicId}/callback`), with the
`callback.token` as a Bearer token.

<CodeGroup>
  ```typescript callback.ts theme={null}
  await fetch(callback.url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${callback.token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      status: "completed",
      result: {
        cms_id: "42",
        preview_url: "https://your-site.example.com/preview/42",
        back_office_url: "https://your-cms.example.com/admin/posts/42",
      },
    }),
  });
  ```

  ```python callback.py theme={null}
  import requests

  requests.post(
      callback["url"],
      headers={"Authorization": f"Bearer {callback['token']}"},
      json={
          "status": "completed",
          "result": {
              "cms_id": "42",
              "preview_url": "https://your-site.example.com/preview/42",
              "back_office_url": "https://your-cms.example.com/admin/posts/42",
          },
      },
  )
  ```

  ```bash callback.sh theme={null}
  curl -X POST "https://api.semji.com/webhooks/calls/7c4a1f08b29d/callback" \
    -H "Authorization: Bearer cbk_9f2c…" \
    -H "Content-Type: application/json" \
    -d '{"status":"completed","result":{"cms_id":"42","preview_url":"https://your-site.example.com/preview/42"}}'
  ```
</CodeGroup>

The body is the same result format as the sync response. Only terminal outcomes
are accepted here — `completed` or `failed`; a body with `accepted` is rejected.

### Response codes

| Code  | Meaning                                                                                                                      |
| ----- | ---------------------------------------------------------------------------------------------------------------------------- |
| `200` | Result accepted. Also returned on an idempotent replay of an already-finalized call (no further effect).                     |
| `400` | Body doesn't respect the contract (e.g. `completed` without `cms_id`, or `accepted`). The call stays open — fix and re-post. |
| `401` | Missing or invalid token.                                                                                                    |
| `404` | Unknown call.                                                                                                                |
| `410` | The callback expired (more than 24 hours after the send).                                                                    |
| `429` | Too many requests — slow down and retry.                                                                                     |

<Tip>
  **If your callback POST times out without a response, re-post it unchanged.**
  The token is single-use per outcome but replays are **idempotent**: a repeat of
  the same call returns `200` with no side effect, so you never risk a double
  handling.
</Tip>

## Idempotency

Both directions are safe to retry:

* **Semji → you**: the same `idempotency_key` means the same send. Don't create a
  second draft — return the result you already produced.
* **You → Semji**: replaying the callback for an already-finalized call returns
  `200` with no effect.

A **new send of the same content** (a re-click of *Send to CMS*) carries a new
`idempotency_key` and a higher `content_version` — the latest send wins, and the
callback of a superseded send no longer changes the content's state.

## No-code example (Make / Zapier)

1. **Webhook** module receives the `content_staged` POST. Return `{"status":"accepted"}` immediately so the scenario doesn't hit the 10-second timeout.
2. Map `data.title`, `data.html`, `data.meta_description` to your CMS "create entry" module.
3. **HTTP** module POSTs the callback: URL = `{{callback.url}}`, header `Authorization: Bearer {{callback.token}}`, body `{"status":"completed","result":{"cms_id":"{{cms_id}}","preview_url":"{{preview_url}}"}}`.

## Testing your endpoint

A workspace owner can test the integration from **Settings** before going live:
Semji sends a sample `content_staged` payload to your URL and shows the parsed
verdict (`received`, `sent`, or `error`). A `sent` verdict means your endpoint
answered `accepted` — the test flips to `received` once your callback arrives.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The content stays stuck in “loading” in Semji">
    You answered `accepted` but no callback reached Semji. Post the result to the
    `callback.url`; if nothing arrives within 24 hours the send is marked failed.
  </Accordion>

  <Accordion title="My callback returns 401">
    The `Authorization` header must be `Bearer <token>` using the exact
    `callback.token` from the payload of that send. Tokens are per-send.
  </Accordion>

  <Accordion title="My callback returns 410">
    The callback expired — more than 24 hours passed since the send. Trigger a new
    send from the editor to get a fresh callback.
  </Accordion>

  <Accordion title="My `completed` response is rejected (400)">
    A `completed` result must include `cms_id` **and** at least one of
    `preview_url` / `back_office_url`. A body with `accepted` is not valid on the
    callback — only `completed` or `failed`.
  </Accordion>
</AccordionGroup>

## Related

* [Sync drafts to your CMS](/guides/sync-drafts-to-cms) — the pull-based
  alternative and the `content_published` webhook.
* [CMS integrations overview](/integrations/overview) — connect a CMS through MCP
  or the REST API.
* [Authentication](/api-reference/authentication) — API keys for the REST API.
