API Reference

Complete reference for the PersonnaPress headless blog delivery API. All endpoints, parameters, error codes, and copy-paste integration examples.

Quickstart

  1. Step 01

    Get your token

    In the app, go to your client, then the Connections tab, then the Delivery API section. Click Create token, give it a name, and copy the ppd_... value. You will only see the full token once.

    Open the app
  2. Step 02

    Call the API

    Send a GET request to https://api.personnapress.com/public/v1/articles with Authorization: Bearer ppd_your_token in the header. The response is a paginated JSON object.

  3. Step 03

    Render the result

    Use article.html for the post body and pass the seo object fields to your page metadata. The seo.json_ld block is ready to embed as-is.

Authentication

All requests require an Authorization header in the format Bearer ppd_<token>. Delivery tokens always start with the prefix ppd_. Do not use session cookies or API keys from the app dashboard.

A missing, revoked, or malformed token returns 401 INVALID_DELIVERY_TOKEN.

Authorization: Bearer ppd_abc123def456ghi789jkl012mno345pqr678stu

The API allows 120 requests per minute per token. Exceeding this returns 429 RATE_LIMIT_EXCEEDED.

List Articles

GET/public/v1/articlesReturns a paginated list of published articles.

Note: the html and seo fields are not included in list responses. Fetch GET /public/v1/articles/{slug} for the full article.

Query Parameters

Query parameters for GET /public/v1/articles
ParameterTypeDefaultDescription
pageoptionalint1Page number for pagination.
page_sizeoptionalint20Items per page. Minimum 1, maximum 50.
tagoptionalstring-Filter articles by tag slug. Returns only articles with this tag.
categoryoptionalstring-Filter articles by category name. Case-insensitive.

Response

{
  "data": [
    {
      "slug": "how-to-price-consulting-services",
      "title": "How to Price Consulting Services Without Guessing",
      "excerpt": "Most consultants underprice because they anchor to hourly rates.",
      "featured_image_url": "https://cdn.personnapress.com/images/how-to-price-consulting.jpg",
      "featured_image_alt": "Chart showing value-based pricing vs hourly rates",
      "author": "Alex Morgan",
      "tags": ["consulting", "pricing"],
      "category": "Business",
      "published_at": "2026-07-14T09:00:00+00:00",
      "updated_at": "2026-07-14T10:23:00+00:00",
      "reading_time_minutes": 7
    }
  ],
  "meta": {
    "page": 1,
    "page_size": 20,
    "total": 42
  }
}

Get Article

GET/public/v1/articles/{slug}Returns a single article with full HTML content and SEO data.

Path parameter: slug (string, required) - the article's URL slug, obtained from the list response.

The meta_description and og fields inside seo are conditional: present only when the article has those fields populated. A 404 is returned for hidden articles, unknown slugs, or articles belonging to another client - all cases are indistinguishable by design.

Response

{
  "slug": "how-to-price-consulting-services",
  "title": "How to Price Consulting Services Without Guessing",
  "excerpt": "Most consultants underprice because they anchor to hourly rates.",
  "featured_image_url": "https://cdn.personnapress.com/images/how-to-price-consulting.jpg",
  "featured_image_alt": "Chart showing value-based pricing vs hourly rates",
  "author": "Alex Morgan",
  "tags": ["consulting", "pricing"],
  "category": "Business",
  "published_at": "2026-07-14T09:00:00+00:00",
  "updated_at": "2026-07-14T10:23:00+00:00",
  "reading_time_minutes": 7,
  "html": "<h2>The problem with hourly pricing</h2><p>When you charge by the hour...</p>",
  "seo": {
    "reading_time_minutes": 7,
    "json_ld": {
      "@context": "https://schema.org",
      "@type": "Article",
      "headline": "How to Price Consulting Services Without Guessing",
      "datePublished": "2026-07-14T09:00:00+00:00",
      "author": { "@type": "Person", "name": "Alex Morgan" }
    },
    "meta_description": "Learn how to price consulting services based on value, not hours.",
    "og": {
      "title": "How to Price Consulting Services Without Guessing",
      "description": "Learn how to price consulting services based on value.",
      "image": "https://cdn.personnapress.com/images/how-to-price-consulting.jpg"
    }
  }
}

List Tags

GET/public/v1/tagsReturns all tags and categories with article counts.

Use this endpoint to build tag clouds, category navigation, or filtered list pages on your site.

Response

{
  "tags": [
    { "name": "consulting", "count": 12 },
    { "name": "pricing", "count": 8 },
    { "name": "freelance", "count": 5 }
  ],
  "categories": [
    { "name": "Business", "count": 18 },
    { "name": "Marketing", "count": 9 }
  ]
}

Create Article

