blogr.ai

Docs

Webhooks

Receive every published article and inner page as a JSON POST to your own endpoint: the fastest way to connect blogr.ai to any CMS or custom stack.

How it works

When you click Publish on an article, blogr.ai sends it to your endpoint as a single JSON POST request. Your endpoint stores or publishes the article on your side and responds with a 2xx status code. That's the whole contract — no SDK, no polling.

Let your AI write the endpoint

You don't have to write the receiver yourself. Copy this prompt into your AI coding assistant (Cursor, Claude Code, Copilot, ChatGPT) and it builds the endpoint in your project's own language and style. The full payload contract below is baked in.

I use blogr.ai to write and publish articles for my website. blogr.ai can deliver every published article to a webhook on my site: blog posts, and the site pages it designs from its topical map (pricing, comparisons, services, locations). Build that webhook receiver in this project. Match the language, framework, and conventions this project already uses.

How blogr.ai delivers:
- An HTTP POST with a JSON body, sent to a public https URL that I will configure in blogr.ai.
- Every request carries my access token in an "Authorization: Bearer" header. Read the expected token from an environment variable or secret store, never hardcode it. I will copy the token from the Webhooks card in blogr.ai.
- Test deliveries carry "test": true with a sample article. Real deliveries carry "test": false.

The JSON body:

{
  "event": "article.published (a blog post) or page.published (a site page)",
  "test": false,
  "article": {
    "id": 123,
    "type": "post or page",
    "title": "Article title",
    "slug": "article-title",
    "path": "null for a post; the site path for a page, e.g. /compare/acme-alternatives/",
    "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"],
    "published_at": "2026-01-01T00:00:00+00:00"
  },
  "website": {
    "domain": "example.com"
  }
}

What the receiver must do:
1. Compare the bearer token against my stored token and respond 401 on a mismatch.
2. Respond 200 to test deliveries ("test": true) without saving anything.
3. For real deliveries, create or update the content on my site from the payload. Match on "article.id" so a repeat delivery updates the existing item instead of duplicating it. When nothing is stored under that id yet, match a post on "article.slug" and a page on "article.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.
4. 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. Skip the page and respond 200 with the existing URL instead.
5. Give every post and page its SEO tags in the served HTML: the <title> from "seo.title" (fall back to "title"), the meta description from "seo.meta_description", og:image from "og_image_url" with "og_image_alt", and JSON-LD structured data: Article for a post, WebPage for a page.
6. Respond with a 2xx status on success. A JSON response body of {"url": "..."} is optional; when present, blogr.ai saves it as the live URL: https://my-site.com/blog/article-title for a post, https://my-site.com followed by "path" for a page.
7. Respond within 15 seconds: blogr.ai stops waiting after that and treats a timeout, like any non-2xx response, as a failed delivery. Save the content and respond; anything slow (such as rebuilding a large sitemap) must not hold up the response. Only error out when something really failed.

"content_html" is ready to render as-is. "content_markdown" is the same body in Markdown, in case my site renders Markdown itself. Neither contains an H1: the body starts with the introduction and uses H2s for sections, so render "article.title" as the page's <h1> above the body: exactly one H1 per page, and never the title twice.

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 blogr.ai delivered 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 stored content, so a newly delivered 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 the time of its latest delivery as the last-modified date, updated on each delivery. 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. blogr.ai re-delivers an older article when it adds a link to a page that went live later, so a repeat delivery must replace the stored body.

When you are done, tell me:
1. The full public URL of the webhook, so I can paste it into the Webhooks card in blogr.ai.
2. Where I paste my access token (the environment variable or secret name).
3. Where pages appear in the navigation, and the URL of the sitemap.

Connect your webhook

  1. In your dashboard, open Setup → Publishing and choose Webhooks.
  2. Enter your endpoint URL — it must be a public https URL, not a private or internal address.
  3. Copy the access token we generate for you into your endpoint's configuration. blogr.ai sends it with every delivery so your endpoint can verify the request really came from us.
  4. Click Send test to deliver a sample article, then Save.

A website publishes to one destination: connecting the webhook replaces any other integration on that website.

The delivery request

Every delivery — test or real — is an HTTP POST with these headers:

POST /your-endpoint HTTP/1.1
Host: api.example.com
Authorization: Bearer <your access token>
Content-Type: application/json
Accept: application/json
  • Redirects are not followed — the endpoint URL must respond directly.
  • Deliveries time out after 15 seconds. Respond fast and process the article asynchronously if you need to.
  • Any 2xx response counts as delivered. Anything else — or no response — marks the article as failed to publish.

The payload

The request body is one JSON object:

