crontent
Integration·Storyblok·intermediate

Scheduled blog posts for Storyblok

Scheduled blog posts for Storyblok: map Crontent fields into draft stories through the Storyblok Management API.

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

Verify Crontent webhooks, fetch each post, and create a draft Storyblok story via the Management API with mapped titles, body, and sources before anyone publishes.

What you need

  • Crontent CRONTENT_API_KEY and CRONTENT_WEBHOOK_SECRET
  • A Storyblok space with a post content type (blok schema)
  • Storyblok Management API token (Personal Access Token or OAuth)
  • HTTPS webhook endpoint

Register the webhook:

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 Storyblok

Crontent fieldStoryblok fieldNotes
titletitleStory name and H1 blok
seo_titleseo_titleMeta title; fall back to title
slugslugStory slug
excerptexcerptText blok
body_mdx / body_htmlbodyRichtext or markdown blok
tagstagsText list or datasource
hero_image_urlhero_imageAsset or external URL
canonical_urlcanonical_urlText field
sources[]sourcesBloks or table field
ready_atcrontent_ready_atDatetime

Crontent handles scheduled research and drafting from your brief. You steer angle; stories land as drafts (is_startpage: false, unpublished) for review.

Receive and verify webhooks

Deduplicate on X-Crontent-Delivery. Crontent retries at 1m, 5m, 30m, 2h, 12h on non-2xx responses.

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 seen(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 createStoryblokDraft(post);
  await markSeen(deliveryId);
  return new Response("OK");
}

Create a draft in Storyblok

Use the Management API POST /v1/spaces/{space_id}/stories. Set publish: 0 to keep the story as a draft.

ts
async function createStoryblokDraft(post: Post) {
  const res = await fetch(
    `https://mapi.storyblok.com/v1/spaces/${process.env.STORYBLOK_SPACE_ID}/stories`,
    {
      method: "POST",
      headers: {
        Authorization: process.env.STORYBLOK_MANAGEMENT_TOKEN!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        story: {
          name: post.title,
          slug: post.slug,
          content: {
            component: "post",
            title: post.title,
            seo_title: post.seo_title ?? post.title,
            excerpt: post.excerpt,
            body: post.body_html ?? post.body_mdx,
            tags: post.tags.join(", "),
            sources: post.sources,
            crontent_post_id: post.id,
          },
        },
        publish: 0,
      }),
    }
  );
  if (!res.ok) throw new Error(await res.text());
}

Backfill and polling

Poll crontent.posts.list({ projectId, since, limit, cursor }) and skip stories where crontent_post_id already exists. Helpful after webhook outages.

Official documentation