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 }
  ]
}

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.
ARTICLE_NOT_FOUND404Slug does not exist, article is hidden, or belongs to another client.
RATE_LIMIT_EXCEEDED429More than 120 requests per minute 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 };
};