Content API

Citequill keeps the pages it writes for your site. Your site reads the live ones from the Content API, from its server or its build, and renders them in its own template.

Every request carries a key in the Authorization header, except the XML sitemaps, the IndexNow key file and the OpenAPI document. The API sends no CORS headers, so a browser cannot read its answers. Keep the key on your server.

The reference of every endpoint and field

The first request

Make a key in Settings, Integration, and put it in CITEQUILL_KEY on your server. The same page shows your site id and every example below with it filled in.

curl https://citequill.com/api/v1/sites/YOUR_SITE_ID/pages \
  -H "Authorization: Bearer $CITEQUILL_KEY"

The answer is { pages, nextCursor }: the live pages, newest first publication first, 20 at a time. Every path of your site starts with https://citequill.com/api/v1/sites/{siteId}.

Keys

A project has up to 10 keys, each with a name such as Production or Preview. Citequill shows a key in full once, when you make it. After that it shows the key's prefix and last 6 characters, and the day it was last used.

Deleting a key stops it at once. To change a key, make a new one, switch your site to it, then delete the old one. Both work in between. A key reads only its own project.

A site that renders a page when it is requested

Server code reads the page when a visitor asks for it, by its locale and slug. Each example renders a live page and answers 404 when the API answers 404. In PHP and Python, an unpublished page gets a 301 or a 410, as the API says.

A Next.js page cannot answer 410. Alone, it answers an unpublished page with a 404, or with a 308 to the new address, which search engines treat as a 301. It keeps the answer for a live or an unpublished page for 5 minutes. So a page can show for that long after it is unpublished, and to one more visitor while the answer refreshes.

The proxy beside it adds the 410 and the 301. On each server it reads the unpublished pages at most every 5 minutes, when a request comes. Until its next read, a page published again still gets the 301 or the 410.

Any other answer means Citequill or the key failed. PHP and Python then answer 503. The Next.js page keeps showing the answer it kept, or answers 500 for a page it never kept. Search engines keep the page for a while, where a 404 would remove it.

Next.js, when a page is requested

// app/blog/[slug]/page.tsx, in an app without Cache Components. Keep
// loading.tsx and <Suspense> out of the layouts above it, so the status is
// set before the page streams.
import type { Metadata } from 'next';
import { unstable_cache } from 'next/cache';
import { notFound, permanentRedirect } from 'next/navigation';

const site = 'https://citequill.com/api/v1/sites/YOUR_SITE_ID';
const neverPublished = 'Citequill has never published this page.';

// Keeps a 200 or a 410 for 5 minutes, so a 410 replaces a kept page.
// A 404 or a failure throws and is never kept: a new page shows at once, and
// an outage keeps the last answer. The webhook's handler expires the tag.
function readAnswer(slug: string) {
    return unstable_cache(
        async () => {
            const response = await fetch(`${site}/pages/en/${encodeURIComponent(slug)}`, {
                headers: { Authorization: `Bearer ${process.env.CITEQUILL_KEY}` },
                cache: 'no-store',
            });

            if (response.status === 404) {
                throw new Error(neverPublished);
            }

            if (response.status !== 200 && response.status !== 410) {
                // Citequill or the key failed, and the page is not gone.
                throw new Error(`Citequill answered ${response.status}.`);
            }

            return { status: response.status, body: await response.json() };
        },
        ['citequill-page', slug],
        { revalidate: 300, tags: [`citequill:en/${slug}`] },
    )();
}

async function readPage(slug: string) {
    const { status, body } = await readAnswer(slug).catch((error: Error) => {
        if (error.message === neverPublished) {
            notFound();
        }

        throw error;
    });

    if (status === 410) {
        // A 308 and a 404 here; proxy.ts answers the 301 and the 410.
        return body.redirect ? permanentRedirect(body.redirect.to) : notFound();
    }

    return body;
}

export async function generateMetadata({
    params,
}: PageProps<'/blog/[slug]'>): Promise<Metadata> {
    const page = await readPage((await params).slug);

    return {
        title: page.metaTitle,
        description: page.metaDescription,
        alternates: {
            canonical: page.canonical,
            languages: Object.fromEntries(
                page.alternates.map(
                    ({ hreflang, url }: { hreflang: string; url: string }) => [
                        hreflang,
                        url,
                    ],
                ),
            ),
        },
        openGraph: page.openGraph,
    };
}

