Automate common help center tasks with the Notiondesk API

Notiondesk API examples: sync from GitHub Actions, back up articles as markdown, post feedback to Slack, and report searches with no results.

7 min read

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

  • 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."
  • 204 means the sync is queued. It runs in the background.
  • 409 conflict_error means a sync of the help center is already queued or running, so the step succeeds without starting another.
  • The Idempotency-Key makes a retried request queue nothing twice.
While automatic translation is on, every sync translates the content it changed and bills it. Trigger the workflow after real edits, not on a short schedule. See Trigger Notion syncs with the Notiondesk API.

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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

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.

Feedback is written by your readers. Run this script with a key that holds only 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.

What's next

Related articles

Was this page helpful?

Still need help? Ask us