DocsThe Content API

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 APISwitch 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:

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 paramMeaning
limit1-100, default 50
beforepagination 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:

FieldWhat it is
htmlThe sanitized, internally-linked article body. Render it as-is.
markdownThe same article as markdown, if you run your own renderer.
jsonldStructured data (Article + FAQ schema) as an object - emit it in a <script type="application/ld+json"> tag.
faqThe FAQ pairs on their own, if your design renders them separately.
title, descriptionPage title and meta description.
cover_urlAbsolute 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_atWhat 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.