This article collects ready-to-use Notiondesk API scripts for the tasks teams automate most: publishing Notion edits from a CI pipeline, backing up every article as markdown, sending negative feedback to Slack, reporting the searches your help center cannot answer, finding failed syncs, and applying one brand to several help centers. Copy a recipe, set two environment variables, and run it.
Before you start
- Create an API key with only the scopes the recipe needs. Each recipe names them. See Authenticate with the Notiondesk API using API keys.
- Find your help center's
idwithGET /sites. See Read your help centers with the Notiondesk API.
- Set both as environment variables:
export NOTIONDESK_API_KEY="nd_wsk_..."
export NOTIONDESK_SITE_ID="c74781f6-1aea-4e17-ab23-bbb8b4d174b6"The JavaScript recipes need Node.js 18 or later.
A small shared helper
Save this file as notiondesk.mjs next to your scripts. It sends the headers, waits when the API answers 429 rate_limited, and reads every page of a list.
export const SITE_ID = process.env.NOTIONDESK_SITE_ID;
export async function api(path, options = {}) {
for (let attempt = 0; attempt < 5; attempt++) {
const response = await fetch(`https://api.notiondesk.so${path}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.NOTIONDESK_API_KEY}`,
"nd-version": "2026-10-16",
...options.headers,
},
});
if (response.status === 429) {
const seconds = Number(response.headers.get("Retry-After")) || 5;
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
continue;
}
if (!response.ok) {
throw new Error(`${response.status} ${await response.text()}`);
}
return response.status === 204 ? null : response.json();
}
throw new Error(`Still rate limited on ${path}`);
}
export async function listAll(path) {
const items = [];
let cursor = null;
do {
const url = new URL(`https://api.notiondesk.so${path}`);
url.searchParams.set("page_size", "100");
if (cursor) {
url.searchParams.set("cursor", cursor);
}
const page = await api(`${url.pathname}${url.search}`);
items.push(...page.data);
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
return items;
}Sync your help center from GitHub Actions
Scope: sync
Publish your Notion edits from a GitHub Actions workflow, for example right after a release. Store the key as the repository secret NOTIONDESK_API_KEY and the help center id as the repository variable NOTIONDESK_SITE_ID.
name: Sync help center
on:
workflow_dispatch:
release:
types: [published]
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Trigger a Notiondesk sync
run: |
status=$(curl -s -o response.json -w "%{http_code}" -X POST \
"https://api.notiondesk.so/sites/${{ vars.NOTIONDESK_SITE_ID }}/sync" \
-H "Authorization: Bearer ${{ secrets.NOTIONDESK_API_KEY }}" \
-H "nd-version: 2026-10-16" \
-H "Idempotency-Key: github-${{ github.run_id }}-${{ github.run_attempt }}")
if [ "$status" = "409" ]; then echo "A sync is already running."; exit 0; fi
if [ "$status" != "204" ]; then cat response.json; exit 1; fi
echo "Sync queued."204means the sync is queued. It runs in the background.
409 conflict_errormeans a sync of the help center is already queued or running, so the step succeeds without starting another.
- The
Idempotency-Keymakes a retried request queue nothing twice.
Back up every article as markdown
Scope: read
Save every article, in every language your help center serves, as a markdown file:
import { mkdir, writeFile } from "node:fs/promises";
import { api, listAll, SITE_ID } from "./notiondesk.mjs";
const site = await api(`/sites/${SITE_ID}`);
for (const locale of site.supported_locales) {
await mkdir(`backup/${locale}`, { recursive: true });
const articles = await listAll(`/sites/${SITE_ID}/articles?locale=${locale}`);
for (const article of articles) {
const full = await api(`/sites/${SITE_ID}/articles/${encodeURIComponent(article.id)}?locale=${locale}`);
const frontMatter = `---\ntitle: ${JSON.stringify(full.title ?? "")}\nstatus: ${full.status}\nurl: ${full.url ?? ""}\n---\n\n`;
await writeFile(`backup/${locale}/${encodeURIComponent(article.id)}.md`, frontMatter + full.text);
if (full.truncated) {
console.warn(`${locale}/${article.id} is longer than 200,000 bytes and was truncated`);
}
}
console.log(`${locale}: ${articles.length} articles saved`);
}The backup includes drafts and archived articles. Reads are limited to 120 requests per minute, so a help center with 500 articles takes about five minutes per language; the helper waits automatically when it reaches the limit.
Send negative feedback to Slack every day
Scope: read only
Post the last 24 hours of negative feedback to a Slack channel through an incoming webhook. Set SLACK_WEBHOOK_URL, then run the script once a day.
import { api, SITE_ID } from "./notiondesk.mjs";
const since = Date.now() - 24 * 60 * 60 * 1000;
const escapeSlack = (text) => (text ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
async function feedbackSince(timestamp) {
const entries = [];
let cursor = null;
do {
const query = new URLSearchParams({ page_size: "100" });
if (cursor) {
query.set("cursor", cursor);
}
const page = await api(`/sites/${SITE_ID}/feedback?${query}`);
const recent = page.data.filter((entry) => Date.parse(entry.submitted_at) >= timestamp);
entries.push(...recent);
cursor = page.has_more && recent.length === page.data.length ? page.next_cursor : null;
} while (cursor);
return entries;
}
const negative = (await feedbackSince(since)).filter(
(entry) => entry.reaction_group === "negative" || entry.reaction_group === "not_helpful",
);
if (negative.length > 0) {
const lines = negative.map(
(entry) => `• *${escapeSlack(entry.article_title)}* (${entry.article_locale}): ${escapeSlack(entry.message) || "_no message_"}`,
);
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: `${negative.length} negative feedback in the last 24 hours\n${lines.join("\n")}` }),
});
}The feedback list is newest first, so the script stops paging at the first entry older than 24 hours.
read, escape the text before posting it, and never pass it to an LLM that holds a key able to write. See Export reader feedback with the Notiondesk API.Report searches without results every week
Scope: read
List what readers searched for last week without finding an article. Each query is a candidate for a new article or a better title.
import { api, SITE_ID } from "./notiondesk.mjs";
const end = new Date();
end.setUTCHours(0, 0, 0, 0);
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000);
const toApiDate = (date) => `${date.toISOString().slice(0, 19)}Z`;
const query = new URLSearchParams({
start_date: toApiDate(start),
end_date: toApiDate(end),
date_scale: "day",
});
const report = await api(`/sites/${SITE_ID}/analytics/search?${query}`);
console.log(`Searches: ${report.searches.total} (${report.searches.percentage_difference}% vs previous week)`);
console.log(`Without results: ${report.searches_without_results.total}`);
console.log(`Click-through rate: ${report.click_through_rate.current.toFixed(1)}%`);
for (const { query: text, searches } of report.searches_without_results.queries) {
console.log(`${searches}\t${text}`);
}Each query list holds up to 50 rows. Analytics reports also share a budget of 30 requests per minute per help center. See Get help center analytics with the Notiondesk API.
Find articles whose last sync failed
Scope: read, plus sync to retry
import { api, listAll, SITE_ID } from "./notiondesk.mjs";
const articles = await listAll(`/sites/${SITE_ID}/articles`);
const failed = articles.filter((article) => article.sync_failed || article.sync_timeout);
for (const article of failed) {
const reason = article.sync_timeout ? "timed out" : article.sync_error_code ?? "failed";
console.log(`${article.title}: ${reason} ${article.notion_article_page_url ?? ""}`);
}Fix each article in Notion first, then sync it again with POST /sites/{site_id}/articles/{article_id}/sync.
Apply one brand to every help center
Scope: read and admin
Copy the colors and fonts of one help center to every other help center of your workspace. Set SOURCE_SITE_ID to the help center to copy from.
import { createHash } from "node:crypto";
import { api, listAll } from "./notiondesk.mjs";
const sourceId = process.env.SOURCE_SITE_ID;
const { colors, primary_font, secondary_font } = await api(`/sites/${sourceId}/settings/customize`);
const body = JSON.stringify({ colors, primary_font, secondary_font });
const bodyHash = createHash("sha256").update(body).digest("hex");
const sites = await listAll("/sites");
for (const site of sites.filter((candidate) => candidate.id !== sourceId)) {
await api(`/sites/${site.id}/settings/customize`, {
method: "PATCH",
headers: { "Content-Type": "application/json", "Idempotency-Key": `brand-${site.id}-${bodyHash}` },
body,
});
console.log(`Updated ${site.full_domain}`);
}Each branding update refreshes the whole target help center, so the script sends one request per help center. A color stored in a short form such as #fff is rejected: fix it in the source help center's dashboard first. See Customize help center branding and themes with the Notiondesk API.
Import redirects after a migration
Scope: read and write
Moving from another help center tool? The redirect import script reads a CSV of old paths and creates one redirect per row. See Manage page redirects with the Notiondesk API.
Common questions
Which language can I use with the Notiondesk API?
Any language that can send HTTPS requests. The API is plain REST with JSON, so curl, JavaScript, Python, PHP, Go or Ruby all work.
Where should I run these scripts?
On a server, a scheduled job or a CI pipeline, with the key stored as a secret. Never run them in a browser.
Can I use these scripts with OAuth instead of an API key?
Yes. Replace the API key with an OAuth access token in the Authorization header, and refresh it before it expires. See Connect your app to Notiondesk with OAuth.