PrismDraft SDK

Recipes

Five complete flows, each with the permissions it needs. The snippets assume pd and the ids described in the overview are in scope, except where a snippet builds its own client.

Publish an imported article

Create a draft from Markdown, send it through review, and publish it with its original date.

The key needs articles.create, articles.update (submitting counts as an update), articles.approve and articles.publish. The Editor role has all four. articles.publish is needed at the first step too, because the draft carries a publishedAt.

const wantedSlug = "choisir-un-crm";
const publishedAt = "2019-03-04T10:00:00.000Z";

// 1. Create the draft. The Markdown is converted on the server.
const { article, warnings } = await pd.articles.create({
  body: {
    projectId,
    title: "Comment choisir un CRM en 2026",
    slug: wantedSlug,
    language: "fr",
    publishedAt, // stored on the draft; the publish call repeats it
    imageBaseUrl: "https://example.com/", // relative image URLs resolve against it
    contentMarkdown: "# Choisir un CRM\n\nUn **paragraphe**.\n\n## Le prix\n\nCela dépend.\n",
  },
});

// The slug is made unique within its language: compare it with the one you asked for.
if (article.slug !== wantedSlug) console.log(`the slug was taken; got ${article.slug}`);
for (const duplicate of warnings) console.log("possible duplicate:", duplicate.title);

// 2. Review.
await pd.articles.submit({ articleId: article.id });
await pd.articles.approve({ articleId: article.id });

// 3. Publish, repeating the date: a publish without it stamps the current time.
const published = await pd.articles.publish({ articleId: article.id, body: { publishedAt } });
console.log(published.status, published.cmsIntegration, published.warnings);

What to know:

  • Order. An article must be APPROVED to be published, and approval needs a submit first. pd.articles.publish on any other status answers 409 (CONFLICT).
  • Images. The API downloads each image the Markdown refers to: http and https only, at most 50 distinct images of 25 MB each, and relative URLs need imageBaseUrl. If any image or construct is refused, no article is created. The leading image (first in the document, or right after the level-1 heading) becomes the article's hero.
  • Markdown the API cannot import (HTML blocks, task lists, footnotes and others) answers 400 with details.issues listing each { construct, line }. See Errors.
  • No idempotency key. If the create call fails with a timeout, the article may exist. Look before you create again:
const existing = await pd.articles.list({
  workspaceId,
  projectId,
  slug: "choisir-un-crm",
  language: "fr",
});
console.log(existing.total === 0 ? "not created" : `already created: ${existing.articles[0]?.id}`);
  • Another language of an existing article. Pass its translationGroupId in the create call. The group must hold an article of the same project and no article in that language yet (409 otherwise).
  • After the publish, the article.published webhook is sent, and a publishing integration, if the project has one, is handed the article in the background. cmsIntegration names it, or is null.

Plan a month of articles

Ask for a month of article ideas, one per publishing slot, look at them, and put them on the calendar. A plan only fills the calendar: it does not write or publish any article.

The key needs articles.create for pd.plans.suggest and pd.plans.create, articles.read for pd.plans.list and pd.plans.get, and articles.update for pd.plans.update. The Editor role has all three.

const cadence = {
  postsPerWeek: 3,
  weeks: 4,
  // A week from today: the first slot must still be ahead when you call.
  startsOn: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10),
  runTime: "09:00",
  timezone: "Europe/Paris",
};

// 1. Suggest. Nothing is saved; the call costs 1 credit per model call that brought new ideas.
const { suggestions, estimate, credits } = await pd.plans.suggest({ projectId, body: cadence });
console.log(`${suggestions.length} articles, at most ${estimate.total} credits to write them`);
console.log(`${credits} credits charged for the suggestions`);

// 2. Confirm what you want. A fresh key per plan makes a retried call safe.
const idempotencyKey = crypto.randomUUID();
const { plan, items } = await pd.plans.create({
  projectId,
  body: {
    ...cadence,
    idempotencyKey,
    items: suggestions.map(
      ({ title, primaryKeyword, searchIntent, contentCluster, localDate }) => ({
        title,
        primaryKeyword,
        searchIntent,
        contentCluster,
        localDate,
      }),
    ),
  },
});
console.log(plan.id, plan.progress, items.length);

// 3. Read it back later.
const { plans } = await pd.plans.list({ workspaceId });
const current = await pd.plans.get({ workspaceId, planId: plan.id });
console.log(plans.length, current.items[0]?.localDate);