export default async function Page({ params }: PageProps<'/blog/[slug]'>) {
    const page = await readPage((await params).slug);
    const jsonLd = JSON.stringify(page.jsonLd).replace(/</g, '\\u003c');

    return (
        <article>
            <script
                type="application/ld+json"
                dangerouslySetInnerHTML={{ __html: jsonLd }}
            />
            <h1>{page.title}</h1>
            <div dangerouslySetInnerHTML={{ __html: page.body.html }} />
        </article>
    );
}

Next.js, the proxy that answers an unpublished page

// proxy.ts, beside app/. It reads the unpublished pages every 5 minutes
// and answers them with a 301 or a 410 before the page renders.
import { type NextRequest, NextResponse } from 'next/server';

const site = 'https://citequill.com/api/v1/sites/YOUR_SITE_ID';
const headers = { Authorization: `Bearer ${process.env.CITEQUILL_KEY}` };

let removed = new Map<string, string | null>();
let readAt = 0;

export async function proxy(request: NextRequest) {
    if (Date.now() - readAt > 5 * 60 * 1000) {
        readAt = Date.now();
        // A failed read keeps the last set, and the page still answers.
        removed = await readRemoved().catch(() => removed);
    }

    const to = removed.get(request.nextUrl.pathname);

    if (to) {
        return NextResponse.redirect(to, 301);
    }

    if (to === null) {
        return new NextResponse('Gone', { status: 410 });
    }
}

async function readRemoved() {
    const read = new Map<string, string | null>();
    let cursor = '';

    do {
        const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '';
        const response = await fetch(`${site}/redirects${query}`, {
            headers,
            signal: AbortSignal.timeout(5000),
        });

        if (!response.ok) {
            throw new Error(`Citequill answered ${response.status}.`);
        }

        const body = await response.json();

        for (const record of body.redirects) {
            read.set(record.path, record.redirect?.to ?? null);
        }

        cursor = body.nextCursor;
    } while (cursor);

    return read;
}

export const config = { matcher: '/blog/:slug' };

PHP, when a page is requested

<?php
$site = 'https://citequill.com/api/v1/sites/YOUR_SITE_ID';
$key = "citequill/en/$slug";
// APCu keeps a 200 or a 410 for 5 minutes. Without it, every visit asks Citequill.
$cached = function_exists('apcu_fetch') ? apcu_fetch($key) : false;

if ($cached) {
    [$status, $body] = $cached;
} else {
    $context = stream_context_create(['http' => [
        'header' => 'Authorization: Bearer ' . getenv('CITEQUILL_KEY'),
        'ignore_errors' => true,
        'timeout' => 10,
    ]]);
    $body = file_get_contents("$site/pages/en/" . rawurlencode($slug), false, $context);
    $status = 0;

    if ($body !== false) {
        $headers = function_exists('http_get_last_response_headers')
            ? http_get_last_response_headers()
            : $http_response_header;
        $status = (int) explode(' ', $headers[0])[1];
    }

    if (($status === 200 || $status === 410) && function_exists('apcu_store')) {
        apcu_store($key, [$status, $body], 300);
    }
}

if ($status === 410) {
    $problem = json_decode($body, true);

    if ($problem['redirect']) {
        header('Location: ' . $problem['redirect']['to'], true, 301);
    } else {
        http_response_code(410);
    }

    exit;
}

if ($status === 404) {
    http_response_code(404);
    exit;
}

if ($status !== 200) {
    // Citequill or the key failed, and the page is not gone.
    http_response_code(503);
    exit;
}

$page = json_decode($body, true);
?>
<h1><?= htmlspecialchars($page['title']) ?></h1>
<?= $page['body']['html'] ?>

Python, when a page is requested

import http.client
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request

SITE = "https://citequill.com/api/v1/sites/YOUR_SITE_ID"
KEPT = {}


def read_page(locale, slug):
    """Returns the status and the body: a page on 200, the problem on 410.

    Keeps a 200 or a 410 for 5 minutes in this process, so a repeat visit in
    that time does not ask Citequill.
    """
    kept = KEPT.get((locale, slug))

    if kept and time.monotonic() - kept[0] < 300:
        return kept[1]

    request = urllib.request.Request(
        f"{SITE}/pages/{locale}/{urllib.parse.quote(slug)}",
        headers={"Authorization": f"Bearer {os.environ['CITEQUILL_KEY']}"},
    )

    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            result = response.status, json.load(response)
    except urllib.error.HTTPError as error:
        result = error.code, json.load(error) if error.code == 410 else None
    except (OSError, http.client.HTTPException, ValueError):
        return 0, None

    if result[0] in (200, 410):
        KEPT[(locale, slug)] = time.monotonic(), result

    return result


