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 the whsec_ 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); an article.unpublished event carries only the id.
  • The event type is a hint; check data.article.status for the post's state. Delivery order isn't guaranteed.
  • Languages: language is the content language code (tr, en, de, fr, es; null if CoText doesn't recognize it) and language_name the stored name. The language versions of the same content share a translation_group_id (the ID of the group's source); translations lists the other versions that are already live, with their addresses (the source's article_id equals translation_group_id, use it for x-default). Build your hreflang links from it. A version published later doesn't resend the earlier ones; the read API always has the full list.
  • Cover image: featured_image is { url, alt, width, height, mime, credit, ai } or null. url is a permanent public address (show it directly or copy the file). When credit isn't null, show it with the image; ai is true for images generated with AI.
  • Structured data: structured_data is a list of schema.org JSON-LD objects: a BlogPosting, plus a FAQPage when 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's next_cursor), updated_since (ISO 8601, inclusive), order (desc | asc, by updated_at), channel (channel ID), language (content language code: tr, en, de, fr, es).
  • Each item carries language (code), language_name, translation_group_id and translations: its other language versions that this API serves, as { language, slug, url }, whatever the channel or language filter. Use them for hreflang links 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 }), or null.
  • structured_data: the same schema.org JSON-LD list as in the webhook (BlogPosting, plus FAQPage when 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_articles returns a batch at once and the assistant follows it with get_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/mcp as 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": "…" } }. message is in the request's language (Accept-Language header: English, Turkish, German, Spanish, French, Arabic, or Japanese; English otherwise); message_en is 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-Remaining and X-RateLimit-Reset; over the limit you get 429 and Retry-After.
  • The endpoints accept GET requests 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.