crontent
Developers·Publishing API

Publishing API and SDK

Get from npm install to a live, signature-verified webhook in six steps. Then dig into the full SDK and REST reference below.

Crontent generates posts on a schedule. When one is ready it notifies your app with a small signed webhook — just an id and an event, not the content. Your handler verifies the signature, fetches the full post, and stores it. The six steps below walk through that loop end to end.

Two secrets do two jobs. An API key (ck_live_…) authorizes REST requests like fetching a post. A webhook secret (whsec_…) lets you verify that a delivery genuinely came from Crontent. You create both in the next two steps.
Part 1 · Get started

Install the SDK

@crontent/sdk is a zero-dependency ESM package. It uses the platform fetch and crypto.subtle, so it runs on Node 20+, Cloudflare Workers, Bun, and Deno.

bash
npm install @crontent/sdk
# or: pnpm add @crontent/sdk / yarn add @crontent/sdk

Get your keys

Open API & settings in your Crontent dashboard, pick the project this integration is for, and create an API key. The full key is shown once — copy it now and store it as CRONTENT_API_KEY.

A key is scoped to a single project: it can read only that project's posts and manage only that project's webhooks. Lost a key? Revoke it and create a new one — it can't be retrieved later.

Make your first request

Confirm the key works by listing the project's posts. This is the fastest way to see real data flowing.

typescript
import { Crontent } from "@crontent/sdk";

const crontent = new Crontent({ apiKey: process.env.CRONTENT_API_KEY! });

const { data } = await crontent.posts.list({ projectId: "proj_xyz", limit: 5 });
console.log(data.map((p) => p.title));

Got titles back? Your key is live. Full details on posts.list and posts.get are in the Posts reference.

Register a webhook

Tell Crontent where to send post.ready events. The URL must be HTTPS and public (not a private or loopback address). The response includes your signing secret.

typescript
const { id, secret } = await crontent.webhooks.register({
  projectId: "proj_xyz",
  url: "https://example.com/api/crontent/webhook",
  events: ["post.ready"], // default
});
The secret is returned once and can't be retrieved later. Store it as CRONTENT_WEBHOOK_SECRET — you need it to verify every delivery in the next step.

Receive & verify a delivery

A Next.js App Router handler that verifies the signature, deduplicates on the delivery id, fetches the full post, and stores it as a draft.

typescript
// app/api/crontent/webhook/route.ts
import { Crontent } from "@crontent/sdk";
import { NextRequest } from "next/server";

const crontent = new Crontent({ apiKey: process.env.CRONTENT_API_KEY! });

export async function POST(req: NextRequest) {
  const rawBody = await req.text(); // verify the RAW body, never re-parsed JSON

  const valid = await crontent.webhooks.verify({
    rawBody,
    signature: req.headers.get("x-crontent-signature"),
    timestamp: req.headers.get("x-crontent-timestamp"),
    secret: process.env.CRONTENT_WEBHOOK_SECRET!,
  });
  if (!valid) return new Response("invalid signature", { status: 401 });

  // A delivery may be retried after it already succeeded — dedupe on its id.
  const deliveryId = req.headers.get("x-crontent-delivery")!;
  if (await alreadyProcessed(deliveryId)) return new Response("ok", { status: 200 });

  const { event, post_id } = JSON.parse(rawBody);
  if (event !== "post.ready") return new Response("ok", { status: 200 });

  const post = await crontent.posts.get(post_id);
  await db.posts.insert({
    slug: post.slug,
    title: post.title,            // display headline — the on-page H1
    seo_title: post.seo_title,    // query-shaped — <title>/og:title; falls back to title
    body_mdx: post.body_mdx,
    excerpt: post.excerpt,
    tags: post.tags,
    hero_image_url: post.hero_image_url,
    status: "draft", // review before publish
    crontent_post_id: post.id,
  });

  await markProcessed(deliveryId);
  return new Response("ok", { status: 200 });
}
verify is asynchronous (it uses Web Crypto) — always await it, and always pass the raw, unparsed body. Re-serializing parsed JSON changes the bytes and the signature won't match.

You're live

