Developers
Bring your content to your own site
Content approved in CoText and published to the Your own website channel reaches your site in two ways: CoText sends your site a signed webhook when it publishes, or your site pulls content from the read API. You can use both. Zapier, Make and n8n use the same paths.
Webhooks and signature verification
When you add the channel, you enter your site's webhook URL (https only). CoText generates a signing secret (whsec_…) and shows it only once. Every request is signed in the Standard Webhooks format, so you can use the ready-made libraries (Node, Python, PHP, Go …) as they are.
webhook-id: the event ID. It stays the same across retries; don't process an ID you've already handled.webhook-timestamp: Unix seconds. Reject requests older than 5 minutes (replay attacks).webhook-signature:v1,<base64>. The signed text is${webhook-id}.${webhook-timestamp}.${raw-body}, the algorithm is HMAC-SHA256, and the key is the bytes of the base64 value after thewhsec_prefix. For 24 hours after a secret is rotated, two space-separated signatures arrive.
Verify the signature on the raw body, before you parse the JSON:
import { createHmac, timingSafeEqual } from "node:crypto";
// secret: the "whsec_…" value CoText shows once when you add the channel
export function verify(rawBody, headers, secret) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigs = headers["webhook-signature"] ?? "";
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
// While a secret is being rotated, the header may carry two space-separated signatures: one match is enough.
return sigs.split(" ").some((s) => {
const [v, b64] = s.split(",");
const got = Buffer.from(b64 ?? "", "base64");
return v === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
});
}Body
{
"type": "article.published", // article.updated · article.unpublished · ping
"timestamp": "2026-10-16T09:30:00.000Z",
"data": {
"article": {
"id": "7c0e…", // CoText content ID: insert or update by this
"title": "A guide to brewing coffee",
"slug": "coffee-brewing-guide",
"status": "published", // single source of truth: "published" | "draft"
"excerpt": "…", "meta_description": "…", "focus_keyword": "…",
"tags": ["coffee"], "category": "Guide", "content_type": "guide",
"language": "en", "language_name": "English", "translation_group_id": "7c0e…",
"translations": [{ "language": "tr", "url": "https://…", "article_id": "9d41…", "channel_kind": "custom" }],
"html": "<h2>…</h2><p>…</p>", "markdown": "## …",
"canonical_url": null, "author_name": null,
"featured_image": { "url": "https://…/kapak.jpg", "alt": "…", "width": 1600, "height": 900, "mime": "image/jpeg", "credit": null, "ai": false },
"published_at": "2026-10-16T09:30:00.000Z", "version_id": "…"
}
}
}- Insert or update by
data.article.id(upsert); anarticle.unpublishedevent carries only theid. - The event type is a hint; check
data.article.statusfor the post's state. Delivery order isn't guaranteed. - Languages:
languageis the content language code (tr,en,de,fr,es;nullif CoText doesn't recognize it) andlanguage_namethe stored name. The language versions of the same content share atranslation_group_id(the ID of the group's source);translationslists the other versions that are already live, with their addresses (the source'sarticle_idequalstranslation_group_id, use it forx-default). Build yourhreflanglinks from it. A version published later doesn't resend the earlier ones; the read API always has the full list. - Cover image:
featured_imageis{ url, alt, width, height, mime, credit, ai }ornull.urlis a permanent public address (show it directly or copy the file). Whencreditisn'tnull, show it with the image;aiistruefor images generated with AI. - Structured data:
structured_datais a list of schema.org JSON-LD objects: aBlogPosting, plus aFAQPagewhen the text has a FAQ section (an h2 followed by question h3s with paragraph answers). Put each one in the page head inside<script type="application/ld+json">. - Return 2xx (optionally with
{ "url": "https://…" }to report the post's address). Return 401 if the signature is invalid. 410 means "endpoint removed" and is not retried; 408, 429 and 5xx are retried; redirects are not followed.
Read API
The workspace owner creates a key under Settings > API keys (ctx_live_…). The key is shown only once, when it's created; CoText stores only a hash of it. Send the Authorization: Bearer ctx_live_… header with every request.
The API returns only content that is live on the Your own website channel, in the version sent to that channel: edits made in CoText after publishing but not yet approved and sent don't show, and content unpublished from the channel drops out of the list.
GET /api/v1/articles: list. Query:limit(1–100, default 20),cursor(the previous response'snext_cursor),updated_since(ISO 8601, inclusive),order(desc|asc, byupdated_at),channel(channel ID),language(content language code:tr,en,de,fr,es).- Each item carries
language(code),language_name,translation_group_idandtranslations: its other language versions that this API serves, as{ language, slug, url }, whatever thechannelorlanguagefilter. Use them forhreflanglinks and a language switcher. featured_image: the cover of the published version, in the same shape as in the webhook ({ url, alt, width, height, mime, credit, ai }), ornull.structured_data: the same schema.org JSON-LD list as in the webhook (BlogPosting, plusFAQPagewhen there's a FAQ section).GET /api/v1/articles/{slug}: a single piece; the content ID works instead of the slug too.GET /api/v1/openapi.json: the OpenAPI 3.1 definition (no key needed).
curl https://cotext.net/api/v1/articles?limit=20 \
-H "Authorization: Bearer ctx_live_…"
{
"data": [
{
"id": "7c0e…", "title": "…", "slug": "…", "excerpt": "…", "meta_description": "…",
"tags": ["…"], "category": "…", "content_type": "blog",
"language": "en", "language_name": "English", "translation_group_id": "7c0e…",
"translations": [{ "language": "tr", "slug": "…", "url": "https://…" }],
"html": "<p>…</p>", "markdown": "…",
"published_at": "…", "updated_at": "…", "version_id": "…", "canonical_url": null,
"featured_image": { "url": "https://…/kapak.jpg", "alt": "…", "width": 1600, "height": 900, "mime": "image/jpeg", "credit": null, "ai": false }
}
],
"next_cursor": "WyIyMDI2LTEwLTE2…",
"has_more": true
}To sync, use order=asc&updated_since=<last sync> and follow the pages until next_cursor is empty. To learn about deleted or unpublished content, listen to the webhook (article.unpublished) or fetch the full list now and then.
MCP: connect your AI assistant
CoText is an MCP server (Model Context Protocol, Streamable HTTP): Claude, Cursor and other MCP clients can plan, generate, edit, approve and schedule content in your workspace. Everything goes through CoText's flow: brand voice, learned editorial rules, credits, versions and version-bound approval.
The owner creates a key with the MCP: AI assistant access under Settings > API keys (content:write). The assistant works with editor rights, under the name of the person who created the key; if that person is no longer an owner or editor, the key stops working. Address: https://cotext.net/api/mcp. In Claude Code:
claude mcp add --transport http cotext https://cotext.net/api/mcp \
--header "Authorization: Bearer ctx_live_…"- Tools:
workspace_overview,get_brand_voice,list_content,get_content,create_idea,plan_quick_pack,generate_articles,get_batch,update_content,run_ai_editor,submit_for_review,approve_content,request_changes,schedule_publish,cancel_publish. - Generation is queued:
generate_articlesreturns a batch at once and the assistant follows it withget_batch. Approval asks for three confirmations and the current revision; publishing sends only the approved version. - Every write appears in the activity log as AI assistant with the key's prefix. The assistant's text edits are saved as a separate version and CoText doesn't learn brand rules from them. Revoke the key to cut access at once.
- In claude.ai, ChatGPT and other apps that support MCP connectors, add
https://cotext.net/api/mcpas a connector: the app sends you to CoText, you choose one or more workspaces and allow access — the assistant can then work in each of their brands (list_workspaces, brand_id) (OAuth 2.1 with PKCE; no key to copy). Connections show up and can be disconnected under Settings > API keys.
Errors and limits
- Error body:
{ "error": { "code": "…", "message": "…", "message_en": "…" } }.messageis in the request's language (Accept-Languageheader: English, Turkish, German, Spanish, French, Arabic, or Japanese; English otherwise);message_enis always English. Codes:unauthorized,key_revoked,key_expired,insufficient_scope,api_access_disabled,invalid_parameter,not_found,rate_limited. - 600 requests per hour per key. Responses carry
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset; over the limit you get 429 andRetry-After. - The endpoints accept
GETrequests from any origin (CORS, no cookies). A key in the browser is visible to everyone, so use it on the server when you can; it only reads live content and can't change anything. If you think it has leaked, revoke it and create a new one.