# 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. # 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. # 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. 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 | ### How it works 1. In the Semji app, go to your workspace **Settings > General** and click **Add a Webhook** — paste the URL of your endpoint. See [Automate your content publishing with webhooks](https://help.semji.com/en/en/automate-your-content-publishing-with-webhooks) in the Help Center, which also provides ready-made Make and N8n templates for WordPress if you'd rather not write code. 2. The editor finishes the draft and clicks **Mark as published**, filling in the publication URL and date (backdating works both in the app and via the API's `publishedAt` field). 3. Semji sends a `POST` request to your endpoint with a `content_published` event. 4. Your endpoint creates or updates the article in the CMS. The payload looks like this: ```json title="content_published webhook payload (excerpt)" theme={null} { "event_type": "content_published", "occurred_at": "2026-06-10T14:32:05+00:00", "data": { "id": "7c4a1f08b29d", "title": "How to choose a CRM in 2026", "html": "

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" } } } ``` Three things to know about the payload: * `data.html` is **already sanitized** — editor annotations are stripped, it's equivalent to the `htmlSanitized` field of the API. You can push it to your CMS as-is. * `data.id` is the content ID, directly usable with [`GET /v1/contents/{id}`](/api-reference/contents/get-content-details). * Delivery is a **single POST** — no signature, no retry on failure. Respond with a 2xx immediately and process asynchronously. Because the webhook is not signed, don't trust the payload blindly: anyone who discovers your endpoint URL could forge it. Treat the webhook as a **trigger**, and re-fetch the content by ID with your API key before touching the CMS — if the content doesn't exist or isn't published, drop the event. ### A minimal webhook handler ```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) * [Automate your content publishing with webhooks (Help Center)](https://help.semji.com/en/en/automate-your-content-publishing-with-webhooks) # Semji Documentation Source: https://developers.semji.com/index Integrate Semji's AI-powered content marketing platform into your own tools and workflows — via the REST API or directly from your AI assistant. 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. # 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.