crontent
Integration·Drupal·advanced

Scheduled blog posts for Drupal

Scheduled blog posts for Drupal: create unpublished nodes from Crontent webhooks via JSON:API for editorial review.

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

Verify the Crontent webhook, fetch the post, and create a Drupal node via JSON:API with status: false so it stays unpublished until review.

Field mapping

Crontent fieldDrupal field
titletitle
seo_titleMetatag title or custom field
slugPath alias (set after save via Path module)
excerptfield_summary or body.summary
body_htmlbody.value with body.format: full_html
body_mdxConvert to HTML before save
tagsfield_tags entity references
hero_image_urlfield_image (create media entity first)
canonical_urlMetatag canonical or custom field
sources[]field_sources or append to body

Webhook to unpublished node

Create a dedicated API user with permission to create unpublished content. Use JSON:API authentication (OAuth or basic with HTTPS).

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

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

async function createDrupalDraft(post: Awaited<ReturnType<typeof crontent.posts.get>>) {
  const sourcesHtml = post.sources.length
    ? `<h2>Sources</h2><ul>${post.sources.map((s) =>
        `<li><a href="${s.url}">${s.title ?? s.url}</a></li>`
      ).join("")}</ul>`
    : "";

  const res = await fetch(`${process.env.DRUPAL_BASE_URL}/jsonapi/node/article`, {
    method: "POST",
    headers: {
      "Content-Type": "application/vnd.api+json",
      Accept: "application/vnd.api+json",
      Authorization: `Basic ${Buffer.from(`${process.env.DRUPAL_USER}:${process.env.DRUPAL_PASS}`).toString("base64")}`,
    },
    body: JSON.stringify({
      data: {
        type: "node--article",
        attributes: {
          title: post.title,
          status: false,
          body: {
            value: (post.body_html ?? "") + sourcesHtml,
            format: "full_html",
            summary: post.excerpt ?? "",
          },
        },
      },
    }),
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

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 createDrupalDraft(post);
  return new Response("ok");
}

Set the path alias to post.slug in a follow-up PATCH to /jsonapi/path_alias/path_alias or via Pathauto after review.

Metatags and media

Install Metatag for seo_title and canonical. Upload hero images through the JSON:API media endpoint before linking field_image.

Backfill

Import historical posts with posts.list. Query existing nodes by title or alias to skip duplicates.

Environment variables

text
CRONTENT_API_KEY=...
CRONTENT_WEBHOOK_SECRET=...
DRUPAL_BASE_URL=https://example.com
DRUPAL_USER=api-writer
DRUPAL_PASS=...

Further reading