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