# The Content API

> Publish without giving Pulse any access to your site - articles land in your Content API and your site pulls them at build time.

Source: https://pulseseoapp.com/docs/content-api
All docs in one file: https://pulseseoapp.com/llms-full.txt

The third publishing route. On GitHub, Pulse commits into your repo; on
WordPress, it posts with a stored credential. On the **Content API** it
touches nothing you own: finished articles land in your own read-only
API on Pulse, and your site fetches them - at build time, on a schedule,
or per request. Pulse never holds write access to your repo, your CMS,
or your hosting.

## Turning it on

Settings → **Content API** → *Switch this site to the Content API*. That
mints your **delivery key** (`pcak_...`), shown on the same card - copy it
into your site's build environment (for example a `PULSE_CONTENT_KEY`
env var). Your agent can also run the `set_content_api` MCP tool, but the
key itself only ever appears on the dashboard.

Two optional settings on the card:

- **Blog path** - where your site serves the articles (default `/blog`).
  Canonical URLs in each article's structured data use it, and after each
  publish we check `https://your-domain{path}/{slug}` until the article
  appears there.
- **Rebuild hook** - a Vercel or Netlify deploy hook we `POST` after each
  publish, so a fully static site rebuilds without polling.

Switching is not a one-way door: the same card switches you back to pull
requests or WordPress at any time.

## Authentication

Every endpoint takes the delivery key as a bearer header:

```bash
curl -H "Authorization: Bearer pcak_..." \
  https://pulseseoapp.com/api/content/v1/posts
```

The key is read-only and scoped to one project - it can list and read
that site's published articles and nothing else. Keep it server-side:
in a build step or a server route, never in client-side JavaScript
(a browser bundle is public, and the header would ship with it). Rotate
it from the Settings card; the old key stops working immediately.

## Endpoints

All four live under `/api/content/v1`. Responses are CDN-cached for five
minutes, so a publish reaches your site within minutes and build-time
fetch bursts are cheap.

### `GET /posts`

The article index, newest first - summaries only, no bodies.

| Query param | Meaning |
| --- | --- |
| `limit` | 1-100, default 50 |
| `before` | pagination cursor: the `next_before` from the previous page |

```json
{
  "posts": [
    {
      "slug": "how-to-warm-up-a-cold-domain",
      "title": "How to warm up a cold email domain",
      "description": "A 155-character meta description.",
      "cover_url": "https://pulseseoapp.com/api/cover/....",
      "reading_minutes": 7,
      "published_at": "2026-09-21T09:00:00.000Z",
      "updated_at": "2026-09-21T09:00:00.000Z"
    }
  ],
  "next_before": null
}
```

`next_before: null` means that was the last page.

### `GET /posts/{slug}`

One article, everything a page needs:

| Field | What it is |
| --- | --- |
| `html` | The sanitized, internally-linked article body. Render it as-is. |
| `markdown` | The same article as markdown, if you run your own renderer. |
| `jsonld` | Structured data (Article + FAQ schema) as an object - emit it in a `<script type="application/ld+json">` tag. |
| `faq` | The FAQ pairs on their own, if your design renders them separately. |
| `title`, `description` | Page title and meta description. |
| `cover_url` | Absolute URL of the cover image (served by Pulse; hotlink it or copy it into your own assets at build time). |
| `reading_minutes`, `published_at`, `updated_at` | What they say. |

Returns `404` for a slug that does not exist or was retracted.

### `GET /sitemap`

Every live article as `{ slug, url, lastmod }`, for merging into your
site's own sitemap. `url` is the canonical address on **your** domain,
built from your blog path.

### `GET /feed`

The 50 newest articles as RSS 2.0, links pointing at your domain. Made
to be proxied: expose `/blog/rss.xml` on your site, fetch this endpoint
server-side, return the body verbatim.

## Let your coding agent do it

If you use Claude Code (or any agent that reads project skills), you don't
have to wire anything by hand — install the adapter skill and ask:

```
/plugin marketplace add pulseseo/pulse-skills
/plugin install pulse-seo@pulse-skills
```

Or drop the skill straight into your project:

```bash
mkdir -p .claude/skills/pulse-seo-adapter
curl -fsSL https://raw.githubusercontent.com/pulseseo/pulse-skills/main/plugins/pulse-seo/skills/pulse-seo-adapter/SKILL.md \
  -o .claude/skills/pulse-seo-adapter/SKILL.md
```

Then say **"Install the Pulse blog adapter"**. Your agent detects your
framework, builds the pages below in your codebase's own style, merges the
sitemap, and sets up the env var — it never commits the key. Free, open, and
it's your agent touching your repo, never ours.

## Wiring up a Next.js site

One lib file and two pages. Set `PULSE_CONTENT_KEY` in your environment
first.

```ts
// lib/pulse.ts
const BASE = "https://pulseseoapp.com/api/content/v1";
const HEADERS = { Authorization: `Bearer ${process.env.PULSE_CONTENT_KEY}` };

export async function pulsePosts() {
  const res = await fetch(`${BASE}/posts`, {
    headers: HEADERS,
    next: { revalidate: 300 },
  });
  if (!res.ok) return [];
  return (await res.json()).posts;
}

export async function pulsePost(slug: string) {
  const res = await fetch(`${BASE}/posts/${slug}`, {
    headers: HEADERS,
    next: { revalidate: 300 },
  });
  if (!res.ok) return null;
  return (await res.json()).post;
}
```

```tsx
// app/blog/page.tsx - the index
import { pulsePosts } from "@/lib/pulse";

export default async function BlogIndex() {
  const posts = await pulsePosts();
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.slug}>
          <a href={`/blog/${p.slug}`}>{p.title}</a>
        </li>
      ))}
    </ul>
  );
}
```

```tsx
// app/blog/[slug]/page.tsx - the article
import { notFound } from "next/navigation";
import { pulsePost, pulsePosts } from "@/lib/pulse";

export async function generateStaticParams() {
  return (await pulsePosts()).map((p: { slug: string }) => ({ slug: p.slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const post = await pulsePost((await params).slug);
  return post ? { title: post.title, description: post.description } : {};
}

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const post = await pulsePost((await params).slug);
  if (!post) notFound();
  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(post.jsonld) }}
      />
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  );
}
```

The `html` is sanitized server-side by Pulse before it is stored, which
is what makes `dangerouslySetInnerHTML` acceptable here. With
`revalidate: 300` a new article appears within five minutes of
publishing, with no rebuild needed; add the deploy hook if your blog
pages are fully static instead.

Finally, merge the sitemap entries into `app/sitemap.ts`:

```ts
import { pulsePosts } from "@/lib/pulse";

export default async function sitemap() {
  const posts = await pulsePosts();
  return [
    { url: "https://your-domain.com" },
    ...posts.map((p) => ({
      url: `https://your-domain.com/blog/${p.slug}`,
      lastModified: p.updated_at,
    })),
  ];
}
```

Any other stack works the same way - two GET requests with a header. If
your framework can fetch JSON at build time, it can serve a Pulse blog.

## How publishing behaves on this route

The pipeline is unchanged up to the last step: articles are written,
gated by the same 24 checks, rendered and scheduled exactly as on the
other routes. At the publish hour the article lands in your Content API,
your rebuild hook is pinged, and the verify job starts checking
`https://your-domain{blog path}/{slug}` - the draft earns **Published**
only when the page actually answers on your site, same as every other
route. If your site has not wired the adapter yet, drafts sit at
"On the way" while the API serves them - honest, and fixed by shipping
the adapter.
