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

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 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.