blogr.ai

Docs

REST API

Pull your generated articles and the inner pages from your topical map into any stack: a read-only JSON API, authenticated with your integration's access token.

How it works

The REST API is a pull-based integration: your site fetches articles from blogr.ai whenever it builds or renders — a natural fit for static site generators and custom stacks. With it connected, clicking Publish marks the article published and makes it available to fetch; nothing is pushed to your servers.

The API is read-only. There are no endpoints that create, change, or delete anything.

Let your AI write the sync

You don't have to write the puller yourself. Copy this prompt into your AI coding assistant (Cursor, Claude Code, Copilot, ChatGPT) and it writes the code that pulls your articles into your project, in your project's own language and style. The full API contract below is baked in.

I use blogr.ai to write and publish articles for my website. blogr.ai has a read-only REST API that returns my articles as JSON: blog posts, and the site pages it designs from its topical map (pricing, comparisons, services, locations). Build the code that pulls them into this project. Match the language, framework, and conventions this project already uses.

The API:
- GET https://blogr.ai/api/v1/articles
- Every request needs my access token in an "Authorization: Bearer" header. Read it from an environment variable or secret store, never hardcode it. I will copy the token from the REST API card in blogr.ai.
- Query parameters: "status" ("published" or "draft"), "type" ("post" by default, "page" for site pages, "all" for both), "per_page" (1 to 100, default 25), and "page".
- The response is standard Laravel pagination JSON: the articles sit under "data", with paging info under "links" and "meta". Follow "links.next" until it is null to read every page.
- The limit is 60 requests per minute; a 429 response means slow down and retry later.
- A 401 response means the token is missing or wrong.

Each article under "data":

{
  "id": 123,
  "title": "Article title",
  "type": "post or page",
  "slug": "article-title",
  "path": "null for a post; the site path for a page, e.g. /compare/acme-alternatives/",
  "status": "published",
  "published": true,
  "published_at": "2026-01-01T00:00:00+00:00",
  "published_url": "https://my-site.com/blog/article-title, or null",
  "content_markdown": "Markdown body, no H1...",
  "content_html": "<p>HTML body, no H1...</p>",
  "layout": "article, or sections for a page built as a designed layout",
  "blocks": null,
  "page_html": null,
  "page_css": null,
  "seo": {
    "title": "SEO title, or null",
    "meta_description": "Meta description, or null"
  },
  "og_image_url": "https://... social image, or null",
  "og_image_alt": "Image alt text, or null",
  "target_keyword": "keyword, or null",
  "categories": ["Category"],
  "tags": ["tag"],
  "created_at": "2026-01-01T00:00:00+00:00",
  "updated_at": "2026-01-01T00:00:00+00:00"
}

What the code should do:
1. Fetch my published content ("status=published&type=all") page by page.
2. Create or update each item on my site. Match on "id" so a re-run updates existing items instead of duplicating them. When nothing is stored under that id yet, match a post on "slug" and a page on "path", never a page on its slug: a page's slug is only the last segment of its path, so /services/seo/ and /industries/seo/ share it.
3. A "type" of "post" is a blog post: publish it in the blog as the site already does. A "type" of "page" is a site page: serve it at "path" exactly, outside the blog ("/pricing/" at /pricing, "/compare/acme-alternatives/" at /compare/acme-alternatives), never list it among blog posts, and never overwrite a hand-built route that already exists at that path.
4. Render "title" as the page's <h1>, then "content_html" below it as-is (or run "content_markdown" through this project's Markdown pipeline). Neither body field contains an H1 (it starts with the introduction and uses H2s for sections), so the page ends up with exactly one H1. Use the "seo" fields for the page title and meta description tags, "og_image_url" with "og_image_alt" for og:image, and add JSON-LD structured data: Article for a post, WebPage for a page.
5. Make it runnable on demand, and on a schedule if this project has one. On every run, update items whose "updated_at" changed: blogr.ai adds links to older articles as new pages go live, so a stored body goes stale.

