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_KEYandCRONTENT_WEBHOOK_SECRET - A Sanity project with a
post(or equivalent) document schema - A webhook receiver (Next.js route, serverless function, or worker)
@crontent/sdkand@sanity/clientinstalled in the receiver
Register the webhook once:
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 field | Sanity field | Notes |
|---|---|---|
title | title | Display H1 |
seo_title | seoTitle | Use for meta title; fall back to title |
slug | slug.current | Slug object if using Sanity slug type |
excerpt | excerpt | Card and meta description |
body_mdx / body_html | body | Convert to Portable Text (see below) |
tags | tags | Array of strings or references |
hero_image_url | mainImage | Upload or reference external URL |
canonical_url | canonicalUrl | Optional SEO override |
sources[] | sources | { url, title, publisher } objects |
ready_at | crontentReadyAt | Custom 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.
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.
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.