# Authentication
Source: https://developers.semji.com/api-reference/authentication
Generate a Semji API key from Settings, send it as a Bearer token on every request, and handle 401 Unauthorized and 403 Forbidden errors in your integration.
Every request to the Semji API must include a valid API key in the `Authorization` header. The API uses HTTP Bearer authentication — there are no sessions, cookies, or OAuth flows. This page explains how to generate a key, attach it to requests, and handle authentication errors.
## Generating an API key
API keys are created in the Semji app. Go to [**Settings > Organization > API Keys**](https://app.semji.com) and click **Create API Key**. Give the key a descriptive name (for example, the name of the integration or tool that will use it) so you can identify and revoke it later.
Each key:
* Begins with `sk_`
* Is scoped to the **user and organization** that created it — it has the same permissions as that user
* Is shown only once at creation time; copy and store it securely before closing the dialog
Never commit API keys to source control. Store them in environment variables or a secrets manager such as AWS Secrets Manager, HashiCorp Vault, or your CI/CD platform's secret store. If a key is accidentally exposed, revoke it immediately from **Settings > Organization > API Keys** and generate a new one.
## Sending the API key
Include the key in the `Authorization` header of every request:
```
Authorization: Bearer sk_your_api_key_here
```
The header value must start with `Bearer sk_` exactly. Any other format — including a bare token without `Bearer`, or a key that doesn't start with `sk_` — will be rejected with `401 Unauthorized`.
```typescript title="TypeScript" theme={null}
const apiKey = process.env.SEMJI_API_KEY;
async function semjiGet(path: string) {
const response = await fetch(`https://api.semji.com/v1${path}`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(`${response.status}: ${error.error.message}`);
}
return response.json();
}
const user = await semjiGet("/me");
console.log(user.email);
```
```python title="Python" theme={null}
import os
import requests
api_key = os.environ["SEMJI_API_KEY"]
def semji_get(path: str) -> dict:
response = requests.get(
f"https://api.semji.com/v1{path}",
headers={"Authorization": f"Bearer {api_key}"},
)
response.raise_for_status()
return response.json()
user = semji_get("/me")
print(user["email"])
```
```bash title="cURL" theme={null}
curl https://api.semji.com/v1/me \
-H "Authorization: Bearer sk_your_api_key_here"
```
## Verifying your key
Call `GET /v1/me` to confirm your key is valid and inspect the associated user and organization:
```bash title="cURL" theme={null}
curl https://api.semji.com/v1/me \
-H "Authorization: Bearer sk_your_api_key_here"
```
```json title="200 OK" theme={null}
{
"id": "usr_01hx9z3k4m5n6p7q8r9s0t1u",
"firstName": "Alice",
"lastName": "Martin",
"email": "alice@example.com",
"profileImageUrl": null,
"jobTitle": "SEO Lead",
"languageCode": "en",
"organization": {
"id": "org_01hx9z3k4m5n6p7q8r9s0t2v",
"name": "Example Corp",
"createdAt": "2024-01-10T08:00:00Z",
"brandName": null,
"brandImageUrl": null,
"credits": {
"analysis": 47,
"aiWriting": 12,
"contentIdeasSearches": 5
},
"usersCount": 3,
"workspacesCount": 2
},
"createdAt": "2024-03-15T09:12:00Z"
}
```
## Authentication errors
### 401 Unauthorized
Returned when the `Authorization` header is missing, malformed, or contains an invalid or revoked key.
```json title="401 Unauthorized" theme={null}
{
"error": {
"code": "unauthorized",
"message": "A valid API key is required. Use Authorization: Bearer sk_xxx."
}
}
```
**What to check:**
* The header name is `Authorization` (capitalized correctly)
* The value starts with `Bearer ` (note the trailing space) followed by your full key
* The key starts with `sk_` and has not been revoked
* You're not accidentally including extra whitespace or newline characters
### 403 Forbidden
Returned when your key is valid but the authenticated user does not have permission to access the requested resource. This happens if, for example, you request a workspace that your user is not a member of.
```json title="403 Forbidden" theme={null}
{
"error": {
"code": "forbidden",
"message": "You do not have access to this resource."
}
}
```
A `403` is not a key problem — the key itself is recognized. You need to use a key belonging to a user with access to the resource, or ask a workspace admin to grant your user the required permissions.
# Get brand voice details
Source: https://developers.semji.com/api-reference/brand-voices/get-brand-voice-details
/api-reference/openapi.json get /v1/brand-voices/{id}
Returns details of a brand voice.
# List brand voices
Source: https://developers.semji.com/api-reference/brand-voices/list-brand-voices
/api-reference/openapi.json get /v1/workspaces/{id}/brand-voices
Returns all brand voices configured for a workspace. Use the brand voice ID in POST /v1/contents/:id/atomic settings.
# Cancel a generation
Source: https://developers.semji.com/api-reference/content-generations/cancel-a-generation
/api-reference/openapi.json post /v1/contents/{id}/generation/cancel
Cancels a generation that is queued, pending, or awaiting review.
# Confirm a generation
Source: https://developers.semji.com/api-reference/content-generations/confirm-a-generation
/api-reference/openapi.json post /v1/contents/{id}/generation/confirm
Confirms and accepts the generated content. The draft is updated with the generation result. Requires status = "review".
# Get generation status
Source: https://developers.semji.com/api-reference/content-generations/get-generation-status
/api-reference/openapi.json get /v1/contents/{id}/generation
Returns the current status of the Atomic Content generation attached to a content. Poll this endpoint to track progress.
# Create a content
Source: https://developers.semji.com/api-reference/contents/create-a-content
/api-reference/openapi.json post /v1/workspaces/{workspaceId}/contents
Creates a new content draft in the workspace. Pass pageId to attach the draft to an existing imported page, or omit it to create a blank draft (editorial brief with no URL) — in that case an empty page is auto-created and returned in the response.
# Delete a content
Source: https://developers.semji.com/api-reference/contents/delete-a-content
/api-reference/openapi.json delete /v1/contents/{id}
Permanently deletes a content. This action cannot be undone.
# Generate Atomic Content
Source: https://developers.semji.com/api-reference/contents/generate-atomic-content
/api-reference/openapi.json post /v1/contents/{id}/atomic
Launches an AI content generation on the draft. The keyword must have a completed SEO analysis. Poll GET /v1/contents/:id/generation to track progress.
# Get content details
Source: https://developers.semji.com/api-reference/contents/get-content-details
/api-reference/openapi.json get /v1/contents/{id}
Returns full details of a content, including HTML body, version, and embedded relations.
# List contents for a page
Source: https://developers.semji.com/api-reference/contents/list-contents-for-a-page
/api-reference/openapi.json get /v1/pages/{pageId}/contents
Returns all content versions associated with a specific page.
# List contents in a workspace
Source: https://developers.semji.com/api-reference/contents/list-contents-in-a-workspace
/api-reference/openapi.json get /v1/workspaces/{workspaceId}/contents
Returns a paginated list of contents. Supports filtering by status, assignee, folder, due date, and text search.
# Mark a content as published
Source: https://developers.semji.com/api-reference/contents/mark-a-content-as-published
/api-reference/openapi.json post /v1/contents/{id}/publish
Marks the content as published in Semji. Call this endpoint after the content has been published on your CMS. If no URL is provided, the associated page URL is used. If no publication date is provided, the current server time is used.
# Update a content
Source: https://developers.semji.com/api-reference/contents/update-a-content
/api-reference/openapi.json put /v1/contents/{id}
Updates a content. The version field is required for optimistic locking.
# List credit usages
Source: https://developers.semji.com/api-reference/credit-usages/list-credit-usages
/api-reference/openapi.json get /v1/credit-usages
Returns the credit consumption history for the organization, paginated. Each entry details a credit consumed with its context.
# Errors
Source: https://developers.semji.com/api-reference/errors
Full reference for Semji API error codes, HTTP status codes, error response format, and handling strategies including retries and validation errors.
When a request cannot be completed, the Semji API returns a JSON error object alongside an appropriate HTTP status code. All errors follow the same structure, making them straightforward to handle in code. This page documents every error code the API can return, explains what triggers each one, and provides guidance on how to respond.
## Error response format
Every error response — regardless of cause or status code — uses the following shape:
```json theme={null}
{
"error": {
"code": "string",
"message": "string"
}
}
```
A stable, machine-readable string identifying the error type. Use this field in your error-handling logic — it will not change between API versions.
A human-readable explanation of the error. For `validation_error` responses, this describes the specific field that failed validation. Useful for debugging but not guaranteed to be stable across releases.
## Error codes reference
| HTTP status | `error.code` | Meaning |
| ----------- | --------------------- | ----------------------------------------------------------- |
| 400 | `bad_request` | The request was malformed or a required field is missing |
| 401 | `unauthorized` | API key is missing, invalid, or revoked |
| 403 | `forbidden` | Authenticated but not permitted to access this resource |
| 404 | `not_found` | The resource does not exist |
| 409 | `conflict` | Request conflicts with current state, e.g. version mismatch |
| 422 | `validation_error` | Request body failed schema validation |
| 429 | `rate_limited` | Too many requests — hourly or burst limit exceeded |
| 500 | `internal_error` | Unexpected server error |
| 502 | `bad_gateway` | The upstream service returned an error |
| 503 | `service_unavailable` | Service temporarily unavailable |
***
## Detailed error reference
Returned when the request is structurally malformed — for example, a required query parameter is missing, a path parameter cannot be parsed, or the request body is not valid JSON.
```json title="Example response" theme={null}
{
"error": {
"code": "bad_request",
"message": "The request was invalid."
}
}
```
**How to handle:** Inspect the `message` field for details on what is wrong. Check that all required parameters are present and that your request body is valid JSON with the correct `Content-Type: application/json` header.
Returned when the `Authorization` header is absent, does not begin with `Bearer sk_`, or contains a key that is invalid or has been revoked.
```json title="Example response" theme={null}
{
"error": {
"code": "unauthorized",
"message": "A valid API key is required. Use Authorization: Bearer sk_xxx."
}
}
```
**How to handle:** Verify that your `Authorization` header is formatted correctly (`Bearer sk_...`), that the key has not been revoked in **Settings > Organization > API Keys**, and that you are not accidentally including extra whitespace or newline characters in the header value. See [Authentication](/api-reference/authentication) for details.
Returned when the API key is valid but the authenticated user does not have permission to access the requested resource. Common causes: accessing a workspace the user is not a member of, or attempting an admin-only action with a Member-role key.
```json title="Example response" theme={null}
{
"error": {
"code": "forbidden",
"message": "You do not have access to this resource."
}
}
```
**How to handle:** Confirm that the user associated with your API key has the required role in the workspace or organization. To check your current user and role, call `GET /v1/me` and `GET /v1/users`.
Returned when the resource identified by the URL or a path parameter does not exist, or the authenticated user cannot see it.
```json title="Example response" theme={null}
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
```
**How to handle:** Double-check the ID in your request path. Note that resources in workspaces you don't have access to also return `404` rather than `403`, to avoid leaking information about what exists.
Returned when the request conflicts with the current state of the resource. The most common cause is an **optimistic locking conflict** on content updates: the `version` field you submitted does not match the current version on the server, meaning another client updated the resource since you last fetched it.
```json title="Example response" theme={null}
{
"error": {
"code": "conflict",
"message": "The request conflicts with the current state."
}
}
```
**How to handle:** Re-fetch the resource to get the latest `version` value, apply your changes to the fresh copy, and retry the update with the new `version`.
Returned when the request body is structurally valid JSON but fails schema validation — for example, an enum field contains an unrecognized value, a required field is `null`, or a string exceeds its maximum length. The `message` field names the specific field that failed.
```json title="Example response" theme={null}
{
"error": {
"code": "validation_error",
"message": "title: String must contain at least 1 character(s)"
}
}
```
**How to handle:** Read the `message` to identify the offending field and correct the value in your request. Do not retry without fixing the input — the same request will fail again.
Returned when you exceed the rate limit for your API key — either 1,000 requests per rolling hour or 20 requests per second. The response includes a `Retry-After` header indicating how many seconds to wait.
```json title="Example response" theme={null}
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Please try again later."
}
}
```
**How to handle:** Wait at least the number of seconds in the `Retry-After` header before retrying. Use exponential backoff with jitter for repeated 429s. See [Rate Limits](/api-reference/rate-limits) for full retry guidance.
Returned when an unexpected error occurs on the server. This is not caused by your request.
```json title="Example response" theme={null}
{
"error": {
"code": "internal_error",
"message": "An internal error occurred."
}
}
```
**How to handle:** Retry the request using exponential backoff. If the error persists, contact Semji support.
Returned when the Semji gateway successfully received your request but the upstream service returned an error. This is typically a transient infrastructure issue.
```json title="Example response" theme={null}
{
"error": {
"code": "bad_gateway",
"message": "The upstream service returned an error."
}
}
```
**How to handle:** Retry with exponential backoff. A `502` is not caused by your request and will usually resolve within seconds.
Returned when the API is temporarily unavailable, typically due to planned maintenance or an unexpected outage.
```json title="Example response" theme={null}
{
"error": {
"code": "service_unavailable",
"message": "The service is temporarily unavailable."
}
}
```
**How to handle:** Retry after a short delay. Check the Semji status page for any active incidents.
## Handling errors in code
The following pattern covers the most important cases — retry on transient server errors, respect rate limit headers, and surface actionable messages for client errors:
```typescript title="TypeScript" theme={null}
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503]);
async function semjiRequest(method: string, path: string, body?: unknown) {
const url = `https://api.semji.com/v1${path}`;
for (let attempt = 0; attempt < 5; attempt++) {
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${process.env.SEMJI_API_KEY}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (RETRYABLE_STATUSES.has(response.status)) {
const retryAfter = parseInt(response.headers.get("Retry-After") ?? "2", 10);
const wait = (retryAfter + Math.random()) * 1000;
await new Promise((r) => setTimeout(r, wait));
continue;
}
if (!response.ok) {
const { error } = await response.json();
throw new Error(`[${error.code}] ${error.message}`);
}
return response.json();
}
throw new Error("Request failed after 5 retries");
}
```
```python title="Python" theme={null}
import time
import random
import requests
RETRYABLE_STATUSES = {429, 500, 502, 503}
def semji_request(method: str, path: str, **kwargs) -> dict:
url = f"https://api.semji.com/v1{path}"
headers = {"Authorization": f"Bearer {API_KEY}"}
for attempt in range(5):
response = requests.request(method, url, headers=headers, **kwargs)
if response.status_code in RETRYABLE_STATUSES:
retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(retry_after + random.uniform(0, 1))
continue
if not response.ok:
error = response.json()["error"]
raise ValueError(f"[{error['code']}] {error['message']}")
return response.json()
raise RuntimeError("Request failed after 5 retries")
```
**Key rules:**
* **Retry** on `429`, `500`, `502`, and `503` using exponential backoff
* **Do not retry** on `400`, `401`, `403`, `404`, `409`, or `422` — these indicate a problem with the request that must be fixed first
* **Re-fetch then retry** on `409` — get the latest resource version before submitting your update again
* **Check `error.message`** on `422` to identify which field failed validation
# Create a folder
Source: https://developers.semji.com/api-reference/folders/create-a-folder
/api-reference/openapi.json post /v1/workspaces/{id}/folders
Creates a new folder in the workspace.
# Delete a folder
Source: https://developers.semji.com/api-reference/folders/delete-a-folder
/api-reference/openapi.json delete /v1/folders/{id}
Deletes a folder. Contents inside the folder are not deleted — they become unorganized.
# List folders
Source: https://developers.semji.com/api-reference/folders/list-folders
/api-reference/openapi.json get /v1/workspaces/{id}/folders
Returns all folders in a workspace as a flat list. Use parentFolderId to reconstruct the tree.
# Update a folder
Source: https://developers.semji.com/api-reference/folders/update-a-folder
/api-reference/openapi.json put /v1/folders/{id}
Updates a folder name or parent. Returns the updated folder.
# Add a keyword to a page
Source: https://developers.semji.com/api-reference/keywords/add-a-keyword-to-a-page
/api-reference/openapi.json post /v1/pages/{pageId}/keywords
Associates a keyword with a page. Creates the keyword if it does not exist.
# Generate keyword analysis report
Source: https://developers.semji.com/api-reference/keywords/generate-keyword-analysis-report
/api-reference/openapi.json post /v1/keywords/{id}/report
Returns an analysis report for the keyword, scoring the provided content against each surface. The report contains the data computed by the keyword analysis — an overall score, typed recommendations (each with its own sub-score and supporting data), and surface-specific context: SERP competitors for Google Search (`googleSearch`); cited sources, mentioned brands, and the markdown preview of the AI response for Google AI Overview (`googleAiOverview`). Provide either `contentId` (by reference) or `title` + `html` (by value), not both.
# Get keyword details
Source: https://developers.semji.com/api-reference/keywords/get-keyword-details
/api-reference/openapi.json get /v1/keywords/{id}
Returns full details of a keyword, including analysis status and focus prompt.
# Launch keyword analysis
Source: https://developers.semji.com/api-reference/keywords/launch-keyword-analysis
/api-reference/openapi.json post /v1/keywords/{id}/analyze
Triggers an asynchronous analysis on a keyword (Google Search organic SERP scrape, recommendations computation). Poll GET /v1/keywords/:id to track progress via analysisStatus.
# List keywords for a page
Source: https://developers.semji.com/api-reference/keywords/list-keywords-for-a-page
/api-reference/openapi.json get /v1/pages/{pageId}/keywords
Returns all keywords associated with a page. Not paginated (pages rarely have more than 50 keywords).
# Update a keyword
Source: https://developers.semji.com/api-reference/keywords/update-a-keyword
/api-reference/openapi.json put /v1/keywords/{id}
Updates a keyword (e.g. set or remove the focus GEO prompt).
# Get knowledge document details
Source: https://developers.semji.com/api-reference/knowledge-documents/get-knowledge-document-details
/api-reference/openapi.json get /v1/knowledge-documents/{id}
Returns details of a knowledge document.
# List knowledge documents
Source: https://developers.semji.com/api-reference/knowledge-documents/list-knowledge-documents
/api-reference/openapi.json get /v1/workspaces/{id}/knowledge-documents
Returns all knowledge documents in a workspace. Enable knowledge sources in POST /v1/contents/:id/atomic settings.
# Get authenticated user
Source: https://developers.semji.com/api-reference/me/get-authenticated-user
/api-reference/openapi.json get /v1/me
Returns the user authenticated by the API key, with the linked organization embedded.
# Semji API overview
Source: https://developers.semji.com/api-reference/overview
Introduction to the Semji REST API v1 — base URL, authentication, response format, rate limits, and the full list of available resources.
The Semji API is a REST API that lets you integrate Semji's AI-powered content marketing platform into your own tools and workflows. Every endpoint returns JSON, requires a Bearer API key, and lives under `https://api.semji.com/v1/`. This page explains the fundamentals before you make your first request.
## Base URL
All API v1 endpoints share the following base URL:
```
https://api.semji.com/v1
```
There is no version negotiation via headers — the version is part of the path. Every endpoint documented in this reference is prefixed with `/v1/`.
## Your first API call
The quickest way to verify your API key is to fetch your own user record. Replace `sk_...` with your actual key:
```bash title="cURL" theme={null}
curl https://api.semji.com/v1/me \
-H "Authorization: Bearer sk_your_api_key_here"
```
A successful response looks like this:
```json title="200 OK" theme={null}
{
"id": "usr_01hx9z3k4m5n6p7q8r9s0t1u",
"firstName": "Alice",
"lastName": "Martin",
"email": "alice@example.com",
"profileImageUrl": null,
"jobTitle": "SEO Lead",
"languageCode": "en",
"organization": {
"id": "org_01hx9z3k4m5n6p7q8r9s0t2v",
"name": "Example Corp",
"createdAt": "2024-01-10T08:00:00Z",
"brandName": null,
"brandImageUrl": null,
"credits": {
"analysis": 47,
"aiWriting": 12,
"contentIdeasSearches": 5
},
"usersCount": 3,
"workspacesCount": 2
},
"createdAt": "2024-03-15T09:12:00Z"
}
```
Generate your API key in the Semji app at [Settings > Organization > API Keys](https://app.semji.com). See [Authentication](/api-reference/authentication) for full details.
## Authentication
Every request must include an `Authorization` header with a Bearer token:
```
Authorization: Bearer sk_your_api_key_here
```
API keys begin with `sk_`. A missing or malformed key returns `401 Unauthorized`. A valid key without access to the requested resource returns `403 Forbidden`. See the [Authentication guide](/api-reference/authentication) for code examples in Python, Node.js, and more.
## Response format
All responses use `Content-Type: application/json`.
**Single resources** are returned as a flat JSON object:
```json theme={null}
{
"id": "...",
"name": "..."
}
```
**Collections** are wrapped in a standard pagination envelope:
```json theme={null}
{
"data": [
{ "id": "...", "name": "..." }
],
"pagination": {
"total": 84,
"page": 1,
"limit": 25,
"hasMore": true
}
}
```
Use the `page` and `limit` query parameters to paginate. `limit` accepts values from 1 to 100 and defaults to 25.
## Error format
All errors follow the same shape regardless of status code:
```json theme={null}
{
"error": {
"code": "not_found",
"message": "The requested resource was not found."
}
}
```
The `code` field is a stable machine-readable string you can match in code. The `message` field is a human-readable description, useful for debugging. See the [Errors reference](/api-reference/errors) for the full list of codes and HTTP status codes.
## Rate limits
Each API key is limited to **1,000 requests per hour** on a rolling window, with a burst limit of **20 requests per second**. Every response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. Exceeding the limit returns `429 Too Many Requests`. See [Rate Limits](/api-reference/rate-limits) for details and retry guidance.
## Resources
The API exposes the following resource groups. Click any card to go to the endpoint reference.
Retrieve the authenticated user and their organization, and list members of your organization with their roles.
List and inspect the websites you track in Semji. Each workspace contains pages, contents, keywords, and workflow configuration.
Import URLs into a workspace, trigger crawls to fetch metadata, and associate focus keywords for SEO analysis.
Create and manage SEO content drafts, track their Content Score (0–100), and generate full articles with Atomic Content AI.
Analyze search terms to get SERP-based recommendations: topics, questions, search intents, and links to add.
Analyze questions asked to AI engines (ChatGPT, Google AI Overviews) to get GEO recommendations and AI citation data.
Configure editorial identities that Atomic Content uses to match your writing style, tone, and brand guidelines.
Upload reference materials that the AI draws from during content generation to produce more accurate, brand-aligned output.
Track the status of Atomic Content AI generation jobs, confirm generated drafts, or cancel them.
View your organization's credit consumption history across keyword analyses, AI generation, and content ideas.
# Crawl a page
Source: https://developers.semji.com/api-reference/pages/crawl-a-page
/api-reference/openapi.json post /v1/pages/{id}/crawl
Triggers an asynchronous crawl to refresh the page metadata (title, meta description, etc.). Poll GET /v1/pages/:id to see updated data.
# Delete a page
Source: https://developers.semji.com/api-reference/pages/delete-a-page
/api-reference/openapi.json delete /v1/pages/{id}
Deletes a page and its associated contents.
# Get page details
Source: https://developers.semji.com/api-reference/pages/get-page-details
/api-reference/openapi.json get /v1/pages/{id}
Returns full details of a page, including its focus keyword.
# Import a page
Source: https://developers.semji.com/api-reference/pages/import-a-page
/api-reference/openapi.json post /v1/workspaces/{workspaceId}/pages
Imports an existing URL into the workspace (crawls it to extract title, meta description, etc.). Use this for pages you already have online and want to track. Optionally sets a URL category and focus keyword. To create a new editorial draft that does not yet have a URL, use POST /v1/workspaces/{workspaceId}/contents instead.
# List pages in a workspace
Source: https://developers.semji.com/api-reference/pages/list-pages-in-a-workspace
/api-reference/openapi.json get /v1/workspaces/{workspaceId}/pages
Returns a paginated list of tracked pages in the workspace. Supports text search, category filtering, and sorting.
# Update a page
Source: https://developers.semji.com/api-reference/pages/update-a-page
/api-reference/openapi.json put /v1/pages/{id}
Updates a page focus keyword and/or URL category. The URL category cannot currently be unset once assigned.
# Create a prompt
Source: https://developers.semji.com/api-reference/prompts/create-a-prompt
/api-reference/openapi.json post /v1/keywords/{keywordId}/prompts
Creates a GEO prompt for a keyword.
# Generate prompt analysis report
Source: https://developers.semji.com/api-reference/prompts/generate-prompt-analysis-report
/api-reference/openapi.json post /v1/prompts/{id}/report
Returns an analysis report for the prompt, scoring the provided content against ChatGPT. The report contains the data computed by the prompt analysis — an overall score, typed recommendations (each with its own sub-score and supporting data), the cited sources, the mentioned brands, and the markdown preview of ChatGPT's response. Provide either `contentId` (by reference) or `title` + `html` (by value), not both.
# Get prompt details
Source: https://developers.semji.com/api-reference/prompts/get-prompt-details
/api-reference/openapi.json get /v1/prompts/{id}
Returns details of a prompt. Useful for polling analysisStatus after launching a GEO analysis.
# Launch prompt analysis
Source: https://developers.semji.com/api-reference/prompts/launch-prompt-analysis
/api-reference/openapi.json post /v1/prompts/{id}/analyze
Triggers an asynchronous analysis on a prompt (ChatGPT). The parent keyword must have a completed keyword analysis. Poll GET /v1/prompts/:id to track progress.
# List prompts for a keyword
Source: https://developers.semji.com/api-reference/prompts/list-prompts-for-a-keyword
/api-reference/openapi.json get /v1/keywords/{keywordId}/prompts
Returns GEO prompts associated with a keyword.
# Rate Limits
Source: https://developers.semji.com/api-reference/rate-limits
Understand Semji API rate limits, how to read rate limit response headers, handle 429 errors, and implement exponential backoff in your integration.
The Semji API enforces rate limits to ensure fair usage and stable performance for all customers. Understanding these limits and building retry logic into your integration will prevent disruptions in production. This page explains the limits in place, the response headers you can use to track your usage, and recommended strategies for handling errors gracefully.
## Limits
Two rate limits apply to every API key simultaneously:
| Limit | Window | Maximum |
| ------ | ---------------- | -------------- |
| Hourly | 1 hour (rolling) | 1,000 requests |
| Burst | 1 second | 20 requests |
Both limits are enforced per API key. The hourly limit is a rolling window — it tracks requests over the past 60 minutes, not a fixed clock-hour boundary. The burst limit prevents sudden spikes that could affect service reliability even when your hourly budget is healthy.
Health check (`/health`), OpenAPI spec (`/openapi.json`), and documentation (`/docs`) endpoints are excluded from rate limiting.
## Rate limit headers
Every API response includes the following headers reflecting your current hourly limit status:
The maximum number of requests allowed per hour for this API key.
The number of requests remaining in the current rolling hour window.
The number of seconds until the oldest request in the rolling window falls out and your remaining count increases.
You can inspect these headers with curl using the `-i` flag:
```bash title="Inspect rate limit headers" theme={null}
curl -i https://api.semji.com/v1/me \
-H "Authorization: Bearer sk_your_api_key_here"
```
```text title="Response headers (excerpt)" theme={null}
HTTP/2 200
x-ratelimit-limit: 1000
x-ratelimit-remaining: 847
x-ratelimit-reset: 1823
content-type: application/json
```
## When the limit is exceeded
When you exceed either limit, the API responds with `429 Too Many Requests`. The response body follows the standard error format:
```json title="429 Too Many Requests" theme={null}
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Please try again later."
}
}
```
On a `429`, the response also includes a `Retry-After` header indicating the number of seconds to wait before retrying.
## Handling 429 errors
### Exponential backoff
The recommended approach is exponential backoff with jitter. Wait progressively longer between retries, and add a small random delay to avoid synchronized retry storms across multiple workers:
```typescript title="TypeScript" theme={null}
const apiKey = process.env.SEMJI_API_KEY;
async function semjiGet(path: string, maxRetries = 5) {
const url = `https://api.semji.com/v1${path}`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get("Retry-After") ?? "2", 10);
const jitter = Math.random();
const wait = (retryAfter + jitter) * 1000;
console.log(`Rate limited. Retrying in ${(wait / 1000).toFixed(1)}s`);
await new Promise((resolve) => setTimeout(resolve, wait));
continue;
}
if (!response.ok) {
const error = await response.json();
throw new Error(`${response.status}: ${error.error.message}`);
}
return response.json();
}
throw new Error(`Exceeded ${maxRetries} retries for ${path}`);
}
```
```python title="Python" theme={null}
import os
import time
import random
import requests
api_key = os.environ["SEMJI_API_KEY"]
def semji_get(path: str, max_retries: int = 5) -> dict:
url = f"https://api.semji.com/v1{path}"
headers = {"Authorization": f"Bearer {api_key}"}
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
jitter = random.uniform(0, 1)
wait = retry_after + jitter
print(f"Rate limited. Retrying in {wait:.1f}s (attempt {attempt + 1})")
time.sleep(wait)
continue
response.raise_for_status()
return response.json()
raise RuntimeError(f"Exceeded {max_retries} retries for {path}")
```
### Proactively monitoring your budget
Instead of waiting for a `429`, read the `X-RateLimit-Remaining` header on each response and slow down when your budget runs low:
```python title="Proactive throttling (Python)" theme={null}
import time
import requests
def semji_get_with_throttle(session: requests.Session, url: str) -> dict:
response = session.get(url)
response.raise_for_status()
remaining = int(response.headers.get("X-RateLimit-Remaining", 1000))
reset_in = int(response.headers.get("X-RateLimit-Reset", 0))
# Slow down when fewer than 50 requests remain
if remaining < 50 and reset_in > 0:
sleep_time = reset_in / max(remaining, 1)
time.sleep(min(sleep_time, 5)) # Cap at 5s between requests
return response.json()
```
## Best practices
Content generation jobs (`/v1/content-generations`) are asynchronous. Poll their status with a reasonable interval — every 5–10 seconds is sufficient. Polling every second wastes your request budget without providing faster results.
**Cache responses where possible.** Resources like workspaces, brand voices, and knowledge documents rarely change. Cache their IDs and names for the duration of your session rather than fetching them on every run.
**Use pagination efficiently.** Fetch only the pages you need. Use the maximum `limit=100` when you need to process all items in a collection, rather than making many small requests.
**Batch reads before writes.** If your workflow reads several resources before creating or updating one, perform all the reads first. This groups your writes into a smaller time window and leaves more headroom for subsequent operations.
**Avoid retrying 4xx errors other than 429.** Errors in the `400`–`428` range indicate a problem with the request itself (bad input, missing permissions, not found). Retrying them will not help and only wastes your quota. Fix the request and then retry.
# List organization members
Source: https://developers.semji.com/api-reference/users/list-organization-members
/api-reference/openapi.json get /v1/users
Returns all members of the organization linked to the API key, with their role.
# Get workspace details
Source: https://developers.semji.com/api-reference/workspaces/get-workspace-details
/api-reference/openapi.json get /v1/workspaces/{id}
Returns details of a specific workspace.
# List content statuses
Source: https://developers.semji.com/api-reference/workspaces/list-content-statuses
/api-reference/openapi.json get /v1/workspaces/{id}/content-statuses
Returns all content statuses for a workspace, ordered by position. Includes system statuses ("to do", "published") which are read-only.
# List workspace members
Source: https://developers.semji.com/api-reference/workspaces/list-workspace-members
/api-reference/openapi.json get /v1/workspaces/{id}/users
Returns all members of a workspace with their role.
# List workspaces
Source: https://developers.semji.com/api-reference/workspaces/list-workspaces
/api-reference/openapi.json get /v1/workspaces
Returns all workspaces in the organization accessible to the authenticated user.
# Semji API guides overview
Source: https://developers.semji.com/guides/overview
End-to-end recipes that chain multiple Semji API endpoints to solve concrete business use cases — sync drafts to your CMS, generate SEO and GEO recommendations, and more.
These guides walk you through complete workflows that combine several Semji API endpoints. Each one assumes you've already followed the [Quickstart](/quickstart) — you have an API key, you know your workspace ID, and you've made at least one successful call.
Poll Semji for drafts ready to publish, push their sanitized HTML to your CMS, then mark them as published — or let Semji drive the sync with webhooks.
Create a draft from a focus keyword, launch the keyword analysis, and retrieve typed SEO and GEO recommendations.
# Get SEO & GEO recommendations from a keyword
Source: https://developers.semji.com/guides/seo-geo-recommendations
Create a draft from a focus keyword, run a keyword analysis, and pull back the typed SEO (Google Search) and GEO (Google AI Overview) recommendations.
This guide shows you how to go from a single keyword to a complete brief of SEO and GEO recommendations. The flow uses four endpoints:
1. **Create a draft content** in your editorial planning.
2. **Add the focus keyword** to the draft's page and set it as the focus keyword.
3. **Launch the keyword analysis** asynchronously.
4. **Generate the analysis report** to retrieve typed recommendations.
The same flow powers the *New content* button in the Semji app.
## Prerequisites
* An API key. See [Authentication](/api-reference/authentication).
* The **workspace ID** you want to plan content in. Get it from [`GET /v1/workspaces`](/api-reference/workspaces/list-workspaces).
* A focus keyword you want recommendations for (e.g. `best crm for small business`).
## 1. Create a draft content
Call [`POST /v1/workspaces/{workspaceId}/contents`](/api-reference/contents/create-a-content) without a `pageId` — Semji will auto-create a blank page to host the draft. You only need a `title` to get started.
```typescript TypeScript 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;
const content = await (
await fetch(`${API}/workspaces/${WORKSPACE_ID}/contents`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ title: "Best CRM for small business" }),
})
).json();
const contentId = content.id;
const pageId = content.page.id;
```
```python Python theme={null}
import os, requests
API = "https://api.semji.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}"}
WORKSPACE_ID = os.environ["WORKSPACE_ID"]
content = requests.post(
f"{API}/workspaces/{WORKSPACE_ID}/contents",
headers=HEADERS,
json={"title": "Best CRM for small business"},
).json()
content_id = content["id"]
page_id = content["page"]["id"]
```
```bash cURL theme={null}
curl -X POST https://api.semji.com/v1/workspaces/$WORKSPACE_ID/contents \
-H "Authorization: Bearer $SEMJI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "Best CRM for small business"}'
```
A successful response includes both the new content ID and the auto-created page ID:
```json title="201 Created (excerpt)" theme={null}
{
"id": "7c4a1f08b29d",
"title": "Best CRM for small business",
"page": { "id": "5e8d203c7f1a", "url": null },
"contentStatus": { "id": "b3d51e92c804", "label": "to do" },
"version": 1
}
```
Keep both `content.id` and `page.id` — you'll use them in the next steps.
## 2. Attach the focus keyword
Adding a focus keyword is a two-step operation:
1. Add the keyword to the page with [`POST /v1/pages/{pageId}/keywords`](/api-reference/keywords/add-a-keyword-to-a-page).
2. Mark it as the page's focus keyword with [`PUT /v1/pages/{id}`](/api-reference/pages/update-a-page).
```typescript TypeScript theme={null}
// 1. Create the keyword on the page
const keyword = await (
await fetch(`${API}/pages/${pageId}/keywords`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ keyword: "best crm for small business" }),
})
).json();
const keywordId = keyword.id;
// 2. Set it as the page's focus keyword
const update = await fetch(`${API}/pages/${pageId}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify({ focusKeywordId: keywordId }),
});
if (!update.ok) throw new Error(`PUT failed: ${update.status}`);
```
```python Python theme={null}
keyword = requests.post(
f"{API}/pages/{page_id}/keywords",
headers=HEADERS,
json={"keyword": "best crm for small business"},
).json()
keyword_id = keyword["id"]
requests.put(
f"{API}/pages/{page_id}",
headers=HEADERS,
json={"focusKeywordId": keyword_id},
).raise_for_status()
```
```bash cURL theme={null}
# 1. Create the keyword on the page
KEYWORD=$(curl -X POST https://api.semji.com/v1/pages/$PAGE_ID/keywords \
-H "Authorization: Bearer $SEMJI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"keyword": "best crm for small business"}')
KEYWORD_ID=$(echo $KEYWORD | jq -r .id)
# 2. Set it as the page's focus keyword
curl -X PUT https://api.semji.com/v1/pages/$PAGE_ID \
-H "Authorization: Bearer $SEMJI_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"focusKeywordId\": \"$KEYWORD_ID\"}"
```
If the keyword already exists in the workspace, `POST /v1/pages/{pageId}/keywords` reuses it instead of creating a duplicate.
## 3. Launch the keyword analysis
Recommendations are not computed on demand — you have to launch an asynchronous analysis with [`POST /v1/keywords/{id}/analyze`](/api-reference/keywords/launch-keyword-analysis). The analysis scrapes the Google SERP, runs the GEO/AI Overview probe, and stores the typed recommendations on the keyword.
```typescript TypeScript theme={null}
const analyze = await fetch(`${API}/keywords/${keywordId}/analyze`, {
method: "POST",
headers: HEADERS,
});
if (!analyze.ok) throw new Error(`analyze failed: ${analyze.status}`);
```
```python Python theme={null}
requests.post(
f"{API}/keywords/{keyword_id}/analyze", headers=HEADERS,
).raise_for_status()
```
```bash cURL theme={null}
curl -X POST https://api.semji.com/v1/keywords/$KEYWORD_ID/analyze \
-H "Authorization: Bearer $SEMJI_API_KEY"
```
The endpoint returns `202 Accepted` immediately and the analysis runs in the background. Poll [`GET /v1/keywords/{id}`](/api-reference/keywords/get-keyword-details) until `analysisStatus` is `"success"`:
```typescript Polling loop (TypeScript) theme={null}
while (true) {
const keyword = await (
await fetch(`${API}/keywords/${keywordId}`, { headers: HEADERS })
).json();
if (keyword.analysisStatus === "success") break;
if (keyword.analysisStatus === "failed") throw new Error("Keyword analysis failed");
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
```
```python Polling loop (Python) theme={null}
import time
while True:
keyword = requests.get(f"{API}/keywords/{keyword_id}", headers=HEADERS).json()
if keyword["analysisStatus"] == "success":
break
if keyword["analysisStatus"] == "failed":
raise RuntimeError("Keyword analysis failed")
time.sleep(5)
```
`analysisStatus` transitions through `queued` → `pending` → `success` (or `failed`). Most analyses complete within 30 seconds.
Each analysis consumes one **analysis credit** from your organization's balance. Check available credits with [`GET /v1/me`](/api-reference/me/get-authenticated-user) (look at `organization.credits.analysis`).
## 4. Retrieve the SEO & GEO recommendations
Once the analysis is `success`, call [`POST /v1/keywords/{id}/report`](/api-reference/keywords/generate-keyword-analysis-report) to score a content draft against the analysis and pull the typed recommendations.
You have two ways to score:
* **By reference** — pass `contentId` and Semji uses the draft's current `title` + `html`.
* **By value** — pass `title` and `html` inline (useful for previewing recommendations against arbitrary text).
```typescript TypeScript theme={null}
const report = await (
await fetch(`${API}/keywords/${keywordId}/report`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ contentId }),
})
).json();
```
```python Python theme={null}
report = requests.post(
f"{API}/keywords/{keyword_id}/report",
headers=HEADERS,
json={"contentId": content_id},
).json()
```
```bash cURL theme={null}
curl -X POST https://api.semji.com/v1/keywords/$KEYWORD_ID/report \
-H "Authorization: Bearer $SEMJI_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"contentId\": \"$CONTENT_ID\"}"
```
The response contains two top-level surfaces:
* `googleSearch` — classic SEO recommendations from the SERP (topics, questions, search intents, internal links to add, SERP competitors).
* `googleAiOverview` — GEO recommendations from the AI Overview answer (topics to cover for citation, cited sources, mentioned brands, markdown preview of the LLM answer).
```json title="200 OK (excerpt)" theme={null}
{
"googleSearch": {
"score": 0.42,
"recommendations": {
"topicsSuggestion": { "score": 0.55, "items": [{ "topic": "pricing", "...": "..." }] },
"questionsSuggestion": { "score": 0.30, "items": [{ "question": "Which CRM is free?" }] }
},
"competitors": [{ "domain": "hubspot.com", "position": 1 }]
},
"googleAiOverview": {
"score": 0.31,
"recommendations": {
"geoTopicsSuggestion": { "score": 0.31, "items": [{ "topic": "integration with email" }] }
},
"sources": [{ "domain": "salesforce.com", "position": 1 }],
"brands": [{ "name": "HubSpot", "count": 4, "category": "software" }],
"preview": "## Best CRM for small business…"
}
}
```
Each `*Suggestion` block carries its own `score` (0.0 – 1.0) and a list of `items` you can render in your brief. The top-level `score` on each surface is the overall match between the content and the recommendations.
Re-call the report endpoint as the draft evolves — the recommendations are fixed (until you re-run the analysis), but the scores change as the `html` improves.
## Putting it all together
```typescript recommendations-from-keyword.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;
async function api(path: string, init: RequestInit = {}) {
const response = await fetch(`${API}${path}`, { ...init, headers: HEADERS });
if (!response.ok) {
throw new Error(`${init.method ?? "GET"} ${path} → ${response.status}`);
}
return response.json();
}
async function createDraft(title: string) {
return api(`/workspaces/${WORKSPACE_ID}/contents`, {
method: "POST",
body: JSON.stringify({ title }),
});
}
async function setFocusKeyword(pageId: string, keyword: string) {
const created = await api(`/pages/${pageId}/keywords`, {
method: "POST",
body: JSON.stringify({ keyword }),
});
await api(`/pages/${pageId}`, {
method: "PUT",
body: JSON.stringify({ focusKeywordId: created.id }),
});
return created.id;
}
async function analyzeAndWait(keywordId: string) {
await api(`/keywords/${keywordId}/analyze`, { method: "POST" });
while (true) {
const keyword = await api(`/keywords/${keywordId}`);
if (keyword.analysisStatus === "success") return;
if (keyword.analysisStatus === "failed") throw new Error("Keyword analysis failed");
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
}
async function getReport(keywordId: string, contentId: string) {
return api(`/keywords/${keywordId}/report`, {
method: "POST",
body: JSON.stringify({ contentId }),
});
}
async function recommendationsFor(keyword: string) {
const draft = await createDraft(keyword.charAt(0).toUpperCase() + keyword.slice(1));
const keywordId = await setFocusKeyword(draft.page.id, keyword);
await analyzeAndWait(keywordId);
return getReport(keywordId, draft.id);
}
const report = await recommendationsFor("best crm for small business");
console.log("SEO score:", report.googleSearch.score);
console.log("GEO score:", report.googleAiOverview.score);
```
```python recommendations_from_keyword.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"]
def create_draft(title):
return requests.post(
f"{API}/workspaces/{WORKSPACE_ID}/contents",
headers=HEADERS, json={"title": title},
).json()
def set_focus_keyword(page_id, keyword):
kw = requests.post(
f"{API}/pages/{page_id}/keywords",
headers=HEADERS, json={"keyword": keyword},
).json()
requests.put(
f"{API}/pages/{page_id}",
headers=HEADERS, json={"focusKeywordId": kw["id"]},
).raise_for_status()
return kw["id"]
def analyze_and_wait(keyword_id):
requests.post(f"{API}/keywords/{keyword_id}/analyze", headers=HEADERS).raise_for_status()
while True:
kw = requests.get(f"{API}/keywords/{keyword_id}", headers=HEADERS).json()
if kw["analysisStatus"] == "success":
return
if kw["analysisStatus"] == "failed":
raise RuntimeError("Keyword analysis failed")
time.sleep(5)
def get_report(keyword_id, content_id):
return requests.post(
f"{API}/keywords/{keyword_id}/report",
headers=HEADERS, json={"contentId": content_id},
).json()
def recommendations_for(keyword):
draft = create_draft(title=keyword.capitalize())
keyword_id = set_focus_keyword(draft["page"]["id"], keyword)
analyze_and_wait(keyword_id)
return get_report(keyword_id, draft["id"])
if __name__ == "__main__":
report = recommendations_for("best crm for small business")
print("SEO score:", report["googleSearch"]["score"])
print("GEO score:", report["googleAiOverview"]["score"])
```
## Reference
* [Create a content](/api-reference/contents/create-a-content)
* [Add a keyword to a page](/api-reference/keywords/add-a-keyword-to-a-page)
* [Update a page](/api-reference/pages/update-a-page)
* [Launch keyword analysis](/api-reference/keywords/launch-keyword-analysis)
* [Get keyword details](/api-reference/keywords/get-keyword-details)
* [Generate keyword analysis report](/api-reference/keywords/generate-keyword-analysis-report)
# Sync drafts to your CMS
Source: https://developers.semji.com/guides/sync-drafts-to-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.
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.
## 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:
```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"
```
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.
See [List content statuses](/api-reference/workspaces/list-content-statuses) for the full schema.
## 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:
```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"
```
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](/api-reference/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}`](/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).
```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}"
```
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": "
How to choose a CRM in 2026
…
",
"htmlSanitized": "How to choose a CRM in 2026
…
",
"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).
```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"}'
```
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:
```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;
declare function rememberCmsRecord(contentId: string, cmsRecordId: string): Promise;
declare function lookupCmsRecord(contentId: string): Promise;
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
```
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 |
Semji fires a `content_published` webhook when an editor clicks **Mark as published**. Configure it in **Settings > General > Add a Webhook**, then your endpoint creates or updates the article in the CMS. For the full setup, the payload, the security notes, and ready-made Make / N8n templates, see the [Content published webhook](/integrations/content-published-webhook) page.
### A minimal webhook handler
```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;
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"],
)
```
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.
## 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)
* [Content published webhook](/integrations/content-published-webhook)
# Semji API & MCP Documentation
Source: https://developers.semji.com/index
Integrate Semji's AI-powered content marketing platform via the REST API or the MCP server — connect your tools, workflows, and AI assistants like Claude or ChatGPT.
Semji helps you scale your organic and AI visibility. This documentation covers everything you need to integrate Semji into your workflows, whether you prefer calling the REST API directly or letting Claude, ChatGPT, or any AI assistant drive your workspace through the MCP server.
Set up your first workspace, import a page, and make your first API call in minutes.
Explore all REST endpoints — workspaces, pages, keywords, contents, and more.
Connect Claude, ChatGPT, or any AI assistant to Semji with natural language.
## Authentication
All API requests require a Bearer API key. Generate one from **Settings > Organization > API Keys** in the Semji app.
```http theme={null}
Authorization: Bearer sk_your_api_key_here
```
See the [Authentication guide](/api-reference/authentication) for detailed instructions on generating keys, rate limits, and error handling.
## Built for AI agents
This documentation is designed to be easily consumed by AI tools like Claude, ChatGPT, or any LLM-powered agent.
* **llms.txt** — A machine-readable index of the entire documentation is available at [`/llms.txt`](https://developers.semji.com/llms.txt) and [`/llms-full.txt`](https://developers.semji.com/llms-full.txt), following the [llms.txt standard](https://llmstxt.org).
* **Copy as context** — Every page has a **Copy page** button that copies the content as Markdown, ready to paste into your favorite AI assistant.
* **[MCP server](/mcp/overview)** — Connect your AI assistant directly to Semji and interact with your workspace using natural language.
# Content published webhook
Source: https://developers.semji.com/integrations/content-published-webhook
The content_published webhook: Semji notifies your endpoint when an editor marks a content as published, so you can distribute it to your CMS or automation tools — with ready-made Make and N8n templates.
When an editor clicks **Mark as published** in Semji, Semji sends a
`content_published` webhook to the URL you configured. Use it to push the
published article to your CMS, or to trigger downstream automations
(dashboards, tasks, notifications) — no polling, no copy-paste.
`content_published` fires **after** publication (the editor decided the content
is live). It is separate from the [Send to CMS webhook](/integrations/send-to-cms-webhook)
(`content_staged`), which pushes a **draft** to your CMS *before* publication.
Both can be configured on the same workspace.
## Prerequisites
* An HTTPS endpoint reachable from the public internet — either your own, or a
scenario URL from a no-code platform (Make, Zapier, N8n).
* A workspace where you can reach **Settings** (to add the webhook).
## How it works
In the app, go to **Settings > General**, click **Add a Webhook**, and paste
your endpoint (or automation scenario) URL.
They fill in the publication URL and date (backdating works, in the app and
via the API's `publishedAt`).
A single `POST` with the content payload lands on your endpoint. Respond with
a `2xx` immediately and process asynchronously.
## The payload Semji sends
```json title="content_published payload" 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": "How to choose a CRM in 2026
…
",
"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" }
}
}
```
Always `content_published` for this webhook.
The published content and its context: `title`, sanitized `html`, plain `text`,
`meta_description`, `content_score`, `words_count`, `published_at`,
`published_by`, `content_status`, `page` (with the live `url`),
`page_focus_keyword` (with `search_volume`), plus `workspace` and `organization`.
IDs are 12-char public IDs.
Three things to know:
* `data.html` is **already sanitized** — editor annotations are stripped, it is
equivalent to the API's `htmlSanitized` field. 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
`2xx` immediately and process asynchronously.
The webhook is **not signed**: anyone who discovers your endpoint URL could
forge a payload. Treat it as a **trigger** — re-fetch the content by ID with
your API key before touching your CMS, and drop the event if the content
doesn't exist or isn't published.
## No-code templates
Prefer not to write code? Import one of these ready-made scenarios and point it
at WordPress (adapt the CMS step for another platform):
A Make scenario that receives the webhook and creates the post in WordPress.
An N8n workflow with a POST webhook trigger that routes the content to WordPress.
The scenario URL these tools give you is what you paste into **Settings > General > Add a Webhook**.
## Use cases
* Publish or update the article in your CMS (WordPress, Contentful, Webflow…).
* Auto-populate a Google Sheets dashboard with the SEO metrics.
* Create a follow-up task (Asana, Jira) for the marketing team.
* Notify a Slack or Teams channel on publication.
* Cross-post to social (X, LinkedIn).
## Troubleshooting
Check the webhook URL in **Settings > General**, and that an editor actually
clicked **Mark as published** (the event only fires on publication). The URL
must be a public HTTPS endpoint reachable from Semji.
Delivery is a single unsigned POST — treat it as a trigger and re-fetch the
content with [`GET /v1/contents/{id}`](/api-reference/contents/get-content-details)
using your API key before writing to your CMS.
## Related
* [Send to CMS webhook](/integrations/send-to-cms-webhook) — push a **draft** to
your CMS before publication (`content_staged`).
* [Sync drafts to your CMS](/guides/sync-drafts-to-cms) — the pull-based
publishing flow, and when to prefer it over this webhook.
* [Authentication](/api-reference/authentication) — API keys for re-fetching content.
# Connect Contentful to Semji
Source: https://developers.semji.com/integrations/contentful
Connect Contentful to Semji in one click through Contentful's hosted MCP server — sign in, approve access, done.
Semji connects to Contentful through **Contentful's official hosted MCP
server** (`https://mcp.contentful.com/mcp`) using OAuth. There are no API
keys, space IDs, or tokens to copy — you sign in to Contentful and approve
the access. Once connected, Semji's AI agents can read and update content in
Contentful. To learn more about the server Semji connects to, see the
[Contentful MCP server documentation](https://www.contentful.com/developers/docs/tools/mcp-server/).
Compatible with **all Contentful versions**.
## Prerequisites
* A **Contentful account** with access to the space you want Semji to work
in.
* You are a **workspace owner** in Semji. The connection is shared with the
whole workspace.
* Your browser allows **popups** from the Semji app (the Contentful consent
screen opens in a new tab).
Availability of the Contentful integration depends on your Semji plan. If
the **Connect** button opens an upgrade dialog instead, contact your
account manager.
## Connect from Semji
In Semji, go to **Settings → Integrations**, open the **CMS** tab, and
click **Connect** on the Contentful row.
Click **Connect Contentful**. A new browser tab opens on Contentful's
sign-in and consent screen.
Sign in with your Contentful account and approve the requested access.
Complete this within **10 minutes** — after that the connection attempt
expires and you have to start over.
The tab closes and you land back in Semji, where the Contentful row now
shows as connected for the whole workspace.
## What Semji can do once connected
The available actions are provided by Contentful's MCP server — typically
reading and updating entries. AI agents discover the available tools at run
time, and Semji's publishing agents never delete content, even when a delete
tool is exposed.
## Manage or disconnect
From **Settings → Integrations → CMS**, the Contentful row offers
**Disconnect**, which revokes the stored access for the whole workspace. To
switch to a different Contentful account, disconnect and reconnect with the
other account.
## Network requirements
The connection runs between Semji and Contentful's cloud — Semji never calls
your own infrastructure, so there is **nothing to allowlist on your side**
for Contentful.
## Troubleshooting
Your browser prevented the Contentful consent tab from opening. Allow
popups for the Semji app domain and click **Connect Contentful** again.
Retry the connection. If it keeps failing, complete the consent within 10
minutes of clicking **Connect Contentful** — the connection attempt is
single-use and expires after that.
The connection could not be completed — you may have denied the consent,
or the attempt expired. Click **Connect Contentful** and try again.
Contentful stopped accepting Semji's stored access and agents skip
Contentful until it is reconnected. A workspace owner must open the
Contentful integration and reconnect.
The connection was disconnected or expired. A workspace owner must
reconnect Contentful under **Settings → Integrations → CMS**.
# Connect a custom MCP server to Semji
Source: https://developers.semji.com/integrations/custom-mcp
Bring your own MCP server — a custom CMS bridge or any internal tool — and let Semji's AI agents use its tools via OAuth or an API key.
Beyond the built-in CMS integrations, Semji can connect to **any MCP server
you host** — a bridge to a custom CMS, an internal tool, or a third-party
service. You register the server once for the workspace, connect it with
OAuth or an API key, then enable it on the AI agents that should use its
tools.
## Requirements for your MCP server
* Served over **HTTPS** and reachable from the **public internet** — servers
on private networks cannot be connected.
* Speaks the MCP **streamable HTTP** transport.
* **If you use OAuth**, the server must implement the MCP authorization
specification:
* It advertises its protected-resource metadata (RFC 9728) — either via a
`WWW-Authenticate` header on unauthenticated requests or at
`/.well-known/oauth-protected-resource`.
* Its authorization server publishes standard discovery metadata
(RFC 8414 or OpenID Connect discovery).
* Its authorization server supports **Dynamic Client Registration**
(RFC 7591) — Semji registers itself automatically; manually
pre-registered client IDs are not supported.
* It accepts the redirect URI
`https://app.semji.com/integrations/mcp/callback`.
* **If you use an API key**, the server must accept it as
`Authorization: Bearer ` on every request.
## Add the server
In Semji, go to **Settings → Integrations**, open the **MCP** tab, and
click **Add server**. You must be a **workspace owner**.
Fill in the form:
* **Name** — a display name; agents see it as the server's name.
* **Description** — optional.
* **URL** — the HTTPS URL of your MCP server, e.g.
`https://mcp.example.com`. The URL cannot be changed after creation.
* **Authentication method** — **OAuth** (sign in through the provider's
consent screen) or **API key** (paste a token). With API key, paste it
into the **API key** field.
Click **Connect**.
* **OAuth**: a new tab opens on your provider's consent screen — sign in
and approve within 10 minutes.
* **API key**: the key is stored immediately, without a live test
against your server.
Back on the MCP tab, the server row shows its status: **Connected**,
**Not connected**, or **Expired**.
Open the agent's configuration, go to **Enhancements → Custom MCP
Servers**, and toggle your server on. The agent discovers the server's
tools at run time.
Depending on your Semji configuration, connections are shared by the whole
workspace or made per user ("Each team member connects their own
account."). With per-user connections, each teammate connects from the MCP
tab or from their profile settings; agents silently skip servers the
current user hasn't connected, and the chat shows *"You are not connected
to …. Connect it to let this agent use it."*
## Manage the server
From the MCP tab, each server row offers:
* **Connect / Disconnect** — manage the stored credentials.
* **Edit** — change the name and description (the URL is immutable; to
change it, delete the server and add it again).
* **Delete** — permanently removes the server from the workspace; every
connected member loses access.
## Firewalls and IP allowlisting
Your MCP server must be reachable from the public internet. Semji's own
servers call it for connection and OAuth flows (discovery, client
registration, token exchange):
Semji's servers reach the internet through a fixed set of outbound IP addresses. If a firewall, WAF, or anti-bot protection (Cloudflare, Akamai, Sucuri, …) sits in front of your site, it can block Semji's requests — typically with a `403 Forbidden` — before they ever reach your CMS, even when your credentials are correct.
Allow the following Semji IP addresses:
```text theme={null}
63.34.75.122
63.35.78.179
54.228.104.165
18.200.156.37
34.248.117.83
52.213.28.177
```
Do **not** restrict access to your MCP server to the IP list above alone.
AI agent tool calls are executed from Semji's AI provider infrastructure,
whose addresses are not fixed — rely on OAuth or the API key for
authentication, and use the IP list to exempt Semji from WAF or anti-bot
challenges rather than as an exclusive allowlist.
## Troubleshooting
Semji discovers your server's OAuth configuration live. Frequent causes,
surfaced with explicit error messages:
* The server does not support **Dynamic Client Registration**
(RFC 7591) — pre-registered clients are not supported.
* The protected-resource metadata is missing or declares no
authorization servers.
* The authorization-server metadata lacks an `authorization_endpoint` or
`token_endpoint`, or is not served over HTTPS.
* Discovery requests time out (10-second limit per request).
Allow popups for the Semji app domain and connect again.
The server's authorization stopped accepting Semji's stored credentials
(for OAuth, the refresh token was rejected). Click **Connect** to
re-authenticate; API-key servers ask for the key again.
* The server isn't enabled on the agent (**Enhancements → Custom MCP
Servers**).
* The server isn't connected for the current user (per-user mode) or the
workspace.
* The API key is wrong — it is stored without a live test, so a bad key
only surfaces at run time.
* A firewall blocks tool calls (see the firewall section above).
Each URL can only be registered once per workspace. Reuse the existing
server, or delete it first if you need to recreate it.
## Related
* [Semji's own MCP server](/mcp/overview) — the reverse direction: use Semji's tools from Claude and other assistants.
# Connect Drupal to Semji
Source: https://developers.semji.com/integrations/drupal
Step-by-step guide to connect your Drupal site to Semji through JSON:API and Basic Auth, so AI agents can read, create, and update content.
Semji connects to Drupal through the **JSON:API** core module, authenticating
every request with **HTTP Basic Auth**. Once connected, Semji's AI agents can
explore your content model, list and read content, create and update it, and
— if you allow it — delete it.
Compatible from **Drupal 8.7** (the version where JSON:API joined Drupal core).
## Prerequisites
* **Drupal 8.7 or newer**, served over **HTTPS** and reachable from the
**public internet**.
* The **JSON:API** core module enabled.
* The **HTTP Basic Authentication** (`basic_auth`) core module enabled.
* For publishing and updating: JSON:API set to read-write — check **"Accept
all JSON:API create, read, update, and delete operations"** under
`/admin/config/services/jsonapi`. JSON:API ships **read-only by default**;
this is the most common blocker.
* A dedicated Drupal user account for Semji, with the permissions listed in
[Permissions for the API account](#permissions-for-the-api-account). A
plain editor role is **not** enough — see that section for why.
* To let agents write meta descriptions: the **Metatag** module, plus a
*Meta tags* field on each content type concerned. See
[Meta descriptions](#meta-descriptions).
* If a firewall, WAF, or anti-bot protection (Cloudflare, Akamai, Sucuri, …)
sits in front of your site, it must **allow Semji's IP addresses** — see
[Firewalls and IP allowlisting](#firewalls-and-ip-allowlisting).
* You are a **workspace owner** in Semji. The connection is shared with the
whole workspace.
Availability of the Drupal integration depends on your Semji plan. If the
**Connect** button opens an upgrade dialog instead of the connection form,
contact your account manager.
## Step 1 — Prepare your Drupal site
In the Drupal admin, go to **Extend** and enable **JSON:API** and **HTTP
Basic Authentication**. Both ship with Drupal core.
Go to **Configuration → Web services → JSON:API**
(`/admin/config/services/jsonapi`) and select **"Accept all JSON:API
create, read, update, and delete operations"**.
Under **People → Roles**, create a role (e.g. *Semji API*) and grant it
the permissions listed in
[Permissions for the API account](#permissions-for-the-api-account).
Then, under **People**, create an account (e.g. `semji-api`) with a strong
password and assign it that role.
Do not reuse a plain editor role: it lacks the permission Semji needs to
set the publication status, and every write fails with a `403`.
Only if agents should write meta descriptions — see
[Meta descriptions](#meta-descriptions) for the full procedure.
## Permissions for the API account
Grant these to the role you created for Semji. Replace `article` with each
content type agents will work on.
| Permission | Why Semji needs it | Required |
| ------------------------------- | ------------------------------------------------- | ----------------------------------------------- |
| `access content` | Read published content | Always |
| `create article content` | Create new content | To create |
| `edit any article content` | Update content the account did not author | To update |
| `use text format basic_html` | Write the body in the text format Semji targets | Always |
| `administer nodes` | Set the publication status (draft vs published) | On content types **without** Content Moderation |
| Workflow transition permissions | Move content between moderation states | On content types **with** Content Moderation |
| `create terms in tags` | Match or create tags in the `tags` vocabulary | If agents attach tags |
| Paragraphs type permissions | Create the paragraphs of a component-based layout | If your site uses **Paragraphs** |
| `delete any article content` | Delete content | Only if you enable the Delete tool |
| `administer node fields` | Read the field definitions of your content types | Optional — see below |
**`administer nodes` is required on content types that do not use Content
Moderation.** Semji always states the publication status explicitly, so
content is never published by accident — and Drupal reserves writing the
`status` field to that permission. Without it, every create and update
fails with *"The current user is not allowed to POST the selected field
(status). The 'administer nodes' permission is required."*
On a content type governed by **Content Moderation**, Semji writes the
moderation state instead, so `administer nodes` is not needed — grant the
transition permissions of your workflow instead.
### About `administer node fields`
This one is **optional but recommended**. With it, Semji reads your content
types' field definitions directly and knows exactly which field holds what.
Without it, Semji falls back to inspecting an existing piece of content to
infer the structure — which works, but is less precise and needs at least one
existing item per content type.
Drupal flags this permission as restricted, because the same permission also
allows *adding and deleting fields* through the admin UI. If your security
policy rules that out, leave it off: the integration works without it.
## Meta descriptions
Drupal core has no meta description field. Semji writes meta descriptions
through the **[Metatag](https://www.drupal.org/project/metatag)** contributed
module, which most Drupal SEO setups already use.
Install the module the way you normally install contributed modules, then
enable it under **Extend**.
Go to **Structure → Content types → *your type* → Manage fields → Create a
new field**, and pick the **Meta tags** field type. Any label and machine
name work — Semji identifies the field by its type, not its name.
Once the field exists, pass the meta description to an agent like any other
instruction ("write the meta description too") — Semji formats and stores it
for you. On an update it **merges** into the tags already on the item, so a
meta title you wrote by hand is preserved.
If a content type has no *Meta tags* field, agents write the content
normally and tell you the meta description could not be saved. They never
fall back to pasting it into the body.
## Step 2 — Connect from Semji
In Semji, go to **Settings → Integrations**, open the **CMS** tab, and
click **Connect** on the Drupal row.
Fill in the three fields:
* **Site URL** — the canonical HTTPS base URL of your site, e.g.
`https://your-site.com`. Semji does not follow redirects, so use the
exact URL your site resolves to (`www` vs non-`www` matters).
* **Username** — the Drupal login of the API account created in step 1.
* **Password** — that account's password.
Click **Next**. Semji tests the connection live by calling
`GET /jsonapi` on your site and verifying that Drupal authenticates the
account.
Choose what AI agents can do on your site. All groups are enabled by
default:
* **Read** — browse, read, and inspect the site's content and structure.
* **Write** — create and update content on the site.
* **Delete** — delete content from the site (destructive).
Click **Activate**. The Drupal row now shows as connected.
## What Semji can do once connected
| Tool group | What it does |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Read | List content types, discover a type's fields and text formats, list and read content (including the raw stored HTML for lossless edits) |
| Write | Create and update content — new content is created as **draft** unless you explicitly ask an agent to publish; tags are matched or created in the `tags` vocabulary; Paragraphs-based layouts are supported |
| Delete | Permanently delete a piece of content (agents ask for explicit confirmation first) |
### Content defaults
Out of the box, Semji targets the **`article`** content type with the
**`basic_html`** text format. On sites using **Content Moderation**, Semji
writes the `published` / `draft` moderation states instead of the raw status
field. Contact Semji support if your site needs different defaults (another
content type, text format, or custom moderation state names).
## Manage or disconnect
From **Settings → Integrations → CMS**, the Drupal row offers:
* **Manage tools** — enable or disable each tool group.
* **Disconnect** — remove the integration for the whole workspace.
## Firewalls and IP allowlisting
Semji's servers call your Drupal JSON:API directly. If your site sits behind
Cloudflare or another WAF, you must allow those calls or the connection will
fail.
Semji's servers reach the internet through a fixed set of outbound IP addresses. If a firewall, WAF, or anti-bot protection (Cloudflare, Akamai, Sucuri, …) sits in front of your site, it can block Semji's requests — typically with a `403 Forbidden` — before they ever reach your CMS, even when your credentials are correct.
Allow the following Semji IP addresses:
```text theme={null}
63.34.75.122
63.35.78.179
54.228.104.165
18.200.156.37
34.248.117.83
52.213.28.177
```
On **Cloudflare**, create a WAF custom rule with the **Skip** action matching
requests where the source IP is one of the addresses above and the path
starts with `/jsonapi/`.
A WAF answering `403 Forbidden` on `/jsonapi` makes Semji report **"Invalid
Drupal credentials"** even though your username and password are correct.
If you are sure of the credentials, check the firewall first.
## Troubleshooting
Drupal did not authenticate the request. Either the username or password
is wrong, the **`basic_auth`** module is not enabled (Drupal then treats
the request as anonymous), or a WAF is blocking the request with a `403`
before it reaches Drupal.
Frequent causes:
* **JSON:API module disabled** — `/jsonapi` returns 404.
* **The URL redirects.** Semji follows no redirects: if
`https://example.com` redirects to `https://www.example.com`, enter the
`www` URL.
* **The site is unreachable** — DNS failure, host down, or the domain
resolves to a private network address.
* **A WAF challenge page** — an HTML anti-bot response is not valid
JSON:API and fails the connection.
* **The site is too slow** — responses must arrive within 10 seconds.
*"Drupal JSON:API is configured to accept only read operations."* — an
administrator must enable **"Accept all JSON:API create, read, update,
and delete operations"** under `/admin/config/services/jsonapi`.
*"Drupal authentication or permission error: the API account cannot
create or update this content."* — the API account lacks create/edit
permission on the target content type. For Paragraphs-based sites:
*"check Paragraphs Type Permissions for the API account."*
Check whether the account has **`administer nodes`**. Drupal reserves
writing the `status` field to that permission, and Semji always states
the publication status explicitly, so an account without it fails on
every create and update — even though reading works fine and the
connection test passes. See
[Permissions for the API account](#permissions-for-the-api-account).
If your site is in **maintenance mode**, Drupal answers `503` on
`/jsonapi` for every account without the *"Use the site in maintenance
mode"* permission — which the API account normally does not have. You can
browse the site yourself because your admin session bypasses it. Turn
maintenance mode off under **Configuration → Development → Maintenance
mode**, or grant that permission to the Semji role.
Two distinct cases:
* *No meta description field on this content type* — install **Metatag**
and add a *Meta tags* field, as described in
[Meta descriptions](#meta-descriptions).
* *Drupal accepted the write but stored nothing* — Semji could not read
your field definitions and guessed the wrong format. Grant
**`administer node fields`** to the API account, or set that one meta
description by hand.
In both cases the rest of the content is written normally.
* *"Please enter a valid URL."* — the value does not parse as a URL.
* *"The site URL must start with https\://."* — HTTP sites cannot be
connected.
* *"The site URL points to a private or reserved address, which is not
allowed."* — localhost and private-network hosts are rejected; the site
must be publicly reachable.
## Related
* [Sync drafts to your CMS](/guides/sync-drafts-to-cms) — API-based publishing flow, if you prefer to drive the sync yourself.
# Connect Magento to Semji
Source: https://developers.semji.com/integrations/magento
Step-by-step guide to connect your Magento 2 store to Semji through the REST Admin API and an Integration access token, so AI agents can read and update CMS pages, blocks, product and category content.
Semji connects to Magento through the **REST Admin API**, authenticating every
request with an **Integration access token** sent as a Bearer credential. Once
connected, Semji's AI agents can work on the four kinds of content a Magento
store carries: **CMS pages**, **CMS blocks**, **product content** (descriptions
and SEO fields), and **category content** (the SEO text of category pages).
Compatible with **Magento 2.4** — Magento Open Source and Adobe Commerce,
self-hosted or on Adobe Commerce on cloud infrastructure (PaaS).
## Prerequisites
* **Magento 2.4.x**, Open Source or Adobe Commerce, served over **HTTPS** with
a certificate from a publicly-trusted CA, and reachable from the **public
internet**. Private hostnames, self-signed certificates, and plain HTTP are
rejected.
* A dedicated **Integration** created for Semji — never a personal admin
user's token. See [Step 1](#step-1-prepare-your-magento-store).
* The Integration's **API permissions (Resource Access)** granted as listed in
[API permissions for the Integration](#api-permissions-for-the-integration).
* On **Magento 2.4.4 and above**: the setting **"Allow OAuth Access Tokens to
be used as standalone Bearer tokens"** enabled — see
[Step 1](#step-1-prepare-your-magento-store). Without it, every request
returns `401` even with a valid token.
* If a firewall, WAF, or anti-bot protection (Fastly, Cloudflare, Akamai, …)
sits in front of your store, it must **allow Semji's IP addresses** on the
REST API path — see
[Firewalls and IP allowlisting](#firewalls-and-ip-allowlisting).
* You are a **workspace owner** in Semji. The connection is shared with the
whole workspace.
Availability of the Magento integration depends on your Semji plan. If the
**Connect** button opens an upgrade dialog instead of the connection form,
contact your account manager.
**Adobe Commerce as a Cloud Service** (the newer SaaS offering that
authenticates through Adobe IMS OAuth) is not supported. The connector
authenticates exclusively with an Integration access token used as a Bearer
credential.
## Step 1 — Prepare your Magento store
In the Magento admin, go to **System → Extensions → Integrations** and
click **Add New Integration**. Give it a recognizable name (e.g.
*Semji*).
Do not reuse a personal admin account's token: an Integration can be
scoped to exactly the permissions Semji needs, it survives admin account
changes or offboarding, and it can be revoked independently without
affecting any human user.
On the Integration's **API** tab, set **Resource Access** to **Custom**
and tick every checkbox listed in
[API permissions for the Integration](#api-permissions-for-the-integration).
Setting **Resource Access** to **All** also works if your security policy
allows it — you can narrow it down later.
Save, then click **Activate** on the Integration's row and approve the
permission summary. Magento displays four credentials — Semji only needs
the **Access Token**. Copy it.
Since Magento 2.4.4, the REST Admin API rejects Integration access tokens
used as bare Bearer tokens unless the store explicitly opts in. Enable:
**Stores → Configuration → Services → OAuth → Consumer Settings →
"Allow OAuth Access Tokens to be used as standalone Bearer tokens" → Yes**
or via CLI:
```bash theme={null}
bin/magento config:set oauth/consumer/enable_integration_as_bearer 1
bin/magento cache:flush
```
A cache flush may be needed for the change to take effect.
## API permissions for the Integration
On the Integration's **API** tab, with **Resource Access** set to **Custom**,
tick the following checkboxes:
| Checkbox to tick | ACL resource | Why Semji needs it | Required |
| -------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------- |
| Stores → Settings → **All Stores** | `Magento_Backend::store` | List store views and their base URLs; validate the connection | Always |
| Stores → Attributes → **Product** | `Magento_Catalog::attributes_attributes` | Discover which product text fields exist before writing anything (read-only) | For product content |
| Content → Elements → **Pages** | `Magento_Cms::page` | Read and search CMS pages | For CMS pages |
| Content → Elements → Pages → **Save Page** | `Magento_Cms::save` | Create and update CMS pages | For CMS pages |
| Content → Elements → **Blocks** | `Magento_Cms::block` | Read, create and update CMS blocks | For CMS blocks |
| Catalog → Inventory → **Products** | `Magento_Catalog::products` | Read and update product content | For product content |
| Catalog → Inventory → **Categories** | `Magento_Catalog::categories` | Read and update category content | For category content |
| Content → Elements → Pages → **Delete Page** | `Magento_Cms::page_delete` | Delete CMS pages | Only if you enable the Delete tool |
**The product-attributes checkbox lives under Stores, not under Catalog.**
`Stores → Attributes → Product` and `Catalog → Inventory → Products` are two
unrelated branches of the permission tree despite the similar names. Ticking
the Catalog one without the Stores one breaks content-model discovery for
products.
**"Save Page" is a separate permission from "Pages".** Reading pages and
writing pages are distinct ACL resources in Magento. Ticking the **Pages**
parent checkbox selects its children automatically, but if you compose the
selection checkbox by checkbox, do not skip **Save Page** — reads will work
and every create or update will fail with a `401`.
If the permissions look right but requests still return `401`, re-open the
Integration, re-select the resources, and **save again**. A partially saved
custom selection can leave the permission state inconsistent, and a clean
re-save repairs it.
## Step 2 — Connect from Semji
In Semji, go to **Settings → Integrations**, open the **CMS** tab, and
click **Connect** on the Magento row.
Fill in the two fields:
* **Store URL** — the canonical HTTPS base URL of your store, e.g.
`https://your-store.com`. Semji does not follow redirects, so use the
exact URL your store resolves to (`www` vs non-`www` matters).
* **Integration access token** — the Access Token copied in Step 1.
Click **Next**. Semji tests the connection live by calling
`GET /rest/all/V1/store/websites` on your store with the token.
Choose what AI agents can do on your store. Every tool is enabled by
default; you can disable any of them:
* **List store views** — read the store views and their base URLs.
* **Describe content model** — inspect the writable fields of pages,
blocks, products and categories.
* **List contents** — browse and search CMS pages, blocks, products and
categories.
* **Read contents** — retrieve a page, block, product or category and its
content.
* **Create & update content** — create and update CMS pages and blocks,
update product and category content.
* **Delete content** — delete CMS pages and blocks.
Click **Activate**. The Magento row now shows as connected.
## What Semji can do once connected
| Content kind | Read | Create | Update | Notes |
| ---------------- | ---- | ------ | ------ | --------------------------------------------------------------------------------------- |
| CMS pages | ✓ | ✓ | ✓ | New pages are created **disabled (draft)**; publishing requires an explicit instruction |
| CMS blocks | ✓ | ✓ | ✓ | Same draft-first behavior as pages |
| Product content | ✓ | — | ✓ | Description and SEO fields, written as product attributes |
| Category content | ✓ | — | ✓ | The SEO text of category pages |
A few guarantees built into the connector:
* **Products and categories are never created or deleted** — Semji only
updates the content fields of entities that already exist.
* **Product and category edits go live immediately** (Magento has no draft
state for them), so agents show the exact field-by-field change and wait
for your explicit validation before writing.
* **Page and block updates are lossless.** Magento's save endpoints replace
the whole entity, so Semji reads the current entity, merges only the fields
the agent changed, and writes the full payload back — anything the agent
did not touch is preserved.
* **Deletion is never exposed to publishing agents.** Even with the Delete
tool enabled, Semji's CMS Publisher agent is deliberately not given access
to it.
### Store views
Magento content is **per store view**: the same page, product, or category
can carry different content on each store view.
* Agents read the store views first and target an **explicit store view**
when you name a specific store, language, or locale.
* On multi-store installations, agents verify that the entry they are about
to touch belongs to your workspace's website before writing, and refuse to
modify content that belongs to another site hosted on the same Magento
instance.
* Product and category writes never target Magento's `all` scope — a
[known Magento issue](https://github.com/magento/magento2/issues/11324)
can silently reassign website associations on writes scoped to `all`.
## Manage or disconnect
From **Settings → Integrations → CMS**, the Magento row offers:
* **Manage tools** — enable or disable each tool individually.
* **Disconnect** — remove the integration for the whole workspace.
## Firewalls and IP allowlisting
Semji's servers call your store's REST Admin API directly. If your store sits
behind Fastly, Cloudflare, or another WAF, you must allow those calls or the
connection will fail.
Semji's servers reach the internet through a fixed set of outbound IP addresses. If a firewall, WAF, or anti-bot protection (Cloudflare, Akamai, Sucuri, …) sits in front of your site, it can block Semji's requests — typically with a `403 Forbidden` — before they ever reach your CMS, even when your credentials are correct.
Allow the following Semji IP addresses:
```text theme={null}
63.34.75.122
63.35.78.179
54.228.104.165
18.200.156.37
34.248.117.83
52.213.28.177
```
Allow these addresses on the REST API path (`/rest/*`). On stores where the
edge layer rate-limits or challenges API traffic, exempt Semji's addresses
from those rules as well.
A WAF answering with an HTML challenge or a `403` on `/rest/*` makes Semji
report a security-layer error even though your token and permissions are
correct. If the credentials look right, check the firewall first.
## Troubleshooting
Magento returns the same `401` status for three different problems —
check them in order:
1. **The token value is wrong** — re-copy the Access Token from the
Integration (System → Extensions → Integrations). Note that
re-creating or re-authorizing an Integration generates a new token.
2. **On Magento 2.4.4+, the Bearer setting is off** — enable *"Allow
OAuth Access Tokens to be used as standalone Bearer tokens"* (see
[Step 1](#step-1-prepare-your-magento-store)) and flush the cache.
3. **The Integration lacks API permissions** — Magento answers `401`
with *"The consumer isn't authorized to access %resources"* naming
the missing ACL resource. Grant every checkbox in
[API permissions for the Integration](#api-permissions-for-the-integration),
save again, and retry.
A quick way to tell cases 1–2 apart from case 3: if **some** REST
endpoints respond with the token while others return `401`, the token
and the Bearer setting are fine — the remaining `401`s are missing
permissions.
`GET /rest/all/V1/store/websites` returned a `404`. Frequent causes:
* The **Store URL is wrong** or points to a specific store-view path.
Enter the bare base URL of the store.
* **The URL redirects.** Semji follows no redirects: if
`https://example.com` redirects to `https://www.example.com`, enter
the `www` URL.
* A reverse proxy in front of Magento **strips or blocks the `/rest`
path**.
The response came from a WAF or anti-bot layer (a `403`, or a non-JSON
challenge page) instead of Magento. Allow Semji's IP addresses on
`/rest/*` — see
[Firewalls and IP allowlisting](#firewalls-and-ip-allowlisting).
* **DNS failure or host down.**
* **The store is too slow** — responses must arrive within 10 seconds.
* **The URL points to a private or reserved address** — localhost and
private-network hosts are rejected; the store must be publicly
reachable.
The token authenticates, but the Integration lacks the ACL resource for
that specific action — for example, pages can be read but not saved
(missing **Save Page**), or products cannot be listed (missing
**Catalog → Inventory → Products**). Grant the corresponding checkbox
from
[API permissions for the Integration](#api-permissions-for-the-integration)
and save the Integration again.
That is Magento's behavior, not a bug: products and categories have no
draft state, so every write is live on the storefront. Semji's agents
show the exact diff and require your explicit validation before each
product or category write for this reason. CMS pages and blocks, in
contrast, are created as drafts.
* *"Please enter a valid URL."* — the value does not parse as a URL.
* *"The store URL must start with https\://."* — HTTP stores cannot be
connected.
* *"The store URL points to a private or reserved address, which is not
allowed."* — the store must be publicly reachable.
## Related
* [Sync drafts to your CMS](/guides/sync-drafts-to-cms) — API-based
publishing flow, if you prefer to drive the sync yourself.
# Connect your CMS to Semji
Source: https://developers.semji.com/integrations/overview
Overview of Semji's CMS integrations — WordPress, Drupal, Magento, Contentful, Strapi, custom MCP servers, and the Send to CMS webhook — with step-by-step connection guides.
Connect your CMS to let Semji's AI agents work directly on your content —
list and read what's there, create new drafts, and update existing pages.
Each integration is connected once by a **workspace owner** from
**Settings → Integrations** and is typically shared with the whole workspace
(custom MCP servers can also be connected per user).
Connect with an application password over the WordPress REST API.
Compatible from WordPress 5.6.
Connect through JSON:API and Basic Auth. Compatible from Drupal 8.7.
Connect through the REST Admin API with an Integration access token.
Compatible with Magento 2.4.
One-click OAuth through Contentful's hosted MCP server — no keys to copy.
Connect your instance's MCP endpoint with an admin token. Compatible from
Strapi 5.47.0.
Bring your own MCP server — a custom CMS bridge or internal tool — via
OAuth or an API key.
Semji posts each draft to your endpoint when an editor clicks *Send to CMS*;
you place it and return a review link, synchronously or via a callback.
## At a glance
| Integration | How it connects | You provide | Minimum version |
| -------------------------------------------------------- | ----------------------------------------- | ---------------------------------------- | --------------- |
| [WordPress](/integrations/wordpress) | REST API + application password | Site URL, username, application password | WordPress 5.6 |
| [Drupal](/integrations/drupal) | JSON:API + Basic Auth | Site URL, username, password | Drupal 8.7 |
| [Magento](/integrations/magento) | REST Admin API + Integration access token | Store URL, integration access token | Magento 2.4 |
| [Contentful](/integrations/contentful) | Contentful's hosted MCP server (OAuth) | A Contentful sign-in — nothing to copy | All versions |
| [Strapi](/integrations/strapi) | Your instance's MCP endpoint | MCP server URL, admin token | Strapi 5.47.0 |
| [Custom MCP server](/integrations/custom-mcp) | Your own MCP server | Server URL + OAuth or API key | — |
| [Send to CMS webhook](/integrations/send-to-cms-webhook) | Outbound webhook + tokenised callback | An HTTPS endpoint URL | — |
**Behind a firewall or WAF?** Semji's requests can be blocked before they
reach your CMS — the most common cause of failed connections. Each guide
above has a dedicated network section explaining what, if anything, to
allow — including Semji's outbound IP addresses.
## Push content to your CMS yourself
If you'd rather push content to your CMS instead of letting Semji's agents work
on it directly, two options work with any backend:
* **[Send to CMS webhook](/integrations/send-to-cms-webhook)** — Semji posts a
draft to your endpoint when an editor clicks *Send to CMS*; you place it and
return a review link, synchronously or through a callback.
* **[Sync drafts to your CMS](/guides/sync-drafts-to-cms)** — an API-based
publishing flow you drive on a schedule.
# Send to CMS webhook
Source: https://developers.semji.com/integrations/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.
`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.
## 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
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.
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.
When the work is done, `POST` the outcome to the `callback.url` from the
payload, authenticated with the `callback.token`.
## 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": "…sanitized content…
",
"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"
}
}
```
Always `content_staged` for this webhook.
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.
Version of the content at send time. A later send of the same content carries a
higher version — the latest send wins.
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.
`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).
## 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):
`completed`, `failed`, or `accepted`.
Required when `status` is `completed`. Must contain `cms_id` **and at least one**
of `preview_url` / `back_office_url`.
Your CMS's identifier for the created/updated entry.
Public preview link an editor can open to review the content.
Link to the entry in your CMS admin.
Optional human-readable reason when `status` is `failed`.
### 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" }
```
A `completed` response **without** `cms_id`, or without any URL, is a contract
violation — Semji records the send as failed.
### 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.
```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"}}'
```
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. |
**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.
## 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
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.
The `Authorization` header must be `Bearer ` using the exact
`callback.token` from the payload of that send. Tokens are per-send.
The callback expired — more than 24 hours passed since the send. Trigger a new
send from the editor to get a fresh callback.
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`.
## 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.
# Connect Strapi to Semji
Source: https://developers.semji.com/integrations/strapi
Connect your Strapi instance to Semji via its MCP endpoint and an admin token, so AI agents can read and update your content.
Semji connects to Strapi through your instance's **MCP endpoint**. You
provide the endpoint URL and an **admin token**; Semji sends the token as a
bearer credential on every request. Once connected, Semji's AI agents can
read and update content in Strapi. Semji uses Strapi's built-in MCP server —
see the [Strapi MCP server documentation](https://docs.strapi.io/cms/features/strapi-mcp-server)
for how to enable it on your instance.
Compatible from **Strapi 5.47.0**.
## Prerequisites
* A **Strapi 5.47.0 or newer** instance exposing its MCP endpoint.
* The MCP endpoint served over **HTTPS**, reachable from the **public
internet**, at a URL ending in `/mcp` (e.g.
`https://your-cms.example.com/mcp`).
* An **admin token** for that endpoint, created as described in the
[Strapi MCP server documentation](https://docs.strapi.io/cms/features/strapi-mcp-server).
Semji sends it as `Authorization: Bearer ` on every request.
* You are a **workspace owner** in Semji. The connection is shared with the
whole workspace.
Availability of the Strapi integration depends on your Semji plan. If the
**Connect** button opens an upgrade dialog instead, contact your account
manager.
## Connect from Semji
In Semji, go to **Settings → Integrations**, open the **CMS** tab, and
click **Connect** on the Strapi row.
Fill in the two fields:
* **MCP server URL** — your Strapi MCP endpoint, e.g.
`https://your-cms.example.com/mcp`. It must use HTTPS and end with
`/mcp`.
* **Admin token** — the token from your Strapi instance.
Click **Connect**. The Strapi row now shows as connected for the whole
workspace.
Semji does **not** test the token or the endpoint when you connect — a
mistyped token or unreachable URL still shows as "Connected". Verify the
connection by asking an AI agent to list content from Strapi: if the agent
reports that no Strapi tools are available or hits an authentication
error, reconnect with a corrected token, or set up the connection again
with the right URL.
## What Semji can do once connected
The available actions are provided by your Strapi MCP endpoint — typically
reading and updating content. AI agents discover the available tools at run
time, and Semji's publishing agents never delete content, even when a delete
tool is exposed.
## Manage or disconnect
From **Settings → Integrations → CMS**, the Strapi row offers **Disconnect**,
which clears the stored token for the whole workspace. Reconnecting asks for
the token again. The MCP server URL cannot be edited in place — to change
it, disconnect and set up the connection again with the new URL.
## Firewalls and IP allowlisting
Your Strapi MCP endpoint must be reachable from the public internet.
Semji's servers reach the internet through a fixed set of outbound IP addresses. If a firewall, WAF, or anti-bot protection (Cloudflare, Akamai, Sucuri, …) sits in front of your site, it can block Semji's requests — typically with a `403 Forbidden` — before they ever reach your CMS, even when your credentials are correct.
Allow the following Semji IP addresses:
```text theme={null}
63.34.75.122
63.35.78.179
54.228.104.165
18.200.156.37
34.248.117.83
52.213.28.177
```
Do **not** restrict access to your MCP endpoint to the IP list above alone.
AI agent tool calls are executed from Semji's AI provider infrastructure,
whose addresses are not fixed — rely on the admin token for
authentication, and use the IP list to exempt Semji from WAF or anti-bot
challenges rather than as an exclusive allowlist.
## Troubleshooting
Enter the full MCP endpoint URL, not your Strapi root URL — e.g.
`https://your-cms.example.com/mcp`.
The connector could not be created. Check that the URL starts with
`https://`, that the same URL is not already connected in this
workspace, and that you are a workspace owner.
The token could not be stored. Make sure it is not empty and try again.
The stored token is wrong or revoked, or the endpoint is unreachable or
blocked by a firewall. Verify the endpoint answers at its URL, check your
WAF (see the firewall section above), then disconnect and reconnect with
a valid token.
# Connect WordPress to Semji
Source: https://developers.semji.com/integrations/wordpress
Step-by-step guide to connect your WordPress site to Semji with an application password, so AI agents can list, read, create, and update your content — posts, pages, and custom post types, with taxonomies, ACF fields, and SEO titles and meta descriptions via Yoast SEO, Rank Math, or SEOPress.
Semji connects to WordPress through the WordPress REST API (`/wp-json/`) using an **application password**. Once connected, Semji's AI agents can list, read, create, and update content on your site — built-in posts and pages as well as custom post types (FAQ, news, landing pages, …) — fill your Advanced Custom Fields (ACF), file articles in the right categories, tags, and custom taxonomies, and update your SEO titles and meta descriptions whatever your SEO plugin (Yoast SEO, Rank Math, or SEOPress). Each action can be toggled individually.
Compatible from **WordPress 5.6**.
## Prerequisites
* **WordPress 5.6 or newer** — application passwords became a core feature in 5.6.
* A site served over **HTTPS** and reachable from the **public internet**. Private or reserved addresses (localhost, `10.x`, `192.168.x`, …) are rejected.
* The **REST API enabled** at `https://your-site.com/wp-json/`. It is on by default, but some security plugins disable it.
* A WordPress user allowed to manage posts (**Author** role or higher — use **Editor** if agents should be able to update any post).
* If a firewall, WAF, or anti-bot protection (Cloudflare, Akamai, Sucuri, …) sits in front of your site, it must **allow Semji's IP addresses** — see [Firewalls, Cloudflare, and IP allowlisting](#firewalls-cloudflare-and-ip-allowlisting).
* You are a **workspace owner** in Semji. The connection is shared with the whole workspace.
Availability of the WordPress integration depends on your Semji plan. If the
**Connect** button opens an upgrade dialog instead of the connection form,
contact your account manager.
## Step 1 — Create an application password in WordPress
In wp-admin, go to **Users → Profile** (or **Users → All Users** and open
the account Semji will use).
Scroll down to the **Application Passwords** section, enter a name such as
`Semji`, and click **Add New Application Password**.
WordPress displays the password **only once**, formatted like
`xxxx xxxx xxxx xxxx xxxx xxxx`. Copy it now — you can paste it into Semji
with or without the spaces.
Don't see the **Application Passwords** section? Your site is either not
served over HTTPS, running a WordPress version older than 5.6, or a security
plugin has disabled the feature.
## Step 2 — Connect from Semji
In Semji, go to **Settings → Integrations**, open the **CMS** tab, and
click **Connect** on the WordPress row.
Fill in the three fields:
* **Site URL** — the canonical HTTPS URL of your site, e.g.
`https://your-site.com`. Semji does not follow redirects, so use the
exact URL your site resolves to (`www` vs non-`www` matters — see
[troubleshooting](#troubleshooting)).
* **Email or username** — the WordPress login or email address of the
user who owns the application password. WordPress accepts either: it
matches the username first, then falls back to the email address.
* **Application password** — the password generated in step 1.
Click **Next**. Semji tests the connection live by calling
`GET /wp-json/wp/v2/users/me` on your site with these credentials, and
detects which SEO plugin your site runs.
Choose what AI agents can do on your site. All six tools are enabled by
default: **Create posts**, **Read posts**, **List post types**, **List
posts**, **List taxonomy terms**, and **Update posts**. You can change
this selection at any time later.
**Update posts** also covers the SEO title and meta description, which
may need a prerequisite configuration on your WordPress site depending on
your SEO plugin — see [Enable SEO title and meta description
updates](#enable-seo-title-and-meta-description-updates). And if your
content types use ACF fields, expose them to the REST API so agents can
read and fill them — see [Expose ACF fields to
Semji](#expose-acf-fields-to-semji).
Click **Activate**. The WordPress row now shows as connected.
## What Semji can do once connected
| Tool | What it does |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List post types | Discover the site's content types — built-in posts and pages plus custom post types (FAQ, news, …) with the taxonomies attached to each — so agents publish in the right place |
| List posts | Search and list posts of any content type, with status filters and pagination |
| Read posts | Fetch a post's full content: raw editor source, SEO title and meta description, taxonomy terms, and ACF fields when the site exposes them |
| Create posts | Create new posts on any content type — always as **draft** unless you explicitly ask an agent to publish — with categories, tags, custom taxonomy terms, and ACF fields |
| Update posts | Update only the fields you change: title, content, status, slug, featured image, categories, tags, custom taxonomy terms, ACF fields, SEO title, and meta description (the SEO fields go through your SEO plugin — may require a prerequisite configuration, see [below](#enable-seo-title-and-meta-description-updates)) |
| List taxonomy terms | Search and list the terms of any taxonomy — categories with their hierarchy, tags, or custom taxonomies (authors, thematics, …) — so agents resolve term names to IDs before filing an article |
## Supported SEO plugins
Semji detects your SEO plugin automatically — no configuration to pick a
provider. Detection reads the namespaces advertised by your REST API index
(`/wp-json/`), and falls back to inspecting the SEO fields exposed on your
posts when a security plugin filters the index. The SEO title and meta
description are then read and written through the detected plugin's own
fields:
| SEO plugin | SEO title field | Meta description field |
| ---------- | ------------------------ | ----------------------- |
| Yoast SEO | `_yoast_wpseo_title` | `_yoast_wpseo_metadesc` |
| Rank Math | `rank_math_title` | `rank_math_description` |
| SEOPress | `_seopress_titles_title` | `_seopress_titles_desc` |
**All in One SEO is not supported.** It stores its metadata in a custom
database table instead of standard post fields, so it cannot be updated
through the WordPress REST API. On AIOSEO sites, SEO updates report an
explicit warning while the rest of the post update still applies;
everything else in the integration works normally.
If several SEO plugins are active at the same time (typically during a
migration), Semji picks the first supported one in this order: Yoast SEO,
then Rank Math, then SEOPress. Deactivate the plugin you no longer use so
updates target the one that actually renders your pages.
## Enable SEO title and meta description updates
Whether a prerequisite is needed depends on your SEO plugin and its version:
* **Yoast SEO 28.1 and newer** — nothing to do. Recent Yoast versions expose
their SEO title and meta description fields to the REST API out of the box,
and Semji can update them right away.
* **Older Yoast SEO, Rank Math, and SEOPress** — these store the SEO title and
meta description in protected custom fields that the REST API does not
accept writes on by default. A small PHP snippet must register the fields
with the REST API first.
There are two equivalent ways to install the snippet — entirely from the
WordPress admin with a snippet plugin (no server access needed), or as a
**must-use plugin** file dropped on the server. Pick one. The same snippet
covers all three supported SEO plugins and every public post type (posts,
pages, and custom post types alike), so it keeps working if you later switch
SEO plugins or add content types.
Writes remain protected by WordPress permissions: only users allowed to edit
posts (the `edit_posts` capability) can modify the fields — the same
permission level as editing the post itself.
Use a snippet plugin such as [Code
Snippets](https://wordpress.org/plugins/code-snippets/) — it stores the
code in the database and runs it on every load, so nothing touches the
server's filesystem.
In wp-admin, go to **Plugins → Add New Plugin**, search for **Code
Snippets**, then install and activate it.
In the new **Snippets** menu, click **Add New**, give the snippet a
name such as `Semji — SEO fields REST support`, and paste the code
below. It starts without the opening ` ['_yoast_wpseo_title', '_yoast_wpseo_metadesc'],
'RANK_MATH_VERSION' => ['rank_math_title', 'rank_math_description'],
'SEOPRESS_VERSION' => ['_seopress_titles_title', '_seopress_titles_desc'],
];
foreach ($seo_meta_keys_by_plugin_constant as $plugin_constant => $seo_meta_keys) {
if (!defined($plugin_constant)) {
continue;
}
foreach (get_post_types(['public' => true], 'names') as $post_type) {
foreach ($seo_meta_keys as $meta_key) {
register_post_meta($post_type, $meta_key, [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'auth_callback' => static function () {
return current_user_can('edit_posts');
},
]);
}
}
}
}, 20);
```
The snippet detects which of the three plugins is active and only
registers that plugin's fields, so it keeps working if you later
switch SEO plugins.
Keep the scope on **Run snippet everywhere** — REST API requests must
load it too, so do not restrict it to the admin area — then click
**Save Changes and Activate**.
Deactivating the snippet removes the capability.
A must-use plugin is a single PHP file dropped into
`wp-content/mu-plugins/`. Must-use plugins are loaded automatically by
WordPress — there is nothing to activate, and removing the file removes
the capability.
On your server, create the directory `wp-content/mu-plugins/` if it
does not exist yet, then create a file named
`semji-seo-fields.php` inside it.
```php semji-seo-fields.php theme={null}
['_yoast_wpseo_title', '_yoast_wpseo_metadesc'],
'RANK_MATH_VERSION' => ['rank_math_title', 'rank_math_description'],
'SEOPRESS_VERSION' => ['_seopress_titles_title', '_seopress_titles_desc'],
];
foreach ($seo_meta_keys_by_plugin_constant as $plugin_constant => $seo_meta_keys) {
if (!defined($plugin_constant)) {
continue;
}
foreach (get_post_types(['public' => true], 'names') as $post_type) {
foreach ($seo_meta_keys as $meta_key) {
register_post_meta($post_type, $meta_key, [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'auth_callback' => static function () {
return current_user_can('edit_posts');
},
]);
}
}
}
}, 20);
```
### Verify the fields are exposed
Whichever option you chose, call your REST API with the same credentials
Semji uses, on any post ID, checking your plugin's meta description field
(here Rank Math — swap in the field from the [table
above](#supported-seo-plugins) for Yoast SEO or SEOPress):
```bash theme={null}
curl -s -u 'USERNAME:APP_PASSWORD' \
'https://your-site.com/wp-json/wp/v2/posts/123' | jq '.meta.rank_math_description'
```
A string — even an empty one (`""`) — means the field is exposed and Semji
can update it. `null` means the code is not running yet.
Without this prerequisite configuration, the SEO part of an **Update posts**
call is skipped: the rest of the post update still applies, and the agent
receives an explicit warning naming your SEO plugin and the missing field —
agents will tell you the field is not exposed rather than pretend the update
succeeded. Everything else in the integration works normally.
## Expose ACF fields to Semji
Many WordPress sites structure their content with [Advanced Custom
Fields](https://www.advancedcustomfields.com/) (ACF) — for example a FAQ
custom post type whose question and answer live in dedicated fields rather
than in the post body. Semji's agents handle those fields natively: **Read
posts** returns a post's ACF values, and **Create posts** / **Update posts**
fill them.
This only works for fields visible through the REST API, and **ACF does not
expose field groups by default**: out of the box the `acf` key is absent from
your site's REST responses, so agents cannot see the fields — let alone fill
them — and will tell you so during a publication.
Unlike the SEO fields above, no PHP snippet is needed: ACF (free and Pro,
version 5.11 or newer) ships native REST API support behind a single setting.
The **Secure Custom Fields** fork offers the same setting.
### Enable "Show in REST API" on the field group
The setting lives on the **field group**, not on each individual field:
In wp-admin, go to **ACF → Field Groups** and open the group attached to
your content type (e.g. *FAQ*).
In the field group's **Settings** panel, turn on **Show in REST API**,
then save the group.
Do the same for every field group agents should be able to read and
fill.
For a **custom post type**, the type itself must also be visible in the
REST API — otherwise agents cannot even list it. If it was created through
ACF's **Post Types** UI, enable **Show in REST API** in the post type's
**Advanced settings**; if it is registered in code, pass
`'show_in_rest' => true` to `register_post_type()`.
### Verify the fields are exposed
Call your REST API with the same credentials Semji uses, on any existing item
— swap `faq` for the rest base of your content type (`posts` for built-in
posts):
```bash theme={null}
curl -s -u 'USERNAME:APP_PASSWORD' \
'https://your-site.com/wp-json/wp/v2/faq/123' | jq '.acf'
```
An object listing your fields — even with empty values — means they are
exposed and agents can read and fill them. `null` means the field group is
not exposed yet.
### Help agents fill the fields correctly
The REST API carries only field **names** and values: the labels and
instructions you see in wp-admin stay invisible to agents. Agents discover a
type's fields by reading an existing item, so a few habits make the fills
reliable:
* **Use descriptive field names** — an agent understands `answer_short`;
it can only guess at `field_2`.
* **Keep at least one well-filled item** of each content type: agents read
it to learn which field holds what, and the expected value shape (plain
text vs HTML, media ID for an image field, …).
* **Brief the agent** — when field names are ambiguous, say what each field
is for in your instructions; the agent will map its content accordingly.
## Manage or disconnect
From **Settings → Integrations → CMS**, the WordPress row offers:
* **Manage tools** — enable or disable each of the six tools.
* **Disconnect** — remove the integration for the whole workspace.
## Firewalls, Cloudflare, and IP allowlisting
Semji's servers call your WordPress REST API directly. If your site sits
behind Cloudflare or another WAF, you must allow those calls or the
connection will fail.
Semji's servers reach the internet through a fixed set of outbound IP addresses. If a firewall, WAF, or anti-bot protection (Cloudflare, Akamai, Sucuri, …) sits in front of your site, it can block Semji's requests — typically with a `403 Forbidden` — before they ever reach your CMS, even when your credentials are correct.
Allow the following Semji IP addresses:
```text theme={null}
63.34.75.122
63.35.78.179
54.228.104.165
18.200.156.37
34.248.117.83
52.213.28.177
```
On **Cloudflare**, create a WAF custom rule with the **Skip** action matching
requests where the source IP is one of the addresses above and the path
starts with `/wp-json/`.
A WAF block is indistinguishable from bad credentials: when a protection
layer answers `403 Forbidden` on `/wp-json/`, Semji shows **"Invalid
WordPress credentials"** even though your login and application password
are correct. If you are sure of the credentials, check the firewall first.
## Troubleshooting
Your site answered `401` or `403`. Either the login (email or username)
or application password is wrong or was revoked, application passwords are disabled on
the site, or — very commonly — a WAF or anti-bot layer (Cloudflare,
Wordfence, …) is blocking Semji before the request reaches WordPress. See
[Firewalls, Cloudflare, and IP
allowlisting](#firewalls-cloudflare-and-ip-allowlisting).
Semji could not complete the request. Frequent causes:
* **The URL redirects.** Semji follows no redirects: if
`https://example.com` redirects to `https://www.example.com`, enter the
`www` URL. Same for HTTP→HTTPS or trailing-path redirects.
* **The REST API is not served** — `/wp-json/` returns 404 (REST API
disabled by a plugin, or permalink/rewrite issues on the server).
* **The site is unreachable** — DNS failure, host down, or the domain
resolves to a private network address.
* **The site is too slow** — responses must arrive within 10 seconds.
* *"Please enter a valid URL."* — the value does not parse as a URL.
* *"The site URL must start with https\://."* — HTTP sites cannot be
connected.
* *"The site URL points to a private or reserved address, which is not
allowed."* — localhost and private-network hosts are rejected; the site
must be publicly reachable.
* *"WordPress authentication failed. Please check the credentials in the
integration settings."* — the application password was revoked or
changed since connection. Reconnect with a fresh one.
* *"No WordPress integration configured for this workspace."* — the
integration was disconnected; a workspace owner must reconnect it.
* *"The post was updated, but its SEO metadata was NOT: no supported SEO
plugin (Yoast SEO, Rank Math or SEOPress) was detected…"* — Semji found
neither Yoast SEO, Rank Math, nor SEOPress on the site. Activate one of
them, or if one is already active, apply the [prerequisite
configuration](#enable-seo-title-and-meta-description-updates) so its
fields are visible through the REST API.
* *"The SEO metadata was NOT saved: this WordPress site runs … but does
not expose its … field(s) for writing through the REST API…"* — the
site does not accept REST API writes on your SEO plugin's fields yet.
Apply the prerequisite configuration described in [Enable SEO title and
meta description
updates](#enable-seo-title-and-meta-description-updates). The rest of
the post update was applied.
The field group is not exposed to the REST API, so the `acf` key is
absent from your site's REST responses and agents cannot read or write
those fields. Enable **Show in REST API** on the field group — see
[Expose ACF fields to Semji](#expose-acf-fields-to-semji). If the content
type itself does not show up when the agent lists post types, the post
type also needs to be visible in the REST API (same section).
## Related
* [Sync drafts to your CMS](/guides/sync-drafts-to-cms) — API-based publishing flow, if you prefer to drive the sync yourself.
# Connect to Semji
Source: https://developers.semji.com/mcp/connecting
Add the Semji MCP server to Claude and any other MCP-compatible assistant.
The Semji MCP server is hosted at:
```
https://mcp.semji.com/mcp
```
The first time you connect, your assistant opens a browser window to sign in to Semji. If your account belongs to more than one organization, you then choose which organization to grant access to. Access is scoped to that organization and to your existing workspace permissions. You need a Semji account with access to at least one workspace.
Semji is not (yet) available as a one-click app inside Claude or ChatGPT. You
add it manually as a **custom MCP server** using the URL above. The steps
below cover that.
## Claude
### Claude Code (CLI)
Add the server, then authenticate from inside Claude Code:
```bash theme={null}
claude mcp add --transport http semji https://mcp.semji.com/mcp
```
Run `/mcp` in Claude Code and follow the browser sign-in. Once it shows
`connected`, you can start prompting.
### Claude Desktop and claude.ai
Add it as a custom connector:
Go to **Settings → Connectors** and click **Add custom connector**.
Paste `https://mcp.semji.com/mcp` and confirm with **Add**.
Open the new connector, click **Connect**, and sign in to Semji in the
browser window that opens.
Custom connectors are available on Free, Pro, Max, Team, and Enterprise plans
(Free is limited to one custom connector). Claude reaches the server from
Anthropic's cloud rather than your machine — this works because
`mcp.semji.com` is publicly reachable.
## Other MCP clients
Most MCP-compatible tools (Cursor, VS Code, Windsurf, Zed, ChatGPT custom
connectors, and others) let you register a server in a JSON config. Use one of
the two patterns below depending on what your client supports.
```json Remote (HTTP) — preferred theme={null}
{
"mcpServers": {
"semji": {
"type": "http",
"url": "https://mcp.semji.com/mcp"
}
}
}
```
```json Local bridge (stdio-only clients) theme={null}
{
"mcpServers": {
"semji": {
"command": "npx",
"args": ["-y", "mcp-remote@latest", "https://mcp.semji.com/mcp"]
}
}
}
```
Use the **remote (HTTP)** form if your client can talk to remote MCP servers
directly. If your client only supports local (stdio) servers, the **local
bridge** uses [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) to forward
the connection and handle the browser sign-in for you (Node.js required).
## Troubleshooting
Trigger the auth flow explicitly: in Claude Code run `/mcp`, in other clients
re-open or reconnect the server. With the local bridge, the browser opens on
first use — make sure no other process is holding the redirect port and that
pop-ups aren't blocked.
Your session expired or was never completed. Reconnect and sign in again. In
Claude Code, `/mcp` lets you re-authenticate an existing server.
Custom connectors in Claude and ChatGPT connect from the vendor's cloud, so
the server must be reachable over the public internet — `mcp.semji.com` is.
If you are behind a corporate proxy or VPN that blocks it, the local bridge
(which connects from your machine) is the workaround.
Make sure Node.js is installed so `npx` can run, and keep `mcp-remote`
up to date (`mcp-remote@latest`, version 0.1.16 or newer). Restart the client
after editing its config file.
The assistant is scoped to the Semji account and the organization you picked
when signing in. To switch organization (or account), disconnect the server
and reconnect, then sign in again and choose the other organization.
# Semji MCP server
Source: https://developers.semji.com/mcp/overview
Connect your AI assistant to Semji and drive your SEO and GEO content workflow in plain language.
The Semji MCP server connects any MCP-compatible AI assistant — such as Claude — directly to your Semji workspace. Ask in plain language and the assistant works on your real content: it reads your planning, runs analyses, generates drafts, and publishes, all scoped securely to your account.
No glue code, no exports. You stay in your assistant, the work happens in Semji.
## What you can do
Explore and filter your content pipeline, create drafts in bulk from a list
of keywords or URLs, organize them into folders, and set owners, statuses,
and due dates.
Set a focus keyword on a piece of content, run the analysis, and get
optimization recommendations for Google Search as well as AI answers
(Google AI Overview and ChatGPT).
Launch AI generation or optimization on one or many drafts, follow progress,
review the result, and publish when you are happy with it.
Pull in your workspaces, team, brand voices, and account status (including
remaining credits) so the assistant acts with the right context.
The assistant only ever sees and changes data in the Semji account you sign in
with. Scope follows your existing workspace permissions.
## Example prompts
Once connected, talk to your assistant the way you would to a teammate:
* *"List the drafts in my Spanish market workspace that don't have a focus keyword yet."*
* *"Create drafts for these 10 keywords in my US market workspace and start the SEO analysis on each."*
* *"Run the SEO and AI-visibility analysis on my draft about 'content marketing tools' and summarize what I should improve."*
* *"Generate an optimized version of these three drafts, then publish them once I approve."*
* *"Publish my approved article in Semji, then adapt it into a LinkedIn post and an X thread and schedule them on my social channels."*
* *"Sort my unsorted drafts into folders by topic and assign each to the right writer."*
* *"How much AI Writing credit do I have left?"*
## Next step
Add the Semji MCP server to Claude or any other MCP-compatible assistant.
# Quickstart
Source: https://developers.semji.com/quickstart
Create your API key, retrieve your workspace, import a page, and optimize it with Atomic Content — all from the command line.
This guide walks you through the four steps to go from zero to an AI-optimized content draft using the Semji API.
You need a Semji account to follow this guide. Sign up or log in at [app.semji.com](https://app.semji.com).
## 1. Create your API key
Log in to [app.semji.com](https://app.semji.com), then go to **Settings > Organization > API Keys**.
Click **New API key**, give it a name (e.g. `quickstart`), and click **Create**.
Your key is shown once. Copy it now — you won't be able to see it again. All keys start with `sk_`.
Treat your API key like a password. Don't commit it to source control or include it in client-side code.
Export it in your terminal to use it in the commands below:
```bash theme={null}
export SEMJI_API_KEY="sk_your_api_key_here"
```
TypeScript examples read exported values with `process.env`. Python examples read them with `os.environ[...]`.
## 2. Test your key
Call `GET /v1/me` to verify your key works and see your organization:
```typescript TypeScript theme={null}
const response = await fetch("https://api.semji.com/v1/me", {
headers: { Authorization: `Bearer ${process.env.SEMJI_API_KEY}` },
});
console.log(await response.json());
```
```python Python theme={null}
import os
import requests
response = requests.get(
"https://api.semji.com/v1/me",
headers={"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}"},
)
print(response.json())
```
```bash cURL theme={null}
curl https://api.semji.com/v1/me \
-H "Authorization: Bearer $SEMJI_API_KEY"
```
You should get back your user profile and organization:
```json theme={null}
{
"id": "df286a001943",
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"createdAt": "2024-03-15T10:30:00+00:00",
"jobTitle": "Marketing Manager",
"languageCode": "en",
"profileImageUrl": null,
"organization": {
"id": "89b0f07aade2",
"name": "Example Corp",
"createdAt": "2024-01-10T08:00:00+00:00",
"brandName": null,
"brandImageUrl": null,
"credits": {
"analysis": 47,
"aiWriting": 12,
"contentIdeasSearches": 5
},
"usersCount": 3,
"workspacesCount": 2
}
}
```
## 3. Get your workspace
A workspace represents one website in Semji. List your workspaces to grab the `id` you'll use in the next steps:
```typescript TypeScript theme={null}
const response = await fetch("https://api.semji.com/v1/workspaces", {
headers: { Authorization: `Bearer ${process.env.SEMJI_API_KEY}` },
});
const { data } = await response.json();
console.log(data[0].id, data[0].name);
```
```python Python theme={null}
import os
import requests
response = requests.get(
"https://api.semji.com/v1/workspaces",
headers={"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}"},
)
workspaces = response.json()["data"]
print(workspaces[0]["id"], workspaces[0]["name"])
```
```bash cURL theme={null}
curl https://api.semji.com/v1/workspaces \
-H "Authorization: Bearer $SEMJI_API_KEY"
```
Save your workspace ID:
```bash theme={null}
export WORKSPACE_ID="6c629e33a9a6"
```
## 4. Import a page and optimize it
### Import the page
Import a URL into your workspace. You can optionally attach a focus keyword right away:
```typescript TypeScript theme={null}
const response = await fetch(
`https://api.semji.com/v1/workspaces/${process.env.WORKSPACE_ID}/pages`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SEMJI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com/blog/my-article",
focusKeyword: "content marketing strategy",
}),
}
);
const page = await response.json();
console.log(page.id);
```
```python Python theme={null}
import os
import requests
response = requests.post(
f"https://api.semji.com/v1/workspaces/{os.environ['WORKSPACE_ID']}/pages",
headers={
"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}",
"Content-Type": "application/json",
},
json={
"url": "https://example.com/blog/my-article",
"focusKeyword": "content marketing strategy",
},
)
page = response.json()
print(page["id"])
```
```bash cURL theme={null}
curl -X POST "https://api.semji.com/v1/workspaces/$WORKSPACE_ID/pages" \
-H "Authorization: Bearer $SEMJI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/blog/my-article",
"focusKeyword": "content marketing strategy"
}'
```
The response returns the page with its `id` and crawled metadata (title, word count, etc.). Save the page ID for the next step:
```bash theme={null}
export PAGE_ID="1b81be0eb082"
```
### Create a content draft
Create a content linked to the page you just imported:
```typescript TypeScript theme={null}
const contentRes = await fetch(
`https://api.semji.com/v1/workspaces/${process.env.WORKSPACE_ID}/contents`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SEMJI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Content Marketing Strategy for 2025",
pageId: process.env.PAGE_ID,
}),
}
);
const content = await contentRes.json();
console.log(content.id);
```
```python Python theme={null}
import os
import requests
response = requests.post(
f"https://api.semji.com/v1/workspaces/{os.environ['WORKSPACE_ID']}/contents",
headers={
"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}",
"Content-Type": "application/json",
},
json={
"title": "Content Marketing Strategy for 2025",
"pageId": os.environ["PAGE_ID"],
},
)
content = response.json()
print(content["id"])
```
```bash cURL theme={null}
curl -X POST "https://api.semji.com/v1/workspaces/$WORKSPACE_ID/contents" \
-H "Authorization: Bearer $SEMJI_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"Content Marketing Strategy for 2025\",
\"pageId\": \"$PAGE_ID\"
}"
```
Save the content ID:
```bash theme={null}
export CONTENT_ID="3a89fc29d1f3"
```
### Analyze the focus keyword
Before generating content, the focus keyword needs a completed SEO analysis. Trigger it with `POST /v1/keywords/:id/analyze` using the keyword ID returned during page import:
```bash theme={null}
export KEYWORD_ID="b211968d8d46"
```
```typescript TypeScript theme={null}
await fetch(
`https://api.semji.com/v1/keywords/${process.env.KEYWORD_ID}/analyze`,
{
method: "POST",
headers: { Authorization: `Bearer ${process.env.SEMJI_API_KEY}` },
}
);
```
```python Python theme={null}
import os
import requests
requests.post(
f"https://api.semji.com/v1/keywords/{os.environ['KEYWORD_ID']}/analyze",
headers={"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}"},
)
```
```bash cURL theme={null}
curl -X POST "https://api.semji.com/v1/keywords/$KEYWORD_ID/analyze" \
-H "Authorization: Bearer $SEMJI_API_KEY"
```
Poll `GET /v1/keywords/:id` until `analysisStatus` reaches `success`:
```typescript TypeScript theme={null}
while (true) {
const res = await fetch(
`https://api.semji.com/v1/keywords/${process.env.KEYWORD_ID}`,
{ headers: { Authorization: `Bearer ${process.env.SEMJI_API_KEY}` } }
);
const kw = await res.json();
console.log(`Analysis: ${kw.analysisStatus}`);
if (["success", "failed"].includes(kw.analysisStatus)) break;
await new Promise((r) => setTimeout(r, 5000));
}
```
```python Python theme={null}
import os
import time
import requests
while True:
kw = requests.get(
f"https://api.semji.com/v1/keywords/{os.environ['KEYWORD_ID']}",
headers={"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}"},
).json()
print(f"Analysis: {kw['analysisStatus']}")
if kw["analysisStatus"] in ("success", "failed"):
break
time.sleep(5)
```
```bash cURL theme={null}
curl "https://api.semji.com/v1/keywords/$KEYWORD_ID" \
-H "Authorization: Bearer $SEMJI_API_KEY"
```
The analysis typically completes within 30 to 90 seconds.
### Launch Atomic Content
Trigger an AI content generation on the draft. Use `replace` to generate from scratch or `optimize` to rewrite existing content:
```typescript TypeScript theme={null}
await fetch(
`https://api.semji.com/v1/contents/${process.env.CONTENT_ID}/atomic`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SEMJI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ type: "replace" }),
}
);
```
```python Python theme={null}
import os
import requests
requests.post(
f"https://api.semji.com/v1/contents/{os.environ['CONTENT_ID']}/atomic",
headers={
"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}",
"Content-Type": "application/json",
},
json={"type": "replace"},
)
```
```bash cURL theme={null}
curl -X POST "https://api.semji.com/v1/contents/$CONTENT_ID/atomic" \
-H "Authorization: Bearer $SEMJI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "replace" }'
```
### Poll for completion
The generation runs asynchronously. Poll `GET /v1/contents/:id/generation` until the status reaches `review`:
```typescript TypeScript theme={null}
while (true) {
const res = await fetch(
`https://api.semji.com/v1/contents/${process.env.CONTENT_ID}/generation`,
{ headers: { Authorization: `Bearer ${process.env.SEMJI_API_KEY}` } }
);
const { status } = await res.json();
console.log(`Status: ${status}`);
if (["review", "failed", "cancelled"].includes(status)) break;
await new Promise((r) => setTimeout(r, 5000));
}
```
```python Python theme={null}
import os
import time
import requests
while True:
status = requests.get(
f"https://api.semji.com/v1/contents/{os.environ['CONTENT_ID']}/generation",
headers={"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}"},
).json()
print(f"Status: {status['status']}")
if status["status"] in ("review", "failed", "cancelled"):
break
time.sleep(5)
```
```bash cURL theme={null}
curl "https://api.semji.com/v1/contents/$CONTENT_ID/generation" \
-H "Authorization: Bearer $SEMJI_API_KEY"
```
Possible statuses: `queued` → `pending` → `review` → `success` (after confirm) or `failed` / `cancelled`.
### Confirm the draft
Once the status is `review`, confirm the generation to apply it to your content:
```typescript TypeScript theme={null}
await fetch(
`https://api.semji.com/v1/contents/${process.env.CONTENT_ID}/generation/confirm`,
{
method: "POST",
headers: { Authorization: `Bearer ${process.env.SEMJI_API_KEY}` },
}
);
```
```python Python theme={null}
import os
import requests
requests.post(
f"https://api.semji.com/v1/contents/{os.environ['CONTENT_ID']}/generation/confirm",
headers={"Authorization": f"Bearer {os.environ['SEMJI_API_KEY']}"},
)
```
```bash cURL theme={null}
curl -X POST "https://api.semji.com/v1/contents/$CONTENT_ID/generation/confirm" \
-H "Authorization: Bearer $SEMJI_API_KEY"
```
Your content is now optimized. Open it in the [Semji editor](https://app.semji.com) to review and publish.
## What's next?
Explore all available endpoints.
Rate limits, error handling, and key management.