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
read— reserved for future authenticated reads.write— create drafts and patch posts that are stilldraftorpending_review.
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 | 
{
"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:

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