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
APPROVEDto be published, and approval needs a submit first.pd.articles.publishon any other status answers409(CONFLICT). - Images. The API downloads each image the Markdown refers to:
httpandhttpsonly, at most 50 distinct images of 25 MB each, and relative URLs needimageBaseUrl. 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
400withdetails.issueslisting 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
translationGroupIdin the create call. The group must hold an article of the same project and no article in that language yet (409otherwise). - After the publish, the
article.publishedwebhook is sent, and a publishing integration, if the project has one, is handed the article in the background.cmsIntegrationnames it, or isnull.
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'stimezone, andscheduledFor, the same slot as a UTC instant. PasslocalDateback as it is. - Fewer than the slots. The model can bring fewer new ideas than there are slots. The answer is then shorter, and
estimatematches the suggestions it lists. - The first slot must be ahead. Both calls answer
400(INVALID_REQUEST) when the first slot,startsOnatrunTimeintimezone, has passed. - Answers you will meet.
plans.suggestanswers402(INSUFFICIENT_CREDITS) when the balance cannot cover the call, and429(QUOTA_EXCEEDED) when a quota of the workspace is used up; nothing is charged in either case.plans.createanswers409withdetails.reasonset toplan_limitwhen the workspace already has 3 open plans (those of an archived project do not count); cancel one withpd.plans.updateandstatus: "cancelled", then send it again. - Retries. Sending
plans.createagain with the sameidempotencyKeyanswers200with 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 answers409withdetails.reasonset toidempotency_key_reused. - Nothing is written by default.
plan.autopilotisfalse, and the calendar items stayplanned.plan.estimatedCreditsandplan.creditCapare figures for writing the articles later;plan.creditsSpentstays0until something is written. - Autopilot. Pass
autopilot: truetoplans.create, or toplans.updateon an existing plan, and each article is written, approved and published on its day. It needsarticles.generate,articles.approveandarticles.publishon the project and a person's session: an API key answers403(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
statusgoesplanned,writingwhen its article starts,approvedwhen it moves on to publication, andpublishedas soon as the source article is live, which is before its translations finish. An item sent back for a second attempt returns toplanned; one that is parked keeps the status of the last step it reached.runStatestays the precise field for where the run is. Items finished before this change keepplanned. - 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
estimatedCreditsandcreditCapinclude those translations, and an item staystranslatinguntil all of them are live. - Changing the hour.
pd.plans.updatewithrunTime: "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'stimezone. Items already running, finished, parked or skipped, and items already overdue, stay where they are. It answers400(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
slugandlanguage. stripLead: "true"leaves out the first heading and the hero image fromcontentMarkdown, for a site that prints the title and the cover itself. The hero's URL comes fromassets.- Answering
500makes PrismDraft retry the delivery (up to 6 attempts over about 15 minutes). A4xxis 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
syncfor 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");
limitis 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 ingroups) andpd.dashboard.listAudit(items inentries) page the same way, withtotal.pd.blog.listPostshas nototal: stop at a page with fewer items thanlimit. 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");
qmatches the title, case-insensitively, as a substring.- Each published article whose categories change sends an
article.updatedwebhook withcategoriesinchanged, 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.