Every request to the Content API carries a site key. It tells us which site you are reading from — and that is pretty much all it does. No OAuth dance, no token refresh, no signing requests at midnight. One string in one header, and you are in.
What a site key is
A site key looks like this:
pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40- The prefix
pk_followed by exactly 32 lowercase hex characters:^pk_[a-f0-9]{32}$. - It belongs to one site. The key alone decides whose content you get — there is no “site id” parameter anywhere in the API.
- It is read-only and only sees published content.
- A site can have several keys at once, which makes painless rotation possible (more on that below).
The pk_ is a nod to “publishable keys” you may know from payment providers: a key that is designed to live in public code. We will get to why that is fine in a minute.
Where to get a key
Open your site in the CRM
Sign in to Diil and pick the site whose content you want to read.Go to Settings → Content API keys
This is where all keys of the site live.Create a key
You get a freshpk_…string. Copy it.Put it into an environment variable
Not straight into the code — future you, rotating keys, will be grateful. Patterns for popular frameworks are below.
How to send the key
Put the key into the x-crm-key request header. That is the standard way, and it works from servers and browsers alike — CORS allows the header from any origin.
curl "https://back.sitecog.com/content/v1/pages/home?lang=en" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
const page = await res.json();GET /content/v1/pages/home?lang=en HTTP/1.1
Host: back.sitecog.com
x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40The ?key= fallback
If you really can't set a header, pass the key as a query parameter instead:
curl "https://back.sitecog.com/content/v1/pages/home?lang=en&key=pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"When is that okay?
- Quick checks — pasting a URL into the browser address bar to see what an endpoint returns.
- Tools that only take a URL — a no-code integration, a feed importer, a static site generator plugin with no header option.
Everywhere else, prefer the header. URLs tend to end up in server logs, browser history and analytics, and although the key is not a secret, there is no reason to sprinkle it all over the place. It also keeps your URLs short and your code tidy.
Why the key is safe in the browser
Short answer: because it can't do anything your visitors can't already do by opening your site. A site key is public by design. Here is what it can and cannot do:
| A site key… | |
|---|---|
| reads published pages, sections, blocks and blog posts of its site | yes |
| changes, creates or deletes anything | no — the API is read-only |
| sees drafts or unpublished blog posts | no — published content only |
| reads content of your other sites | no — one key, one site |
It is the same idea as a payment provider's publishable key: it identifies whose data to show, it does not grant power over it. Everything it can read is going to be on your public website anyway.
The one thing a copied key can do is spend your request quota: each key has its own limit of 600 requests per minute (see Rate limits). If someone starts doing that, rotate the key — that takes a couple of minutes.
Keeping the key in environment variables
The key is public, so why bother with env vars? Because keys change — a rotated key should be a config change, not a code change. Most frameworks also need a prefix before they let a variable into browser code:
# Next.js — Server Components, Route Handlers (never sent to the browser)
CRM_KEY=pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
# Next.js — Client Components (inlined into the bundle at build time)
NEXT_PUBLIC_CRM_KEY=pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
# Vite (React, Vue, Svelte…) — inlined at build time
VITE_CRM_KEY=pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
# Nuxt — overrides runtimeConfig.public.crmKey
NUXT_PUBLIC_CRM_KEY=pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40// app/page.tsx — a Server Component
export default async function Home() {
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
next: { revalidate: 60 },
});
const page = await res.json();
return <h1>{page.content.hero.content.hero_title.content.en}</h1>;
}'use client';
import { useEffect, useState } from 'react';
export function HeroTitle() {
const [title, setTitle] = useState('');
useEffect(() => {
fetch('https://back.sitecog.com/content/v1/blocks/hero_title?section=hero&lang=en', {
headers: { 'x-crm-key': process.env.NEXT_PUBLIC_CRM_KEY! },
})
.then((r) => r.json())
.then((block) => setTitle(block.content.en ?? ''));
}, []);
return <h1>{title}</h1>;
}// src/content.ts
export async function getPage(marker: string, lang = 'en') {
const res = await fetch(`https://back.sitecog.com/content/v1/pages/${marker}?lang=${lang}`, {
headers: { 'x-crm-key': import.meta.env.VITE_CRM_KEY },
});
if (!res.ok) throw new Error(`Content API: ${res.status}`);
return res.json();
}export default defineNuxtConfig({
runtimeConfig: {
public: {
crmKey: '', // filled from NUXT_PUBLIC_CRM_KEY
},
},
});<script setup>
const { public: { crmKey } } = useRuntimeConfig();
const { data: page } = await useFetch('https://back.sitecog.com/content/v1/pages/home', {
query: { lang: 'en' },
headers: { 'x-crm-key': crmKey },
});
</script>
<template>
<h1>{{ page.content.hero.content.hero_title.content.en }}</h1>
</template>Revoking and rotating keys
Any key can be revoked in the CRM in the same Settings → Content API keys list. A revoked key stops working: every request with it gets 401 invalid_key. To swap keys without a single failed request, overlap them:
Create a new key
Leave the old one alone for now — both keep working side by side.Deploy with the new key
Update the environment variable everywhere the old key was used, rebuild if your framework inlines it, and deploy.Check that the site still loads content
Open a couple of pages, look at the logs. No 401s? Good.Revoke the old key
Now it is safe to pull the plug.
Key errors
| Status | Body | What happened |
|---|---|---|
| 401 | {"message":"invalid_key"} | The key is missing, malformed (not pk_ + 32 hex) or revoked. |
| 429 | {"message":"rate_limit_exceeded"} | Too many requests — or too many bad keys from your IP this minute (see below). |
The bad-key lockout
To keep key guessing pointless, we count requests with invalid keys per IP. More than 20 bad-key requests in a minute from one IP, and that IP gets 429 for the rest of the minute — even with a valid key. There are no Retry-After headers; the window resets at the start of the next minute.
The classic way to trip it by accident: you revoke an old key, but one server or a forgotten cron job still uses it. Server-side rendering keeps firing 401s, and a minute later the whole server is locked out — new key included. So:
const res = await fetch(url, { headers: { 'x-crm-key': process.env.CRM_KEY } });
if (res.status === 401) {
// A wrong or revoked key won't fix itself. Retrying only burns
// through the bad-key budget and gets this IP locked out.
throw new Error('Content API: invalid site key, check CRM_KEY');
}
if (res.status === 429) {
// Back off until the next minute (about 60 s, plus a little jitter).
}All other codes are on the Errors page.