def answer(locale, slug):
    """Returns the status, the headers and the page your template renders."""
    status, body = read_page(locale, slug)

    if status == 200:
        return 200, {}, body

    if status == 410:
        redirect = body["redirect"]

        if redirect:
            return 301, {"Location": redirect["to"]}, None

        return 410, {}, None

    if status == 404:
        return 404, {}, None

    # Citequill or the key failed, and the page is not gone.
    return 503, {}, None


status, headers, page = answer("en", "first-post")

A site built ahead of time

A build that keeps a copy of the pages asks for what changed since its last build. The first sync is ?sync=start, or ?updatedSince= and a date to start from one. Its last page carries nextSyncToken, which the next build sends as ?sync= and the token.

A sync holds every page that changed, once, as it is now. A live page comes as a page object, and an unpublished one as a removal record. Tell them apart by status. A page may come again in the next sync, so key pages by id. A token lasts 30 days. After that the sync answers 410, and the build syncs again from the start.

// sync.mjs, run before the build; keep pages.json between builds
import { readFile, writeFile } from 'node:fs/promises';

const pagesUrl = 'https://citequill.com/api/v1/sites/YOUR_SITE_ID/pages';
const headers = { Authorization: `Bearer ${process.env.CITEQUILL_KEY}` };
const saved = await readFile('pages.json', 'utf8').catch(() => '{"pages":{}}');
const state = JSON.parse(saved);

let sync = state.token ?? 'start';
let cursor = null;

for (;;) {
    const query = new URLSearchParams({ sync });

    if (cursor) {
        query.set('cursor', cursor);
    }

    const response = await fetch(`${pagesUrl}?${query}`, { headers });

    if (response.status === 410 && sync !== 'start') {
        state.pages = {};
        sync = 'start';
        cursor = null;
        continue;
    }

    if (!response.ok) {
        throw new Error(`Citequill answered ${response.status}.`);
    }

    const body = await response.json();

    for (const page of body.pages) {
        if (page.status === 'published') {
            state.pages[page.id] = page;
        } else {
            delete state.pages[page.id];
        }
    }

    if (!body.nextCursor) {
        state.token = body.nextSyncToken;
        break;
    }

    cursor = body.nextCursor;
}

await writeFile('pages.json', JSON.stringify(state));

Save the deploy hook of your host as the webhook, and each change starts the next build. The next section says how.

The webhook

Citequill sends a POST to the address you save in Settings, Integration, when a page is published, changed or unpublished. The body names the pages, and your site reads them from the API. The event is one of these:

{
    "type": "page.published",
    "timestamp": "2026-10-15T06:12:00.000Z",
    "data": {
        "pages": [
            {
                "id": "6f1c2d3e-0000-4000-8000-000000000001",
                "locale": "en",
                "slug": "first-post",
                "path": "/blog/first-post",
                "url": "https://www.yoursite.com/blog/first-post"
            }
        ],
        "updated": []
    }
}

For a publication or an unpublish, data.updated lists the other language versions whose alternates the action changed.

Every request carries webhook-id, the event's id, which stays the same on every attempt. It also carries webhook-timestamp in Unix seconds, and webhook-signature. The signature is v1, and the base64 HMAC-SHA256 of the id, the timestamp and the raw body, joined by full stops. Its key is the part of your secret after whsec_, decoded from base64.

For 24 hours after you rotate the secret, the header holds 2 signatures, separated by a space. Accept either. Refuse a timestamp more than 5 minutes off your clock, and skip an id you already handled.

A 2xx within 15 seconds is a success. Citequill follows no redirect. It tries a failed delivery again for about 3 days. A 410 turns the webhook off at once. So do 5 days of failures in a row, counted from the last success or the last time you turned the webhook on. A failure more than 27.4 hours after the one before it starts the count again. You turn the webhook on again in Settings.

A deploy hook from Vercel or Cloudflare Pages, or a build hook from Netlify, works as it is. Each event then starts a build. A Next.js site that renders pages on request expires them with the route below instead. The page recipe above tags each answer it keeps with the page's locale and slug, and the route expires those tags.

Next.js, the route that receives the webhook

