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.
On this page
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 checkhttps://your-domain{path}/{slug}until the article appears there. - Rebuild hook - a Vercel or Netlify deploy hook we
POSTafter 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:
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 |
{
"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:
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.
// 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;
}
// 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>
);
}
// 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:
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.