Powlo developers · v1.0.0

Blog API

Public read API and token-authenticated write API for powlo.co/blog.

1. Overview

This API is for vetted third-party contributors. Rate limits are per token (default 60 requests / hour). Breaking changes get 6 months’ notice.

2. Authentication

An admin issues a token at dashboard.powlo.co/blog/tokens and shares the plaintext over a single-use signed URL. Send it as:

Authorization: Bearer blg_…

Postman: import docs/Powlo-Blog.postman_collection.json plus docs/Powlo-Blog.postman_environment.json (local) or docs/Powlo-Blog.production.postman_environment.json. Paste the plaintext into write_token.

Missing or invalid tokens return 401 with WWW-Authenticate: Bearer realm="powlo.co blog api". A read-only token calling a write endpoint returns 403.

3. Scopes

The API cannot publish. An admin publishes from the dashboard.

4. Formatting content

Send the body in body_md and choose how it is rendered with render_mode:

render_mode What to put in body_md What readers see
markdown (default) GitHub-flavoured Markdown. Raw HTML in the body is stripped before conversion. Server-rendered, sanitised HTML in body_html.
html HTML fragment (still sent as body_md — the field name is the same in both modes). Sanitised HTML. script, iframe, style, classes, and inline CSS are removed.

Always write body_md. The API stores the source and fills body_html on save. Public GET returns both.

Markdown (recommended)

CommonMark plus GitHub Flavoured Markdown: headings, emphasis, links, lists, tables, fenced code blocks, block quotes, and images. Smart punctuation (curly quotes, em dashes) is applied.

# Is solar worth it in 2026?

A short answer: **often yes**, depending on *tariff* and roof.

## What we measured

- South-facing roofs in Brighton
- Heat-pump households on ToU tariffs

