Empieza aquí

Without a framework

There is an npm package, and you do not need it. CMSKite is an HTTP API with a bearer token, so any language that can make a request can use it. Every example below is complete: read the list, read one post, and report the view.

Actualizado el 23 de setiembre de 2026

4 min de lectura

The whole API, in two requests

Whatever you are writing, an integration is these two calls. The first runs on your server. The second runs in the reader’s browser, and is the one people forget.

1. Read the content — from your server

bash
curl 'https://api.cmskite.com/v1/blog/posts?limit=20' \
  -H "authorization: Bearer $CMSKITE_API_KEY"

curl 'https://api.cmskite.com/v1/blog/posts/slug/hello-world' \
  -H "authorization: Bearer $CMSKITE_API_KEY"

2. Report the view — from the reader’s browser

bash
curl -X POST 'https://api.cmskite.com/v1/blog/events?key=YOUR_PROJECT_KEY' \
  -H 'content-type: text/plain;charset=UTF-8' \
  --data '{"events":[{"type":"view","postId":"post_01h...","path":"/blog/hello-world"}]}'

The key already knows which project it belongs to, so there is no project or workspace header to send — one supplied by the client is refused rather than honoured. Every response is the same envelope: success, data, and a requestId to quote if you ever need support.

Two keys, not one. Use one key on your server for fetching and a second in the page for tracking. Both are read-only and return published content only, so neither is a secret — but two means the one in your HTML can be revoked without taking the site down.

PHP

cmskite.php

php
<?php

function cmskite_get(string $path, array $query = []): mixed
{
    $url = 'https://api.cmskite.com/v1' . $path;
    if ($query) {
        $url .= '?' . http_build_query($query);
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => ['authorization: Bearer ' . getenv('CMSKITE_API_KEY')],
    ]);

    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    // No curl_close(). It has done nothing since PHP 8.0 and since 8.5 it
    // prints a deprecation notice -- into the page, above the <!doctype>.

    // A blog that cannot reach the API renders empty. It does not fatal.
    if ($body === false || $status !== 200) {
        error_log("CMSKite: HTTP {$status} for {$path}");
        return null;
    }

    return json_decode($body, true)['data'] ?? null;
}

function cmskite_posts(int $limit = 20): array
{
    return cmskite_get('/blog/posts', ['limit' => $limit]) ?? [];
}

function cmskite_post(string $slug): ?array
{
    return cmskite_get('/blog/posts/slug/' . rawurlencode($slug));
}

post.php

php
<?php
require __DIR__ . '/cmskite.php';

$post = cmskite_post($_GET['slug'] ?? '');
if (!$post) {
    http_response_code(404);
    exit('Post not found');
}
?>
<article>
  <h1><?= htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8') ?></h1>
  <?php
    // The body is HTML the author wrote in CMSKite, so it is printed as markup.
    echo $post['body'];
  ?>
</article>

<script>
  var POST_ID = <?= json_encode($post['id']) ?>
  var KEY     = <?= json_encode(getenv('CMSKITE_PUBLIC_KEY')) ?>
  // …the tracker from "Why your views are zero" goes here, unchanged.
</script>

WordPress

No plugin. Two defines in wp-config.php and one file in your theme. Using wp_remote_get rather than curl means proxies, timeouts and filters behave the way the rest of the site does.

wp-config.php

php
define('CMSKITE_API_KEY', 'your server key');
define('CMSKITE_PUBLIC_KEY', 'your browser key');

wp-content/themes/your-theme/cmskite.php

php
<?php
// require_once get_stylesheet_directory() . '/cmskite.php'; from functions.php

function cmskite_get($path, $query = []) {
    $url = 'https://api.cmskite.com/v1' . $path . ($query ? '?' . http_build_query($query) : '');

    $response = wp_remote_get($url, [
        'timeout' => 10,
        'headers' => ['authorization' => 'Bearer ' . CMSKITE_API_KEY],
    ]);

    if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
        error_log('CMSKite: could not fetch ' . $path);
        return null;
    }

    return json_decode(wp_remote_retrieve_body($response), true)['data'] ?? null;
}

/** Cached, because a blog index should not make an API call per visitor. */
function cmskite_posts($limit = 20) {
    $cached = get_transient('cmskite_posts_' . $limit);
    if ($cached !== false) return $cached;

    $posts = cmskite_get('/blog/posts', ['limit' => $limit]) ?: [];
    set_transient('cmskite_posts_' . $limit, $posts, 5 * MINUTE_IN_SECONDS);
    return $posts;
}

function cmskite_post($slug) {
    return cmskite_get('/blog/posts/slug/' . rawurlencode($slug));
}

A template

php
<?php $post = cmskite_post(get_query_var('cmskite_slug')); ?>
<article>
  <h1><?php echo esc_html($post['title']); ?></h1>
  <?php echo wp_kses_post($post['body']); ?>
</article>

A plain HTML site

Allowed origins are not optional here. With no server, the browser makes the request — and a browser will not hand your page a response from another domain unless we say it may. Add every domain your site is served from under Project → Settings → Allowed origins first, or the page renders empty with a CORS error in the console.

blog.html

html
<ul id="posts"></ul>

<script>
var KEY = 'your project key'

fetch('https://api.cmskite.com/v1/blog/posts?limit=20', {
  headers: { authorization: 'Bearer ' + KEY }
})
  .then(function (res) {
    if (!res.ok) throw new Error('CMSKite ' + res.status)
    return res.json()
  })
  .then(function (payload) {
    document.getElementById('posts').innerHTML = payload.data
      .map(function (post) {
        var a = document.createElement('a')
        a.href = 'post.html?slug=' + encodeURIComponent(post.slug)
        a.textContent = post.title
        return '<li>' + a.outerHTML + '</li>'
      })
      .join('')
  })
  .catch(function (err) {
    document.getElementById('posts').textContent = 'Could not load posts.'
    console.error(err)
  })
</script>

The single-post page is the same fetch against /blog/posts/slug/… — and it is where the tracker goes, inside the .then, because that is where the post id finally exists.

Python

cmskite.py

python
import os
import httpx

_client = httpx.Client(
    base_url="https://api.cmskite.com/v1",
    timeout=10.0,
    headers={"authorization": f"Bearer {os.environ['CMSKITE_API_KEY']}"},
)


def posts(limit: int = 20) -> list[dict]:
    """The published posts, newest first. Empty when the API is unreachable."""
    try:
        response = _client.get("/blog/posts", params={"limit": limit})
        response.raise_for_status()
    except httpx.HTTPError as error:
        print(f"CMSKite: {error}")
        return []
    return response.json()["data"]


def post(slug: str) -> dict | None:
    """One post, or None when there is no such post."""
    try:
        response = _client.get(f"/blog/posts/slug/{slug}")
        if response.status_code == 404:
            return None
        response.raise_for_status()
    except httpx.HTTPError as error:
        print(f"CMSKite: {error}")
        return None
    return response.json()["data"]

Let an assistant do it

The CMSKite MCP server gives an AI assistant these guides directly. Point it at your project, ask it to add a blog, and it will write the right files for whatever your site is built in — then verify the result rather than assume it.

text
get_integration_guide   framework: php | wordpress | laravel | python | html
check_integration       does this project actually work?
get_content_analytics   views, uniques and clicks