// app/api/citequill/route.ts. Save its address as the webhook in Citequill's
// Settings, Integration, and the secret in CITEQUILL_WEBHOOK_SECRET.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { revalidateTag } from 'next/cache';

export async function POST(request: Request) {
    const secret = (process.env.CITEQUILL_WEBHOOK_SECRET ?? '').trim();
    const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');

    // Anyone can sign with an empty key, and base64 drops what it cannot read, so
    // a secret that decodes to fewer than 16 bytes refuses every event.
    if (!secret.startsWith('whsec_') || key.length < 16) {
        return new Response(
            "CITEQUILL_WEBHOOK_SECRET holds no webhook secret from Citequill. Set it to the secret from Citequill's Settings, Integration.",
            { status: 500 },
        );
    }

    const id = request.headers.get('webhook-id') ?? '';
    const timestamp = request.headers.get('webhook-timestamp') ?? '';
    const signatures = request.headers.get('webhook-signature') ?? '';
    const body = await request.text();
    const sentAt = Number(timestamp);

    // A timestamp more than 5 minutes off is a replay or a stale retry.
    if (!Number.isFinite(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > 300) {
        return new Response('Stale timestamp', { status: 400 });
    }

    const expected = Buffer.from(
        createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64'),
    );

    // During a rotation the header holds two signatures, and either one passes.
    const isSigned = signatures.split(' ').some((signature) => {
        const given = Buffer.from(signature.replace(/^v1,/, ''));

        return (
            signature.startsWith('v1,') &&
            given.length === expected.length &&
            timingSafeEqual(given, expected)
        );
    });

    if (!isSigned) {
        return new Response('Wrong signature', { status: 401 });
    }

    const { data } = JSON.parse(body);

    for (const page of [...(data?.pages ?? []), ...(data?.updated ?? [])]) {
        revalidateTag(`citequill:${page.locale}/${page.slug}`, { expire: 0 });
    }

    return new Response(null, { status: 204 });
}

In other languages, a library of Standard Webhooks checks the signature. It lists libraries for Python, JavaScript and TypeScript, Java and Kotlin, Rust, Go, Ruby, PHP, C# and Elixir.

IndexNow

Citequill tells the search engines that read IndexNow, such as Bing and Yandex, when a page changes. It pings a page's URL when the page goes live, when a change to its body is published, and when it is unpublished. Google does not read IndexNow. Your XML sitemap tells Google.

Citequill waits at least 10 minutes between two pings of one URL, so a change made sooner is pinged later. It tries a ping that IndexNow did not take at most 5 times. Settings, Integration, show the latest pings with IndexNow's answers.

Before it pings a host, Citequill checks the host's key file, unless the file has already answered with the key. After a failed check or a 403 from IndexNow, it checks again before the next ping. Place a file named after your project's key at the root of every host of your site, such as https://www.yoursite.com/YOUR_INDEXNOW_KEY.txt, holding the key alone. A host with www and the same host without it are 2 hosts. Settings, Integration, show the key, the address of each file and whether it answered with the key.

The file must answer 200 with the key. Citequill follows no redirect when it checks the file, so a redirect fails. When the file fails, Citequill does not ping that host, and the log says why.

When your platform already pings IndexNow for the pages it serves, as Cloudflare's Crawler Hints does, turn the pings off in Settings, Integration.

A site that cannot place the file

Proxy /YOUR_INDEXNOW_KEY.txt to https://citequill.com/api/v1/sites/{siteId}/indexnow-key.txt, which answers the key with no API key. The proxy must answer the file itself, never a redirect to Citequill.

In Next.js, a rewrite in next.config.ts does it. A proxy.ts that sends every path to a locale must leave the file's path alone, because it runs before the rewrite.

Next.js, the IndexNow key file through a rewrite

// next.config.ts. Answers your IndexNow key file with the key from Citequill,
// for a site that cannot place the file in public/. Keep the rewrites you have.
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
    async rewrites() {
        return [
            {
                source: '/YOUR_INDEXNOW_KEY.txt',
                destination: 'https://citequill.com/api/v1/sites/YOUR_SITE_ID/indexnow-key.txt',
            },
        ];
    },
};

export default nextConfig;

On Cloudflare, a Worker answers the file. Give it a route for each host, such as www.yoursite.com/YOUR_INDEXNOW_KEY.txt. The host's DNS record must be proxied by Cloudflare.

A Cloudflare Worker, the IndexNow key file