That's the whole loop. From here Crontent handles delivery reliability for you: if your endpoint returns a non-2xx, times out (10s), or errors, it retries on a back-off schedule of 1m, 5m, 30m, 2h, then 12h — six attempts total. After the final failure the webhook is marked unhealthy in your dashboard, where you can inspect the delivery log and redeliver manually. See Retries & delivery for the full lifecycle.

Platform-specific field maps (Sanity, Ghost, WordPress, Next.js, and more) live under Integrations. Use those when you want the create-draft call for a particular CMS rather than the generic contract on this page.

Part 2 · Reference

Detailed behavior for each part of the SDK and the underlying REST API. Land here from the table of contents once you know the flow above.

Authentication

REST requests authenticate with a project-scoped bearer key. A key can read only its own project's posts and manage only its own project's webhooks.

http
Authorization: Bearer ck_live_xxxxxxxxxxxxxxxxxxxxxxxx

The full key is shown once at creation; only its hash is stored. Manage keys — create and revoke — from API & settings.

new Crontent(options)

OptionTypeDescription
apiKey requiredstringYour project-scoped key, ck_live_….
baseUrlstringAPI base URL. Defaults to https://api.crontent.co.
typescript
const crontent = new Crontent({
  apiKey: process.env.CRONTENT_API_KEY!,
  baseUrl: "https://api.crontent.co", // optional override
});

crontent.posts;     // PostsAPI
crontent.webhooks;  // WebhooksAPI

Posts

posts.get(id)

Returns the full Post, including body_mdx and body_html. Throws a CrontentError with status 404 if the post does not exist or is not owned by your key's project.

typescript
const post = await crontent.posts.get("post_abc123");
json
{
  "id": "post_abc123",
  "project_id": "proj_xyz",
  "run_id": "run_456",
  "title": "Stop paying for eval platforms you don't need.",
  "seo_title": "Are LLM eval platforms worth paying for?",
  "slug": "stop-paying-for-eval-platforms",
  "excerpt": "Every LLM provider is shipping first-party eval tooling now.",
  "body_mdx": "...",
  "body_html": "...",
  "tags": ["llm", "tooling", "eval"],
  "hero_image_url": "https://cdn.crontent.co/hero/...",
  "canonical_url": null,
  "angle": { "kind": "operator", "strength": 0.86, "confidence": 0.82 },
  "sources": [{ "url": "...", "title": "...", "publisher": "..." }],
  "word_count": 1180,
  "created_at": "2026-05-24T09:02:14Z",
  "ready_at": "2026-05-24T09:02:14Z"
}

posts.list(options)

Lists a project's posts, newest first. Each item is a summary that omits body_mdx, body_html, and sources. Responses include an opaque cursor for pagination. Pass since to fetch posts created after a given time — useful for recovering from missed deliveries.

OptionTypeDescription
projectId requiredstringProject to list. Must match your key's project.
sincestringISO 8601 lower bound on created_at.
limitnumber1 to 100, default 20.
cursorstringOpaque cursor from a previous next_cursor.
typescript
let cursor: string | null = null;
do {
  const page = await crontent.posts.list({ projectId: "proj_xyz", cursor: cursor ?? undefined });
  for (const summary of page.data) {
    // summary.id, summary.title, summary.slug, summary.tags, ...
  }
  cursor = page.next_cursor;
} while (cursor);

Webhooks

webhooks.register(options)

Registers a webhook endpoint and returns its signing secret. The URL must be HTTPS and must not resolve to a private or loopback address. The secret is returned once.

typescript
const { id, secret } = await crontent.webhooks.register({
  projectId: "proj_xyz",
  url: "https://example.com/api/crontent/webhook",
  events: ["post.ready"], // default
});

webhooks.verify(options)

Returns true only if the signature matches and the timestamp is within the tolerance window. The comparison is constant-time. Pass the raw, unparsed request body.

OptionTypeDescription
rawBody requiredstringThe exact request body string.
signature requiredstring | nullX-Crontent-Signature header.
timestamp requiredstring | nullX-Crontent-Timestamp header.
secret requiredstringYour webhook signing secret.
toleranceSecondsnumberReplay window. Default 300 (5 min).

