API Reference
Complete reference for the PersonnaPress headless blog delivery API. All endpoints, parameters, error codes, and copy-paste integration examples.
Quickstart
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
Open the appppd_...value. You will only see the full token once.Step 02
Call the API
Send a GET request to
https://api.personnapress.com/public/v1/articleswithAuthorization: Bearer ppd_your_tokenin the header. The response is a paginated JSON object.Step 03
Render the result
Use
article.htmlfor the post body and pass theseoobject fields to your page metadata. Theseo.json_ldblock 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_abc123def456ghi789jkl012mno345pqr678stuThe API allows 120 requests per minute per token. Exceeding this returns 429 RATE_LIMIT_EXCEEDED.
List Articles
/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
| Parameter | Type | Default | Description |
|---|---|---|---|
| pageoptional | int | 1 | Page number for pagination. |
| page_sizeoptional | int | 20 | Items per page. Minimum 1, maximum 50. |
| tagoptional | string | - | Filter articles by tag slug. Returns only articles with this tag. |
| categoryoptional | string | - | 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
/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"
}
}
}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).
/public/v1/articlesCreate or upsert a hidden article verbatim.Request Fields
| Parameter | Type | Default | Description |
|---|---|---|---|
| titlerequired | string | - | Article title. Trimmed, 1 to 300 characters. |
| contentrequired | string | - | Article body in the given format. Non-empty. |
| formatrequired | "markdown" | "html" | - | How to interpret content. Markdown is rendered to HTML; HTML is sanitized directly. |
| slugoptional | string | auto | URL slug (max 200). Omit to derive a unique slug from the title. |
| excerptoptional | string | - | Short summary, max 500 characters. |
| meta_descriptionoptional | string | - | SEO meta description, max 320 characters. |
| authoroptional | string | - | Author name, max 200 characters. |
| categoryoptional | string | - | Category name, max 100 characters. |
| tagsoptional | string[] | - | Up to 20 tags, each max 50 characters. |
| featured_image_altoptional | string | - | Alt text for the featured image, max 300 characters. |
| featured_image_urloptional | string | - | 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
srcis not a PersonnaPress-hosted URL are stripped by the sanitizer. Upload images in the app and reference the hosted URL, or setfeatured_image_url. - Markdown tables,
h5andh6headings, and horizontal rules are flattened by the HTML allowlist. Useh2toh4for structure.
Error Reference
| Error Code | HTTP Status | When it fires |
|---|---|---|
| INVALID_DELIVERY_TOKEN | 401 | Token missing, malformed, revoked, or does not match any active token. |
| WRITE_SCOPE_REQUIRED | 403 | A valid read-only (ppd_) token was used on the create endpoint. Use a write (ppw_) token. |
| ARTICLE_NOT_FOUND | 404 | Slug does not exist, article is hidden, or belongs to another client. |
| SLUG_CONFLICT_PUBLISHED | 409 | Create request used a slug that already belongs to a published article. Published posts are never overwritten via the API. |
| CONTENT_TOO_LARGE | 413 | Request body exceeds the 200 KB limit. |
| VALIDATION_ERROR | 422 | Create request body failed validation (missing/oversized field, bad format, or an unknown field). |
| RATE_LIMIT_EXCEEDED | 429 | More than 120 requests per minute (reads) or 60 per minute (writes) for this token. |
| INTERNAL_ERROR | 500 | Unexpected 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 };
};