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
Sites created on the managed plan are on it from the start. Any other site
switches with Settings → Content API → Switch this site to the Content
API. Either way that mints your delivery key (pcak_...), shown on the
same card with the fetch snippet - set the key as a PULSE_CONTENT_KEY
environment variable on your hosting platform (or in your CI's secrets, if
that's where your site is built). Your agent can also run the
set_content_api MCP tool, but the key itself only ever appears on the
dashboard.
Your hosted blog
Every Content API site has a hosted blog on Pulse at
https://pulseseoapp.com/s/<your-slug>/blog - linked from Settings →
Content API and from the setup checklist on Home. Each article is live
there the moment it publishes: the same HTML, FAQ and structured data the
API serves, rendered as a plain reader's page. So the first article is
visibly live before your own site has fetched anything, and wiring your
site is a step you take when you're ready rather than a step between
signing up and seeing the product work.
It is deliberately not your search surface. Every hosted page carries
noindex and a canonical URL pointing at your own domain
(https://your-domain{blog path}/{slug}), so nothing on Pulse competes
with your site, and the articles earn their traffic once your site serves
them.
Testing the connection
Settings → Content API → Test the connection makes one real request
from Pulse's server to GET /api/content/v1/posts with your key - the
exact call your site has to make - and reports the status, the latency and
how many live articles came back. Use it before wiring your site (the key
and the endpoint work) and after (this is what your site receives). It
does not count as your site's first fetch: the checklist's delivery step
ticks only when your own site fetches. The check_content_api MCP tool is
the same test for your agent.
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.