Section layouts:
- "layout" is "article" for every post and for pages written as one body: render "content_html" as described. It is "sections" for a page built as a designed landing page (pricing, comparison, service pages). Then "blocks" holds its sections, "page_html" the same sections rendered to HTML, and "page_css" the stylesheet for that HTML; all three are null otherwise. Older deliveries may not carry these four fields at all: treat a missing "layout" as "article" and missing "blocks", "page_html", and "page_css" as null, so the same code handles both.
- Render a "sections" page from "blocks.sections", in order, with this project's own templates or components, under the title as the page's only <h1>. Section headings are <h2>, item titles <h3>. Keep "content_html" only as the fallback for search and feeds.
- "blocks" looks like {"version": 1, "sections": [{"type": "hero", "heading": "Pricing", "body": "Plans for every team size.", "items": [], "table": {"columns": [], "rows": []}, "cta": {"label": "Start now", "href": "https://example.com/signup/"}, "secondary_cta": null, "image": null}]}. Every section has those same fields. "body" fields (on the section and on its items) are Markdown; every other field is plain text. "items" entries have title, body, value, and source_url. "cta" and "secondary_cta" are {label, href} or null; "image" is {url, alt} or null.
- By type: hero is the page lead (heading is a short kicker above the lead, never a second <h1>; body is the lead paragraph; cta and secondary_cta are buttons). features is a card grid of items. steps is a numbered sequence of items. stats shows each item's value as a big number, title as its label, body as a note, and links source_url. comparison is a table whose first column holds the criteria and whose second column is this business. pricing is plan cards (title the plan, value the price, body what it includes) with the section's cta under them. proof is quotes (body the quote, title the person, value their role). faq is an accordion (title the question, body the answer). text is a heading with Markdown prose. cta is a closing band with heading, body, and the cta button. Any section may carry an image: show it below the section's content. Skip a section type you do not recognize.
- If building components for this is not practical, output "page_html" as-is below the <h1> and add "page_css" to that page: every rule in it is scoped under .blogr-page, so it never affects the rest of the site.

Make every page reachable. A page nothing links to is one visitors never find and search engines rank poorly, so no page may be reachable only by typing its URL:
1. Directory pages: the parent path of every page must answer (/compare/ for /compare/acme-alternatives/). When the API returned a page at that path, it is the hub: render it, then list the pages under it below its body, each as its title linking to its path. When no page sits there yet, serve a generated index at that path listing the pages under it, titled from the path segment ("Compare"). A hub that arrives later takes over the generated index. Leave a hand-built route at a directory path alone.
2. Navigation: link each top-level directory once from the footer or main navigation ("Compare" to /compare/, "Services" to /services/), and single pages such as /pricing/ directly. Build it from the synced content, so a new page shows up without a code change.
3. Breadcrumbs: show them on every page, built from its path (Home / Compare / Acme alternatives), each crumb linking to the directory page above, with BreadcrumbList JSON-LD.
4. Sitemap: add every post, page, and directory page to the site's sitemap.xml with "updated_at" as the last-modified date. If the site has no sitemap, create one at /sitemap.xml and reference it from robots.txt.
5. Links inside the body point at other posts and pages on this site: keep them as they are.

When you are done, tell me where I paste my access token, how I trigger the first sync, where pages appear in the navigation, and the URL of the sitemap.

Connect the API

  1. In your dashboard, open Setup → Publishing and choose REST API.
  2. Copy the generated access token — it authenticates every request as a bearer token.
  3. Save. A website publishes to one destination, so this replaces any other integration on the website.

Authentication

Send the access token with every request. Each website has its own token, and the token identifies the website — there are no ids to pass:

curl https://blogr.ai/api/v1/articles \
  -H "Authorization: Bearer <your website's access token>" \
  -H "Accept: application/json"

A missing or wrong token gets a 401. Running several websites? Connect the REST API on each one and give every site's consumer its own token.

List your articles

GET /api/v1/articles

Returns the token's website's generated articles — published pieces and waiting drafts — newest first, paginated. Titles still in planning or mid-generation never appear.

Query parameter Meaning
statuspublished — only live articles; draft — only articles not (yet) published. Omit for both.
typepost — blog posts (the default); page — the inner pages your topical map designs; all — both. See Blog posts and inner pages below.
per_pageArticles per page, 1–100. Defaults to 25.
pageThe page number; pagination links also appear in the response meta.

Each article looks like this:

