crontent
Integration·Sanity·intermediate

AI content for Sanity

AI content for Sanity: wire Crontent webhooks to create researched draft posts in Sanity Studio with field mapping, sources, and dual titles.

How-to: Integrate Crontent with Sanity. Official docs: Sanity. Shared API contract: Publishing API and SDK.

Verify the Crontent webhook signature, fetch the post with @crontent/sdk, map fields into a Sanity document, and save a Studio draft for review before publish.

What you need

  • A Crontent project with CRONTENT_API_KEY and CRONTENT_WEBHOOK_SECRET
  • A Sanity project with a post (or equivalent) document schema
  • A webhook receiver (Next.js route, serverless function, or worker)
  • @crontent/sdk and @sanity/client installed in the receiver

Register the webhook once:

ts
await crontent.webhooks.register({
  projectId: process.env.CRONTENT_PROJECT_ID!,
  url: "https://your-app.com/api/crontent/webhook",
  events: ["post.ready"],
});

Map Crontent fields to Sanity

Crontent fieldSanity fieldNotes
titletitleDisplay H1
seo_titleseoTitleUse for meta title; fall back to title
slugslug.currentSlug object if using Sanity slug type
excerptexcerptCard and meta description
body_mdx / body_htmlbodyConvert to Portable Text (see below)
tagstagsArray of strings or references
hero_image_urlmainImageUpload or reference external URL
canonical_urlcanonicalUrlOptional SEO override
sources[]sources{ url, title, publisher } objects
ready_atcrontentReadyAtCustom metadata field

Crontent does research and drafting; you steer the angle in the brief. Treat every inbound post as a draft until an editor approves it in Sanity Studio.

Receive and verify webhooks

Crontent sends a thin payload (event, post_id, project_id, ready_at). Retries run at 1m, 5m, 30m, 2h, and 12h. Deduplicate on X-Crontent-Delivery.

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

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

export async function POST(req: Request) {
  const rawBody = await req.text();
  const deliveryId = req.headers.get("x-crontent-delivery")!;

  if (await alreadyProcessed(deliveryId)) return new Response("OK");

  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("Unauthorized", { status: 401 });

  const { post_id } = JSON.parse(rawBody);
  const post = await crontent.posts.get(post_id);
  await createSanityDraft(post);
  await markProcessed(deliveryId);
  return new Response("OK");
}

Create a draft in Sanity

Use createOrReplace with _id prefixed drafts. so documents land unpublished in Studio. Convert HTML to Portable Text with @portabletext/block-tools or store body_html in a custom HTML field if your schema allows it.

ts
import { createClient } from "@sanity/client";

const sanity = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: "production",
  token: process.env.SANITY_WRITE_TOKEN!,
  apiVersion: "2024-01-01",
  useCdn: false,
});

async function createSanityDraft(post: Post) {
  const docId = `crontent-${post.id}`;
  await sanity.createOrReplace({
    _id: `drafts.${docId}`,
    _type: "post",
    title: post.title,
    seoTitle: post.seo_title ?? post.title,
    slug: { _type: "slug", current: post.slug ?? docId },
    excerpt: post.excerpt,
    body: post.body_html, // or Portable Text blocks
    tags: post.tags,
    sources: post.sources,
  });
}

Backfill and polling

Missed webhooks? Poll crontent.posts.list({ projectId, since, limit, cursor }) and create drafts for posts not yet in Sanity. Store post.id on the document to avoid duplicates.

Official documentation