See the [PowloFlex overview](https://powlo.co).

| Scenario | Payback |
| --- | --- |
| Typical semi | 8–12 years |

![Rooftop array in Brighton](https://images.unsplash.com/photo-1509391366360-2e959784a276)
{
  "title": "Is solar worth it in 2026?",
  "render_mode": "markdown",
  "body_md": "# Is solar worth it in 2026?\n\nA short answer: **often yes**."
}

Do not embed <script>, <iframe>, or other HTML in markdown mode — it is stripped. Use Markdown syntax instead.

HTML

Set "render_mode": "html" when you already have an HTML fragment. Allowed tags:

p, br, hr, h1–h6, strong, b, em, i, u, s, code, pre, blockquote, ul, ol, li, a[href|title|rel], img[src|alt|title|width|height], table, thead, tbody, tr, th, td

Not allowed: script, iframe, style, class, id, style="", data-*, target="_blank". Links may use href, title, and rel only.

{
  "title": "HTML mode example",
  "render_mode": "html",
  "body_md": "<h1>Hello Brighton</h1><p>Solar, heat pumps, and what we are building.</p>"
}

5. Images

There is no media-upload endpoint. Host files yourself (or on Unsplash) and pass absolute https URLs. Relative paths, data: URIs, and unknown hosts are rejected with 422.

Default allowed hosts: images.unsplash.com, cdn.powlo.co, cdn.marblism.com . Pass extra hostnames on the post with image_host_allowlist (additive — defaults are never removed).

Inline images in the body

Markdown:

![Rooftop array in Brighton](https://images.unsplash.com/photo-1509391366360-2e959784a276)

HTML (render_mode: html):

<img src="https://cdn.powlo.co/blog/brighton-array.jpg" alt="Rooftop array in Brighton" width="1600" height="900">

Always set meaningful alt text. width and height are optional.

Hero and Open Graph images

These sit outside the body and must also be on the allowlist:

Field Used for
hero_image_url Large image at the top of the public post.
og_image_url Social / Open Graph share image. Falls back to the hero if omitted.
{
  "title": "Hello Brighton",
  "render_mode": "markdown",
  "hero_image_url": "https://images.unsplash.com/photo-1509391366360-2e959784a276",
  "og_image_url": "https://cdn.powlo.co/blog/hello-brighton-og.jpg",
  "body_md": "# Hello Brighton\n\n![Street view](https://images.unsplash.com/photo-1509391366360-2e959784a276)\n"
}

Extending the allowlist

If your images live on another host, pass extra hostnames (no scheme, no path). They are merged with the defaults:

{
  "title": "Partner research note",
  "image_host_allowlist": ["images.sussex.ac.uk"],
  "hero_image_url": "https://images.sussex.ac.uk/blog/spru-hero.jpg",
  "body_md": "# Note\n\n![Chart](https://images.sussex.ac.uk/blog/chart.png)\n"
}

A rejected host returns:

{ "error": "image_host_not_allowed", "host": "evil.example", "allowlist_source": "default" }

allowlist_source is default when the URL missed the global list, or post when it missed a post-specific extra list.

6. Fields

JSON request bodies for POST and PATCH. Types are JSON types. POST requires title and body_md; every other write field is optional. PATCH is partial — omit a field to leave it unchanged; send JSON null where the table says nullable to clear it.

Write fields

Field Type POST PATCH Limits Notes
title string required optional max 200 Used to generate slug on create.
body_md string required optional max 500,000 Markdown or HTML source. See §4.
render_mode string enum optional optional markdown or html Default markdown.
subtitle string or null optional optional max 255 Shown under the title on the public post.
excerpt string or null optional optional max 500 If omitted, truncated from the body (up to 280 characters).
tags string[] or null optional optional max 10 items, each max 40 Exact-match filter on GET …/posts?tag=.
hero_image_url uri or null optional optional max 2048 Absolute https URL. Host must be allowlisted. See §5.
og_title string or null optional optional max 255 Open Graph title. Falls back to title.
og_description string or null optional optional max 500 Open Graph description. Falls back to excerpt.
og_image_url uri or null optional optional max 2048 Share image. Same allowlist as hero. Falls back to hero.
author_name string optional optional max 255 On create, defaults to the API token’s name.
author_email email or null optional optional max 255 Not shown on the public post; stored for editorial contact.
locale string optional — max 5 Default en. Create only; unique with slug.
image_host_allowlist string[] or null optional optional each max 255 Extra hostnames (no scheme), merged with the defaults.
status string enum — optional draft or pending_review Create always stores draft. published is rejected. Admin publishes.

Full create example

{
  "title": "Hello Brighton",
  "subtitle": "A note from the PowloFlex work",
  "excerpt": "A short update from Brighton.",
  "body_md": "# Hello Brighton\n\nSolar, heat pumps, and what we are building.",
  "render_mode": "markdown",
  "tags": ["solar", "brighton", "policy"],
  "hero_image_url": "https://images.unsplash.com/photo-1509391366360-2e959784a276",
  "og_title": "Hello Brighton",
  "og_description": "A short update from Brighton.",
  "og_image_url": "https://cdn.powlo.co/blog/hello-brighton-og.jpg",
  "author_name": "Sussex SPRU",
  "author_email": "spru@example.ac.uk",
  "locale": "en",
  "image_host_allowlist": ["images.sussex.ac.uk"]
}

Server-set (cannot write)

Field Type Set by
slug string Generated from title on create. Returned in the 201 body.
body_html string Rendered and sanitised from body_md on save.
reading_time_minutes integer Estimated from body word count (~220 wpm).
published_at datetime (ISO-8601) or null Set when an admin publishes.
url string Create response only: /blog/{slug}.

Create response (201)

{ "slug": "hello-brighton", "status": "draft", "url": "/blog/hello-brighton" }

Drafts are not on the public site until an admin publishes. GET /api/v1/blog/posts/{slug} returns 404 until then.

List query parameters

GET /api/v1/blog/posts:

Param Type Default Notes
status string published Unauthenticated callers always receive published posts only.
tag string — Exact tag match.
locale string en
page integer 1
per_page integer 10 Max 50. Higher values return 422.

Read responses

List items (data[]): slug, title, subtitle, excerpt, tags, author_name, published_at, reading_time_minutes, hero_image_url, og_title, og_description, og_image_url.

Post detail adds: body_html, body_md, render_mode, locale, status. Wrapped as { "data": { … } }. List is { "data": […], "meta": { "current_page", "per_page", "total", "last_page" } }.

7. Endpoints

GET /api/v1/blog/posts

Unauthenticated. Paginated published posts. per_page default 10, max 50.

curl "https://powlo.co/api/v1/blog/posts?status=published&page=1"
const res = await fetch("https://powlo.co/api/v1/blog/posts");
const { data, meta } = await res.json();
import requests
print(requests.get("https://powlo.co/api/v1/blog/posts").json())

GET /api/v1/blog/posts/{slug}

Returns body_html, body_md, and OG fields. Sends ETag; honour If-None-Match with 304.

curl -I "https://powlo.co/api/v1/blog/posts/is-solar-worth-it-in-2026"

POST /api/v1/blog/posts

Creates a draft. Requires write scope. Fields: §6. Body and images: §4, §5.

curl -X POST "https://powlo.co/api/v1/blog/posts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Hello Brighton","body_md":"# Hello\n\nFrom SPRU."}'
await fetch("https://powlo.co/api/v1/blog/posts", {
  method: "POST",
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
  body: JSON.stringify({ title: "Hello Brighton", body_md: "# Hello" }),
});
requests.post("https://powlo.co/api/v1/blog/posts",
    headers={"Authorization": f"Bearer {token}"},
    json={"title": "Hello Brighton", "body_md": "# Hello"})

PATCH /api/v1/blog/posts/{slug}

Updates fields while the post is draft or pending_review. You may set status to pending_review. Setting published is rejected. Field list: §6.

8. Validation errors

422 responses use Laravel’s errors.{field} shape. Disallowed image hosts use the payload in §5 Images.

9. Rate limiting

Per token, default 60 requests / hour. Exceeding the limit returns 429 with a Retry-After header.

10. Versioning

Current version 1.0.0. Breaking changes get 6 months’ notice.

# Blog API changelog

## v1.0.0 — 24 August 2026

- Public `GET /api/v1/blog/posts` and `GET /api/v1/blog/posts/{slug}`.
- Token-authenticated `POST` and `PATCH` for drafts / pending review.
- HTML or markdown bodies, image-host allowlist, SHA-256 hashed tokens.
- Breaking-change policy: 6 months notice before any breaking change.
- Postman collection: `docs/Powlo-Blog.postman_collection.json` with local and production environments.
- Public docs cover markdown/HTML formatting, image-host rules, and the full write/read field list (`/developers/blog-api`).

11. Support

Questions: noreply@powlo.co.