{
  "event": "article.published",
  "test": false,
  "article": {
    "id": 812,
    "type": "post",
    "title": "How to Choose a Laravel Hosting Provider",
    "slug": "how-to-choose-a-laravel-hosting-provider",
    "path": null,
    "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"],
    "published_at": "2026-07-08T09:30:00+00:00"
  },
  "website": {
    "domain": "example.com"
  }
}
Field Meaning
eventarticle.published for a blog post, page.published for an inner page from your topical map — see Blog posts and inner pages below. Check it before processing so future event types don't surprise your endpoint.
testtrue for test deliveries from the setup screen, false for real articles.
article.idThe article's stable ID in blogr.ai — use it to de-duplicate retried deliveries. 0 in test deliveries.
article.type"post" for a blog post, "page" for an inner page from the topical map. A page belongs on your site at article.path, outside the blog, and should never appear among blog posts.
article.slugURL-ready slug, taken from the SEO settings or derived from the title. For a page, the last segment of its path.
article.pathnull for a post. For a page, the site path it belongs at, with slashes on both ends — /pricing/, /compare/acme-alternatives/.
article.content_markdownThe article body as Markdown — the canonical source. It starts with the introduction and never contains an H1: render article.title as the page's <h1>.
article.content_htmlThe same body rendered to sanitized HTML, ready to store as-is. Like the Markdown, it carries no H1.
article.seoThe SEO title and meta description. Either can be null when not set.
article.og_image_urlPublic URL of the generated share image — keyword-named, metadata-free. Null when none was produced.
article.og_image_altKeyword-optimized alt text for the image, ready to use as-is.
article.target_keywordThe search query the article targets, or null.
article.categoriesUp to 3 categories generated with the article, as an array of strings. Empty when none were generated.
article.tagsUp to 3 tags generated with the article, as an array of strings. Empty when none were generated.
article.published_atISO 8601 timestamp of the delivery.
website.domainThe domain of the blogr.ai website the article belongs to — useful when one endpoint receives several sites.

Blog posts and inner pages

Your endpoint receives two kinds of content. Check event or article.type before you publish anything.

Blog posts arrive as article.published with type "post" and belong in your blog. Inner pages — the site-level pages your topical map designs, such as pricing, comparisons, services, and locations — arrive as page.published with type "page" and belong on the site itself, at the path in article.path, outside the blog.

An endpoint built before pages existed treats every delivery as a blog post. Add the check before publishing pages through it, or paste the current prompt above into your coding tool to update the receiver.

A page delivery looks like an article delivery, with the event, type, and path set:

{
  "event": "page.published",
  "test": false,
  "article": {
    "id": 913,
    "type": "page",
    "title": "Acme Alternatives: 7 CRMs Compared",
    "slug": "acme-alternatives",
    "path": "/compare/acme-alternatives/",
    "content_html": "<p>Seven CRMs compared…</p><h2>…",
    "seo": { "title": "…", "meta_description": "…" },
    "categories": [],
    "tags": []
  },
  "website": { "domain": "example.com" }
}
Blog post Inner page
eventarticle.publishedpage.published
article.typepostpage
article.pathnullThe site path, slashes on both ends — /pricing/, /compare/acme-alternatives/
article.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.
Respond with{"url": "https://example.com/blog/{slug}"}{"url": "https://example.com{path}"}

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 article.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
article.layoutarticle or sections.
article.blocksThe sections as data, in page order: {"version": 1, "sections": [...]}. Build them with your own components for a page that matches your design.
article.page_htmlThe same sections rendered to HTML inside one .blogr-page element, with no H1. Output it under your own <h1> title.
article.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.

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

Test deliveries

The Send test button on the setup screen delivers a fake article with the exact shape shown above, flagged with "test": true. Build your endpoint against the test delivery and it's ready for the real thing — just make sure test deliveries don't end up published on your site.

Responding to a delivery

Respond with any 2xx status code to confirm the delivery. If your response is JSON and includes a url, blogr.ai records it as the article's published URL:

{
  "url": "https://example.com/blog/how-to-choose-a-laravel-hosting-provider"
}

Failed deliveries and retries

If your endpoint is unreachable or responds with a non-2xx status, the article is marked "Failed to publish" in your workspace with the reason on the article row — for example "Your webhook endpoint responded 500". Nothing is lost: fix the endpoint and click Retry publish to deliver the article again.

Retries resend the full payload with the same article.id, so an endpoint that already stored the article can treat the retry as an update or ignore it.

Securing your endpoint

  • Verify the Authorization header on every request: it must equal Bearer followed by your access token. Reject anything else with a 401.
  • The token is generated for you and stored encrypted — treat it like a password, and rotate it anytime by entering a new value in Setup → Publishing.
  • Serve the endpoint over https. blogr.ai refuses to deliver to private or internal addresses.