Skip to content

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.

Last updated

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 serverbash
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 browserbash
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.

PHP

cmskite.phpphp
<?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.phpphp
<?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.phpphp
define('CMSKITE_API_KEY', 'your server key');
define('CMSKITE_PUBLIC_KEY', 'your browser key');
wp-content/themes/your-theme/cmskite.phpphp
<?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 templatephp
<?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

blog.htmlhtml
<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.pypython
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.

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