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"
}
}
}Error Reference
| Error Code | HTTP Status | When it fires |
|---|---|---|
| INVALID_DELIVERY_TOKEN | 401 | Token missing, malformed, revoked, or does not match any active token. |
| ARTICLE_NOT_FOUND | 404 | Slug does not exist, article is hidden, or belongs to another client. |
| RATE_LIMIT_EXCEEDED | 429 | More than 120 requests per minute 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 };
};