Signatures

Every delivery is a POST with a JSON body and these headers:

http
POST /api/crontent/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Crontent-Webhook/1.0
X-Crontent-Event: post.ready
X-Crontent-Delivery: 0d7c2c1e-...
X-Crontent-Signature: sha256=9a7c...
X-Crontent-Timestamp: 1716537734

{"event":"post.ready","project_id":"proj_xyz","post_id":"post_abc123","ready_at":"2026-05-24T09:02:14Z"}

The signature covers the timestamp and the raw body. Including the timestamp in the signed value lets the receiver reject replayed requests:

text
X-Crontent-Signature = "sha256=" + hex( HMAC_SHA256( secret, timestamp + "." + rawBody ) )

webhooks.verify() performs this check for you, including the tolerance window and a constant-time comparison. Verify against the raw request body — re-serializing parsed JSON changes the bytes and invalidates the signature.

Retries & delivery

Respond with a 2xx quickly. Crontent retries on any non-2xx response, a timeout (10s), or a connection error, on this back-off schedule:

text
attempt 1  →  immediate
attempt 2  →  + 1m
attempt 3  →  + 5m
attempt 4  →  + 30m
attempt 5  →  + 2h
attempt 6  →  + 12h   (final)

A successful delivery marks the webhook healthy. After the sixth failure it's marked unhealthy and the delivery is exhausted. Disabled webhooks are skipped entirely. You can inspect every attempt and manually redeliver from the webhook's delivery log in your dashboard.

Because retries can re-send a delivery that already succeeded, always deduplicate on the X-Crontent-Delivery id, as shown in step 5.

REST reference

The SDK wraps these endpoints. All require Authorization: Bearer ck_live_… and are scoped to the key's project. The API is versioned at /v1.

GET/v1/posts/:idCanonical post JSON
GET/v1/projects/:id/postsList posts (cursor paginated)
POST/v1/projects/:id/api-keysMint a scoped key (returned once)
DELETE/v1/api-keys/:idRevoke a key
POST/v1/projects/:id/webhooksRegister a webhook (secret once)
GET/v1/projects/:id/webhooksList registered webhooks
DELETE/v1/webhooks/:idRemove a webhook
GET/v1/webhooks/:id/deliveriesDelivery log for debugging
POST/v1/webhooks/:id/deliveries/:deliveryId/redeliverManually retry a delivery

Types

The package ships TypeScript types. The core shapes are:

typescript
export type WebhookEvent = "post.ready";

export interface Angle {
  kind: string | null;
  strength: number | null;
  confidence: number | null;
}

export interface Source {
  url: string;
  title: string | null;
  publisher: string | null;
}

export interface Post {
  id: string;
  project_id: string;
  run_id: string;
  title: string | null;
  // Query-shaped discovery title: use for <title> and og:title,
  // keep `title` as the visible H1. Falls back to `title` when null.
  seo_title: string | null;
  slug: string | null;
  excerpt: string | null;
  body_mdx: string;
  body_html: string | null;
  tags: string[];
  hero_image_url: string | null;
  canonical_url: string | null;
  angle: Angle;
  sources: Source[];
  word_count: number | null;
  created_at: string;
  ready_at: string | null;
}

// List responses omit the heavy fields.
export type PostSummary = Omit<Post, "body_mdx" | "body_html" | "sources">;

export interface WebhookEventPayload {
  event: WebhookEvent;
  project_id: string;
  post_id: string;
  ready_at: string;
}

Errors

Non-2xx responses throw a CrontentError with the HTTP status and a machine-readable code.

typescript
import { Crontent, CrontentError } from "@crontent/sdk";

try {
  const post = await crontent.posts.get("post_missing");
} catch (err) {
  if (err instanceof CrontentError) {
    console.error(err.status, err.code); // e.g. 404 "not_found"
  } else {
    throw err;
  }
}
StatusCodeMeaning
401unauthorizedMissing or invalid API key.
401key_revokedThe key has been revoked.
403forbiddenKey's project doesn't match the path.
404not_foundResource missing or not owned by your project.
400invalid_body / invalid_query / invalid_cursorRequest validation failed.