// A Worker with a route for each host of your site, such as
// www.yoursite.com/YOUR_INDEXNOW_KEY.txt.
// It answers the file itself, never a redirect.
export default {
    async fetch() {
        const answer = await fetch('https://citequill.com/api/v1/sites/YOUR_SITE_ID/indexnow-key.txt');

        return new Response(await answer.text(), {
            status: answer.status,
            headers: { 'Content-Type': 'text/plain; charset=utf-8' },
        });
    },
};

Hosted builders

Shopify, Wix and Webflow render from their own CMS, and their templates cannot call the Content API. Neither can a WordPress site without PHP of its own in its theme or a plugin, or a plain HTML site with no build step. Pushing pages into these platforms is in Citequill's backlog, so today they cannot show Citequill's pages.

Some sites on such a platform also run a front end of their own, such as Next.js in front of Shopify. They use the recipes above.

What a template shows

A page object holds every value a template needs, ready to print. The template computes nothing itself.

A page has no image, so the template keeps its own og:image. The HTML has no h1, no class but language-* on code, no style and no rel or target on links. A heading has an id, so a link to #heading works, and toc lists the headings for a table of contents.

How to answer an unpublished page

A page asked for by its locale and slug after it was unpublished answers 410, with redirect beside the error. Answer your visitor with a 301 to redirect.to, or with 410 when redirect is null. A page that was never published answers 404.

/redirects lists a removal record for every unpublished page, for a site that writes its redirects at build time. An unpublished page is in neither the list nor the sitemaps.

Lists and pages

/pages takes locale, kind and limit, 20 by default and 25 at most. /redirects and /sitemap take limit, 500 by default and 1,000 at most. A larger limit counts as the maximum.

When more items follow, the answer carries nextCursor, and the next page's address in a Link: <…>; rel="next" header. Send the cursor back as it came, with the same filters. A parameter the endpoint does not take answers 400.

The XML sitemap

Citequill keeps a ready sitemap of the live pages for each host of your site, with no key. Name it in the robots.txt of that host, and Google finds the pages with no sitemap code on your site:

Sitemap: https://citequill.com/api/v1/sites/YOUR_SITE_ID/sitemaps/www.yoursite.com.xml

When a host has more pages than one file holds, the sitemap is an index of its files. A site that builds its own sitemap merges /sitemap into it instead.

Caching

Every answer under /sites carries Cache-Control: private, no-cache and an ETag. Send the ETag back in If-None-Match, and an answer that has not changed comes back as 304 with no body. A single page also carries Last-Modified.

Every request runs on Citequill's side, so keep your own copy and ask no more often than every few minutes. Citequill clears its own cache after a change: when a page is published or unpublished, and when an edit in Settings or a new crawl changes what a page shows. If a clear fails, an answer is never more than 15 minutes older than the change.

Formats

Dates are RFC 3339 in UTC with 3 fractional digits, such as 2026-10-15T06:12:00.000Z. A locale is a language, or a language and a two-letter country, such as pl or en-US. Field names are camelCase. Ignore a field or a value you do not know. Version 1 only gains fields, endpoints, values and optional parameters. A removal or a change of meaning comes in version 2.

Errors

An error is an RFC 9457 problem, with type, title, status, detail and code. Branch on code. The detail is for people and may change.

missing_key, 401
The request has no Authorization header with Bearer and a key. Send a Content API key in it.
invalid_key, 401
Citequill has no such key. It was deleted, or a character of it changed. Copy the key again, or make a new one in Settings, Integration.
wrong_site, 403
The key belongs to another project. Use a key made in the Settings of the project whose site id is in the path.
invalid_parameter, 400
A parameter of the request is unknown or malformed.
invalid_cursor, 400
The cursor does not fit this request. Send it back as the previous answer gave it, with the same filters.
page_not_found, 404
No page with this locale and slug was ever published.
site_not_found, 404
No project has this site id.
host_not_found, 404
The host is not among the base URLs of the project's locales.
sitemap_not_found, 404
The host's sitemap has fewer files. Start from the host's sitemap.
page_unpublished, 410
The page was unpublished. Answer with a 301 to redirect.to, or with 410 when redirect is null.
sync_token_invalid, 410
The sync token is older than 30 days, or it is not a token. Sync again from sync=start.
server_error, 500
Citequill could not answer the request. Send it again in a few minutes.

Vercel, which runs Citequill, may answer 429 or 503 before the API does, with a body of its own. Wait, then send the request again.