Post a blog article directly to a client with a write-scoped token. The article is stored verbatim after HTML sanitization and lands as hidden in the Article Manager, ready for you to review and publish in the app. No voice, generation, or AI transformation is applied. This endpoint is ideal for a terminal or an AI agent such as Claude.

Authentication uses a Bearer ppw_<token> header. Create a write token in the app under your client's Connections tab and choose the Write scope. A read-only ppd_ token returns 403 WRITE_SCOPE_REQUIRED here. This route is rate limited to 60 requests per minute per token. The request body is capped at 200 KB.

Provide a slug to make the call idempotent: if a hidden article with that slug already exists it is updated and the response is 200 with updated: true. If the slug belongs to a published article the call returns 409 SLUG_CONFLICT_PUBLISHED and never overwrites a live post. Omit the slug and a unique one is derived from the title, always creating a new article ( 201).

POST/public/v1/articlesCreate or upsert a hidden article verbatim.

Request Fields

Fields accepted by POST /public/v1/articles
ParameterTypeDefaultDescription
titlerequiredstring-Article title. Trimmed, 1 to 300 characters.
contentrequiredstring-Article body in the given format. Non-empty.
formatrequired"markdown" | "html"-How to interpret content. Markdown is rendered to HTML; HTML is sanitized directly.
slugoptionalstringautoURL slug (max 200). Omit to derive a unique slug from the title.
excerptoptionalstring-Short summary, max 500 characters.
meta_descriptionoptionalstring-SEO meta description, max 320 characters.
authoroptionalstring-Author name, max 200 characters.
categoryoptionalstring-Category name, max 100 characters.
tagsoptionalstring[]-Up to 20 tags, each max 50 characters.
featured_image_altoptionalstring-Alt text for the featured image, max 300 characters.
featured_image_urloptionalstring-Featured image URL. Must be a valid http(s) URL.

Request Body

{
  "title": "How We Cut Onboarding Time in Half",
  "format": "markdown",
  "content": "## The problem\n\nNew accounts took **three days** to activate.\n\n- Manual review\n- Slow email loops\n",
  "slug": "cut-onboarding-time-in-half",
  "excerpt": "A short summary shown in list views.",
  "meta_description": "How we cut onboarding time in half with two workflow changes.",
  "author": "Alex Morgan",
  "category": "Operations",
  "tags": ["onboarding", "ops"],
  "featured_image_alt": "Before and after onboarding timeline"
}

Response

{
  "id": "0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
  "slug": "cut-onboarding-time-in-half",
  "status": "hidden",
  "edit_url": "https://app.personnapress.com/articles/0f9c1e7a-4b2d-4a11-9c3e-2a7f8b6d5c40",
  "created_at": "2026-09-20T14:02:11+00:00",
  "updated_at": "2026-09-20T14:02:11+00:00",
  "updated": false
}

cURL example

# Create a hidden article from Markdown (Claude / terminal)
curl --silent --fail-with-body \
  -X POST "https://api.personnapress.com/public/v1/articles" \
  -H "Authorization: Bearer ppw_your_write_token_here" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "How We Cut Onboarding Time in Half",
    "format": "markdown",
    "content": "## The problem\n\nNew accounts took three days to activate.\n"
  }'

# The article lands as "hidden" in your Article Manager.
# Open edit_url from the response to review and publish it.

Known limits in v1

  • Inline images whose src is not a PersonnaPress-hosted URL are stripped by the sanitizer. Upload images in the app and reference the hosted URL, or set featured_image_url.
  • Markdown tables, h5 and h6 headings, and horizontal rules are flattened by the HTML allowlist. Use h2 to h4 for structure.

Error Reference

API error codes, HTTP status codes, and conditions that trigger them
Error CodeHTTP StatusWhen it fires
INVALID_DELIVERY_TOKEN401Token missing, malformed, revoked, or does not match any active token.
WRITE_SCOPE_REQUIRED403A valid read-only (ppd_) token was used on the create endpoint. Use a write (ppw_) token.
ARTICLE_NOT_FOUND404Slug does not exist, article is hidden, or belongs to another client.
SLUG_CONFLICT_PUBLISHED409Create request used a slug that already belongs to a published article. Published posts are never overwritten via the API.
CONTENT_TOO_LARGE413Request body exceeds the 200 KB limit.
VALIDATION_ERROR422Create request body failed validation (missing/oversized field, bad format, or an unknown field).
RATE_LIMIT_EXCEEDED429More than 120 requests per minute (reads) or 60 per minute (writes) for this token.
INTERNAL_ERROR500Unexpected server error. Retry with exponential backoff.

Error response shape

{
  "detail": {
    "error": {
      "code": "INVALID_DELIVERY_TOKEN",
      "message": "Missing or invalid delivery token."
    }
  }
}

Caching

