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.
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.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.
npm install @crontent/sdk
# or: pnpm add @crontent/sdk / yarn add @crontent/sdkGet 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.
Make your first request
Confirm the key works by listing the project's posts. This is the fastest way to see real data flowing.
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.
const { id, secret } = await crontent.webhooks.register({
projectId: "proj_xyz",
url: "https://example.com/api/crontent/webhook",
events: ["post.ready"], // default
});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.
// 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.
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.
Authorization: Bearer ck_live_xxxxxxxxxxxxxxxxxxxxxxxxThe full key is shown once at creation; only its hash is stored. Manage keys — create and revoke — from API & settings.
new Crontent(options)
| Option | Type | Description |
|---|---|---|
| apiKey required | string | Your project-scoped key, ck_live_…. |
| baseUrl | string | API base URL. Defaults to https://api.crontent.co. |
const crontent = new Crontent({
apiKey: process.env.CRONTENT_API_KEY!,
baseUrl: "https://api.crontent.co", // optional override
});
crontent.posts; // PostsAPI
crontent.webhooks; // WebhooksAPIPosts
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.
const post = await crontent.posts.get("post_abc123");{
"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.
| Option | Type | Description |
|---|---|---|
| projectId required | string | Project to list. Must match your key's project. |
| since | string | ISO 8601 lower bound on created_at. |
| limit | number | 1 to 100, default 20. |
| cursor | string | Opaque cursor from a previous next_cursor. |
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.
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.
| Option | Type | Description |
|---|---|---|
| rawBody required | string | The exact request body string. |
| signature required | string | null | X-Crontent-Signature header. |
| timestamp required | string | null | X-Crontent-Timestamp header. |
| secret required | string | Your webhook signing secret. |
| toleranceSeconds | number | Replay window. Default 300 (5 min). |
Signatures
Every delivery is a POST with a JSON body and these headers:
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:
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:
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.
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.
Types
The package ships TypeScript types. The core shapes are:
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.
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;
}
}| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing or invalid API key. |
| 401 | key_revoked | The key has been revoked. |
| 403 | forbidden | Key's project doesn't match the path. |
| 404 | not_found | Resource missing or not owned by your project. |
| 400 | invalid_body / invalid_query / invalid_cursor | Request validation failed. |