crontent
Integration·Notion·intermediate

Scheduled blog posts for Notion

Scheduled blog posts for Notion: create draft Notion database pages from Crontent post.ready webhooks using the Notion API.

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

Verify the Crontent webhook, fetch the post, and create a Notion database page with mapped properties before you mark it ready to share.

Field mapping

Crontent fieldNotion property
titleName (title)
seo_titleSEO Title (rich_text)
slugSlug (rich_text or formula)
excerptExcerpt (rich_text)
body_mdxConvert to Notion blocks
body_htmlParse to blocks or store in page body
tagsTags (multi_select)
hero_image_urlCover external URL
canonical_urlCanonical URL (url)
sources[]Bulleted list blocks at page bottom

Webhook to Notion draft

Create an integration at Notion integrations and share your database with it.

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

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

function paragraph(text: string) {
  return { object: "block", type: "paragraph", paragraph: { rich_text: [{ type: "text", text: { content: text.slice(0, 2000) } }] } };
}

async function createNotionDraft(post: Awaited<ReturnType<typeof crontent.posts.get>>) {
  const blocks = (post.body_mdx ?? "").split("\n\n").slice(0, 100).map(paragraph);
  if (post.sources.length) {
    blocks.push(paragraph("Sources"));
    for (const s of post.sources) {
      blocks.push(paragraph(`${s.title ?? s.url}: ${s.url}`));
    }
  }

  return notion.pages.create({
    parent: { database_id: process.env.NOTION_DATABASE_ID! },
    cover: post.hero_image_url ? { type: "external", external: { url: post.hero_image_url } } : undefined,
    properties: {
      Name: { title: [{ text: { content: post.title ?? "Untitled" } }] },
      Status: { select: { name: "Draft" } },
      Slug: { rich_text: [{ text: { content: post.slug ?? "" } }] },
      Excerpt: { rich_text: [{ text: { content: post.excerpt ?? "" } }] },
      "SEO Title": { rich_text: [{ text: { content: post.seo_title ?? post.title ?? "" } }] },
      "Canonical URL": post.canonical_url ? { url: post.canonical_url } : undefined,
      Tags: { multi_select: post.tags.map((name) => ({ name })) },
    },
    children: blocks,
  });
}

export async function handleWebhook(req: Request) {
  const rawBody = await req.text();
  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 createNotionDraft(post);
  return new Response("ok");
}

For production MDX, use a Markdown-to-Notion converter library. The example splits paragraphs for brevity.

Publishing workflow

Editors review in Notion, fix formatting, set Status to Published. Notion Sites or Super/Potion frontends pick up published rows.

Backfill

Paginate posts.list and create draft pages for missing slugs. Notion API has rate limits (roughly 3 requests per second).

Environment variables

text
CRONTENT_API_KEY=...
CRONTENT_WEBHOOK_SECRET=...
NOTION_TOKEN=...
NOTION_DATABASE_ID=...

Further reading