{
  "id": 812,
  "title": "How to Choose a Laravel Hosting Provider",
  "type": "post",
  "slug": "how-to-choose-a-laravel-hosting-provider",
  "path": null,
  "status": "published",
  "published": true,
  "published_at": "2026-07-08T09:30:00+00:00",
  "published_url": "https://example.com/blog/how-to-choose-a-laravel-hosting-provider",
  "content_markdown": "Picking a host…\n\n## What to compare first…",
  "content_html": "<p>Picking a host…</p>\n<h2>What to compare first</h2>…",
  "seo": {
    "title": "How to Choose a Laravel Hosting Provider (2026 Guide)",
    "meta_description": "Compare Laravel hosting providers on speed, pricing, and deploys."
  },
  "og_image_url": "https://blogr.ai/storage/articles/laravel-hosting-9f2c41ab.png",
  "og_image_alt": "How to Choose a Laravel Hosting Provider (2026 Guide) — laravel hosting",
  "target_keyword": "laravel hosting",
  "categories": ["Hosting", "Laravel"],
  "tags": ["laravel hosting"],
  "created_at": "2026-07-06T14:02:11+00:00",
  "updated_at": "2026-07-08T09:30:00+00:00"
}

status is always published or draft: an article is either live or it isn't. published_at and published_url are null on drafts.

content_markdown and content_html are the article body only: they start with the introduction and use H2s for sections, and never contain an H1. Render title as the page's <h1> yourself, so the live page has exactly one H1 and the title is never printed twice.

Blog posts and inner pages

The API returns two kinds of content. Check type on every item before you place it.

Blog posts (type "post") belong in your blog. Inner pages (type "page") are the site-level pages your topical map designs — pricing, comparisons, services, locations — and belong on the site itself, at the path in the path field, outside the blog.

By default the API lists posts only, so a consumer built before pages existed keeps working unchanged. Add type=page to list pages, or type=all to sync both in one pass:

GET /api/v1/articles?status=published&type=all

A page looks like a post, with type and path set:

{
  "id": 913,
  "title": "Acme Alternatives: 7 CRMs Compared",
  "type": "page",
  "slug": "acme-alternatives",
  "path": "/compare/acme-alternatives/",
  "status": "published",
  "published": true,
  "content_html": "<p>Seven CRMs compared…</p><h2>…",
  "seo": { "title": "…", "meta_description": "…" },
  "categories": [],
  "tags": []
}
Blog post Inner page
typepostpage
pathnullThe site path, slashes on both ends — /pricing/, /compare/acme-alternatives/
slugFrom the SEO settings, or derived from the titleThe last segment of path
Where it goesYour blog, e.g. /blog/{slug}Exactly at path, never listed among posts. Leave a hand-built route at that path alone.
Listed byThe default, or type=posttype=page, or type=all for both

Page layouts

Some inner pages are built as designed landing pages rather than one body of text: a hero with buttons, feature cards, numbered steps, a comparison table, an FAQ, and a closing call to action. Those arrive with layout set to sections. Every post, and every page written as one body, says article, and the three fields below are null. Content sent before layouts existed carries none of these fields: treat a missing layout as article.

Field Meaning
layoutarticle or sections.
blocksThe sections as data, in page order: {"version": 1, "sections": [...]}. Build them with your own components for a page that matches your design.
page_htmlThe same sections rendered to HTML inside one .blogr-page element, with no H1. Output it under your own <h1> title.
page_cssThe stylesheet that designs page_html. Every rule is scoped under .blogr-page, so adding it never restyles the rest of your site. Fonts and text color come from your site.

Every section has the same fields: type, heading, body, items (each with title, body, value, and source_url), table (columns and rows), cta and secondary_cta (a label and href, or null), and image (a url and alt, or null). The body fields are Markdown; the rest are plain text. The types are hero, features, steps, stats, comparison, pricing, proof, faq, text, and cta. Skip a type you do not recognize, since more may be added.

content_html stays filled in for sections pages too: it is the same page as plain HTML, for search, feeds, or a receiver that ignores layouts.

Rate limits

Requests are limited to 60 per minute per account, across all your websites and tokens. Exceeding the limit returns a 429 with a Retry-After header; the X-RateLimit-Remaining header on every response shows what's left in the current window.

Errors

Status Meaning
401Missing or invalid access token.
422An invalid query parameter (e.g. an unknown status value).
429Rate limit exceeded — wait for the Retry-After seconds and try again.

Good to know

  • Your token is stored encrypted and never shown again after saving — save a new value anytime to rotate it, and update your consumer.
  • Prefer a push instead? The Webhooks integration delivers the same article data to your endpoint the moment it publishes.