All successful responses include Cache-Control: public, max-age=60, stale-while-revalidate=300. CDNs and browsers may cache the response for 60 seconds and continue serving it stale for up to 300 seconds while revalidating in the background.

All responses also include an ETag header. Send it back as If-None-Match on subsequent requests to receive 304 Not Modified and save bandwidth.

Response headers

Cache-Control: public, max-age=60, stale-while-revalidate=300
ETag: W/"a3f9bc12de56f789"

Next.js revalidation pattern

const res = await fetch(`${API}/articles/${slug}`, {
  headers: { Authorization: `Bearer ${TOKEN}` },
  next: { revalidate: 60 },  // ISR: re-fetch at most every 60 s
});

Note: 401, 404, and 429 responses are sent with Cache-Control: no-store and must not be cached.

Code Examples

Copy-paste samples for common environments. The endpoint URL and header format are the same in every language.

cURL

# List articles
curl --silent --fail-with-body \
  "https://api.personnapress.com/public/v1/articles?page_size=20" \
  -H "Authorization: Bearer ppd_your_token_here"

# Get a single article
curl --silent --fail-with-body \
  "https://api.personnapress.com/public/v1/articles/how-to-price-consulting-services" \
  -H "Authorization: Bearer ppd_your_token_here"

Plain fetch

const TOKEN = "ppd_your_token_here";
const API = "https://api.personnapress.com/public/v1";

// List articles (index page)
const listRes = await fetch(`${API}/articles?page_size=20`, {
  headers: { Authorization: `Bearer ${TOKEN}` },
});
if (!listRes.ok) throw new Error(`HTTP ${listRes.status}`);
const { data, meta } = await listRes.json();

// Get a single article (detail page)
const articleRes = await fetch(
  `${API}/articles/how-to-price-consulting-services`,
  { headers: { Authorization: `Bearer ${TOKEN}` } }
);
if (!articleRes.ok) throw new Error(`HTTP ${articleRes.status}`);
const article = await articleRes.json();

document.querySelector("h1").textContent = article.title;
document.querySelector("article").innerHTML = article.html;

Next.js App Router

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";

const API = "https://api.personnapress.com/public/v1";
const TOKEN = process.env.PERSONNAPRESS_DELIVERY_TOKEN!;

async function getArticle(slug: string) {
  const res = await fetch(`${API}/articles/${slug}`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
    next: { revalidate: 60 },
  });
  if (!res.ok) return null;
  return res.json();
}

export async function generateStaticParams() {
  const res = await fetch(`${API}/articles?page_size=50`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
    next: { revalidate: 3600 },
  });
  if (!res.ok) return [];
  const { data } = await res.json();
  return data.map(({ slug }: { slug: string }) => ({ slug }));
}

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params;
  const article = await getArticle(slug);
  if (!article) return {};
  return {
    title: article.title,
    ...(article.seo.meta_description && {
      description: article.seo.meta_description,
    }),
    ...(article.seo.og && { openGraph: article.seo.og }),
  };
}

export default async function BlogPostPage(
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
  const article = await getArticle(slug);
  if (!article) return <p>Article not found.</p>;
  return (
    <main>
      <h1>{article.title}</h1>
      <article dangerouslySetInnerHTML={{ __html: article.html }} />
    </main>
  );
}

Astro

---
// src/pages/blog/[slug].astro
const { slug } = Astro.params;
const res = await fetch(
  `https://api.personnapress.com/public/v1/articles/${slug}`,
  {
    headers: {
      Authorization: `Bearer ${import.meta.env.PERSONNAPRESS_TOKEN}`,
    },
  }
);
if (!res.ok) return Astro.redirect("/404");
const article = await res.json();
---

<html lang="en">
  <head>
    <title>{article.title}</title>
    {article.seo.meta_description && (
      <meta name="description" content={article.seo.meta_description} />
    )}
    {article.seo.og?.title && (
      <meta property="og:title" content={article.seo.og.title} />
    )}
    {article.seo.og?.image && (
      <meta property="og:image" content={article.seo.og.image} />
    )}
    <script
      type="application/ld+json"
      set:html={JSON.stringify(article.seo.json_ld)}
    />
  </head>
  <body>
    <img
      src={article.featured_image_url}
      alt={article.featured_image_alt ?? article.title}
    />
    <h1>{article.title}</h1>
    <article set:html={article.html} />
  </body>
</html>

SvelteKit

// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";

export const load: PageServerLoad = async ({ params, fetch }) => {
  const res = await fetch(
    `https://api.personnapress.com/public/v1/articles/${params.slug}`,
    {
      headers: {
        Authorization: `Bearer ${import.meta.env.PERSONNAPRESS_TOKEN}`,
      },
    }
  );
  if (!res.ok) {
    throw error(404, "Article not found");
  }
  const article = await res.json();
  return { article };
};