What to know:

  • Dates. Each suggestion carries localDate, its day in the plan's timezone, and scheduledFor, the same slot as a UTC instant. Pass localDate back as it is.
  • Fewer than the slots. The model can bring fewer new ideas than there are slots. The answer is then shorter, and estimate matches the suggestions it lists.
  • The first slot must be ahead. Both calls answer 400 (INVALID_REQUEST) when the first slot, startsOn at runTime in timezone, has passed.
  • Answers you will meet. plans.suggest answers 402 (INSUFFICIENT_CREDITS) when the balance cannot cover the call, and 429 (QUOTA_EXCEEDED) when a quota of the workspace is used up; nothing is charged in either case. plans.create answers 409 with details.reason set to plan_limit when the workspace already has 3 open plans (those of an archived project do not count); cancel one with pd.plans.update and status: "cancelled", then send it again.
  • Retries. Sending plans.create again with the same idempotencyKey answers 200 with the plan made the first time, and makes nothing new, even if that plan's first slot has passed since. A key that a plan of another project already used answers 409 with details.reason set to idempotency_key_reused.
  • Nothing is written by default. plan.autopilot is false, and the calendar items stay planned. plan.estimatedCredits and plan.creditCap are figures for writing the articles later; plan.creditsSpent stays 0 until something is written.
  • Autopilot. Pass autopilot: true to plans.create, or to plans.update on an existing plan, and each article is written, approved and published on its day. It needs articles.generate, articles.approve and articles.publish on the project and a person's session: an API key answers 403 (FORBIDDEN), as does a member without those permissions. The caller becomes the member whose permissions are checked again on every run; resuming a paused autopilot plan makes the resuming member that person.
  • Item status follows the run. With autopilot on, an item's status goes planned, writing when its article starts, approved when it moves on to publication, and published as soon as the source article is live, which is before its translations finish. An item sent back for a second attempt returns to planned; one that is parked keeps the status of the last step it reached. runState stays the precise field for where the run is. Items finished before this change keep planned.
  • Translations. With autopilot on, a project that has more than one language gets every article translated and published into each other language. The plan's estimatedCredits and creditCap include those translations, and an item stays translating until all of them are live.
  • Changing the hour. pd.plans.update with runTime: "18:30" moves every item that has not started and still lies ahead to that time on its own day, on the wall of the plan's timezone. Items already running, finished, parked or skipped, and items already overdue, stay where they are. It answers 400 (INVALID_REQUEST) when the new time would put an upcoming item in the past, and then moves nothing.

Sync a site from webhooks

Keep a copy of the published articles in your own store. Subscribe an endpoint to article.published, article.updated, article.unpublished and image.regenerated (the registering key needs integrations.create; see Webhooks). The receiving key needs only articles.read.

Deliveries can arrive more than once and out of order. So the receiver does not act on the event's contents. It treats the event as a hint that one article changed, reads the article's current state, and makes the store match it. A repeated or reordered delivery then does no harm.

import {
  PrismDraft,
  PrismDraftError,
  UnknownWebhookEventError,
  constructWebhookEvent,
  type WebhookEvent,
} from "@prism-draft/sdk";

const pd = new PrismDraft({ apiKey: process.env.PRISMDRAFT_API_KEY!, timeoutMs: 10_000 });

// Your site's store. Replace the Map with a database.
const pages = new Map<
  string,
  { slug: string; language: string; title: string; markdown: string; hero: string | null }
>();

async function sync(articleId: string): Promise<void> {
  try {
    const { article, assets } = await pd.articles.get({ articleId, stripLead: "true" });
    if (article.status === "PUBLISHED") {
      pages.set(article.id, {
        slug: article.slug,
        language: article.primaryLanguage,
        title: article.title,
        markdown: article.contentMarkdown ?? "",
        hero: assets.find((asset) => asset.type === "hero")?.publicUrl ?? null,
      });
    } else {
      pages.delete(article.id);
    }
  } catch (error) {
    // A deleted article answers 404.
    if (error instanceof PrismDraftError && error.code === "RESOURCE_NOT_FOUND") {
      pages.delete(articleId);
    } else {
      throw error;
    }
  }
}

async function handle(event: WebhookEvent): Promise<void> {
  switch (event.event) {
    case "article.published":
    case "article.updated":
    case "article.unpublished":
    case "image.regenerated":
      await sync(event.data.articleId);
      break;
    default:
      break; // an event this site does not use
  }
}

