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_KEYandCRONTENT_WEBHOOK_SECRET - A Storyblok space with a
postcontent type (blok schema) - Storyblok Management API token (Personal Access Token or OAuth)
- HTTPS webhook endpoint
Register the webhook:
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 field | Storyblok field | Notes |
|---|---|---|
title | title | Story name and H1 blok |
seo_title | seo_title | Meta title; fall back to title |
slug | slug | Story slug |
excerpt | excerpt | Text blok |
body_mdx / body_html | body | Richtext or markdown blok |
tags | tags | Text list or datasource |
hero_image_url | hero_image | Asset or external URL |
canonical_url | canonical_url | Text field |
sources[] | sources | Bloks or table field |
ready_at | crontent_ready_at | Datetime |
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.
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.
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.