export async function POST(request: Request): Promise<Response> {
  const rawBody = await request.text();
  let event: WebhookEvent;
  try {
    event = await constructWebhookEvent({
      secret: process.env.PRISMDRAFT_WEBHOOK_SECRET!,
      rawBody,
      signature: request.headers.get("x-prism-draft-signature") ?? "",
    });
  } catch (error) {
    if (error instanceof UnknownWebhookEventError) return new Response(null, { status: 204 });
    return new Response("invalid signature", { status: 400 });
  }
  try {
    await handle(event);
  } catch {
    return new Response("sync failed", { status: 500 }); // PrismDraft retries a 5xx
  }
  return new Response(null, { status: 204 });
}
  • The store is keyed by article id, so a changed slug updates the entry instead of leaving the old one behind. Serve a page by looking up slug and language.
  • stripLead: "true" leaves out the first heading and the hero image from contentMarkdown, for a site that prints the title and the cover itself. The hero's URL comes from assets.
  • Answering 500 makes PrismDraft retry the delivery (up to 6 attempts over about 15 minutes). A 4xx is never retried.
  • Webhooks are not a history. To fill the store the first time, or to repair it, list every published article with the loop in the next recipe and call sync for each.

Paginate everything

pd.articles.list returns one page and the number of matches. This generator walks all of them:

import type { ArticlesListInput, ArticlesListOutput } from "@prism-draft/sdk";

type ArticleSummary = ArticlesListOutput["articles"][number];

async function* allArticles(
  filter: Omit<ArticlesListInput, "limit" | "offset">,
): AsyncGenerator<ArticleSummary> {
  let offset = 0;
  for (;;) {
    const page = await pd.articles.list({ ...filter, limit: 100, offset });
    if (page.articles.length === 0) return;
    yield* page.articles;
    offset += page.articles.length;
    if (offset >= page.total) return;
  }
}

const slugs: string[] = [];
for await (const article of allArticles({ workspaceId, status: "PUBLISHED" })) {
  slugs.push(article.slug);
}
console.log(slugs.length, "published articles");
  • limit is at most 100. The default is 25.
  • The list is ordered by newest change first. An article that is edited while you walk the pages moves to the front, which can make another article appear twice or be missed. Deduplicate by id, and do not edit what you are listing while you list it. To keep a copy current over time, use webhooks.
  • pd.articleGroups.list (items in groups) and pd.dashboard.listAudit (items in entries) page the same way, with total. pd.blog.listPosts has no total: stop at a page with fewer items than limit. Conventions lists the limits.

Bulk-update categories

Two cases: rename a category, and put a category on many articles.

Renaming is one call, and it changes every article that carries the category. The key needs categories.read and categories.update.

const { categories } = await pd.categories.list({ workspaceId });
const guides = categories.find((category) =>
  category.translations.some((name) => name.language === "en" && name.name === "Guides"),
);
if (guides === undefined) throw new Error("no category named Guides");

await pd.categories.update({
  workspaceId,
  categoryId: guides.id,
  body: { translations: [{ language: "en", name: "How-to guides" }] }, // renames the "en" name
});

Putting a category on many articles takes one call per article, and needs articles.read and articles.update. pd.categories.setForArticle replaces the article's whole set, so read the current ids and add yours. It is safe to send again, because it sets the same result each time.

Collect the articles first, then write, so the writes do not move the list you are walking:

import { PrismDraftError } from "@prism-draft/sdk";

const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

// Repeats a call once after the API's rate limit (300 requests a minute by default) refuses it.
async function pacing<T>(call: () => Promise<T>): Promise<T> {
  try {
    return await call();
  } catch (error) {
    // The limiter's 429 has no `details`; a plan quota's 429 has them.
    if (error instanceof PrismDraftError && error.status === 429 && error.details === undefined) {
      await pause(60_000);
      return await call();
    }
    throw error;
  }
}

async function addCategory(categoryId: string, titleContains: string): Promise<number> {
  const matches: { id: string; categoryIds: string[] }[] = [];
  for (let offset = 0; ;) {
    const page = await pd.articles.list({ workspaceId, q: titleContains, limit: 100, offset });
    for (const article of page.articles) {
      matches.push({
        id: article.id,
        categoryIds: article.categories.map((category) => category.id),
      });
    }
    offset += page.articles.length;
    if (page.articles.length === 0 || offset >= page.total) break;
  }

  let changed = 0;
  for (const match of matches) {
    if (match.categoryIds.includes(categoryId)) continue;
    await pacing(() =>
      pd.categories.setForArticle({
        articleId: match.id,
        body: { categoryIds: [...match.categoryIds, categoryId] },
      }),
    );
    changed += 1;
  }
  return changed;
}

console.log(await addCategory(categoryId, "guide"), "articles updated");
  • q matches the title, case-insensitively, as a substring.
  • Each published article whose categories change sends an article.updated webhook with categories in changed, so a receiver sees a burst. See Webhooks.
  • Writes are never retried by the SDK. Retries and timeouts explains why, and why the rate-limit 429 is safe to send again.