Publish with a webhook or the API
Get your articles into any site or tool: receive each one as signed JSON on a webhook, or fetch published articles from the ProjectHQ content API.
Not on WordPress, Webflow, Ghost or PrestaShop? Two destinations get your articles into anything else. A Webhook pushes each article to a URL you choose — a Zapier, Make or n8n catch hook, or your own code. The ProjectHQ API works the other way round: your site fetches published articles from us. This page is for whoever builds the receiving end.
| Webhook | ProjectHQ API | |
|---|---|---|
| Direction | ProjectHQ sends each article to you | Your site asks ProjectHQ for articles |
| Good for | Automations, custom CMSs, static sites rebuilt on change | Sites that render the blog themselves (Next.js, Nuxt, Astro…) |
| Security | Every request signed with your secret | Public and read-only; only published articles |
| Address of the article | Your endpoint can tell us | Built from your Blog URL and the article's slug |
Both are chosen in your blog's Settings → Publishing — see Publish to your website.
Webhook
Connect it
- Choose Webhook.
- Paste your Endpoint URL.
- Copy the Signing secret — ProjectHQ makes one for you. Copy it now: it's stored encrypted and can't be shown again. The refresh button next to it makes a new one.
- Click Connect & verify. ProjectHQ sends your endpoint a test
ping, which it must answer with a 2xx status.
Once connected, Update credentials lets you change the URL; The current secret stays unless you click Generate a new one.
What ProjectHQ sends
A POST with a JSON body for each event. The event name is also in the X-ProjectHQ-Event header.
| Event | When |
|---|---|
ping |
When you connect, and when you click Test connection |
article.published |
An article is published (or goes live on its scheduled date) |
article.updated |
You click Push update or Retry delivery for an article that was already delivered |
article.unpublished |
An article is unpublished in ProjectHQ — only its uuid is sent |
The headers:
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: ProjectHQ-Webhook/1.0 (+https://projecthq.pro)
X-ProjectHQ-Event: article.published
X-ProjectHQ-Signature: sha256=5f2b…(64 hex characters)
article.published and article.updated:
{
"event": "article.published",
"timestamp": 1791367200,
"publication": "Blog",
"article": {
"uuid": "8f6c2d4e-1a3b-4c5d-9e8f-0a1b2c3d4e5f",
"title": "How to plan your week",
"slug": "how-to-plan-your-week",
"content_html": "<p>…</p>",
"meta_description": "A simple weekly planning routine…",
"featured_image_url": "https://…/cover.jpg",
"language": "en"
}
}
article.unpublished:
{
"event": "article.unpublished",
"timestamp": 1791370800,
"article": { "uuid": "8f6c2d4e-1a3b-4c5d-9e8f-0a1b2c3d4e5f" }
}
ping:
{ "event": "ping", "timestamp": 1791367200 }
timestamp is in Unix seconds. Use the article's uuid to recognise an article you've already received — an update carries the same uuid. Images in content_html and featured_image_url are links to images hosted by ProjectHQ; copy them to your own storage if you prefer.
Check the signature
X-ProjectHQ-Signature is sha256= followed by the HMAC-SHA256 of the raw request body, keyed with your signing secret, in lowercase hex. Compute it over the body exactly as received — before any JSON parsing — and compare in constant time.
Node.js (Express):
const crypto = require('crypto');
app.post('/projecthq', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.PROJECTHQ_WEBHOOK_SECRET)
.update(req.body) // the raw body
.digest('hex');
const given = req.get('X-ProjectHQ-Signature') || '';
const ok = given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
// … save or publish event.article …
res.json({ url: `https://www.example.com/blog/${event.article?.slug ?? ''}` });
});
Python (Flask):
import hashlib, hmac, os
from flask import Flask, request, abort, jsonify
app = Flask(__name__)
SECRET = os.environ["PROJECTHQ_WEBHOOK_SECRET"].encode()
@app.post("/projecthq")
def projecthq():
raw = request.get_data()
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-ProjectHQ-Signature", "")):
abort(401)
event = request.get_json()
# … save or publish event["article"] …
return jsonify(url=f"https://www.example.com/blog/{event.get('article', {}).get('slug', '')}")
Answer the request
- Answer with any 2xx status within 30 seconds. Anything else — another status, a timeout, a refused connection — is a failed delivery, shown on the article in the editor's SEO tab under Publishing, and retried automatically a few times.
- Optional: to tell ProjectHQ where the article went live, reply with JSON like
{"url": "https://www.example.com/blog/how-to-plan-your-week"}. That address becomes the article's link in ProjectHQ.
ProjectHQ API
Turn it on
Choose ProjectHQ API. Publishing is on straight away — there's nothing to connect. The section then shows:
- Blog URL — the address your blog lives at, for example
https://www.example.com/blog. ProjectHQ adds each article's slug to it to make the article's address, which it uses everywhere it links to the article. If you leave it empty, it falls back to your project's domain plus the blog's slug. - API endpoints — the two addresses your developer needs, with copy buttons.
Only articles with the published status are returned. When you publish an article in ProjectHQ, it's in the API immediately; when you unpublish it, it's gone.
Endpoints
No API key is needed — the endpoints are public and read-only. {project} is your project's slug and {publication} your blog's slug; the exact addresses are shown in the settings.
| Request | Returns |
|---|---|
GET https://api.projecthq.pro/public/v1/projects/{project}/publications |
The project's publications that have publishing through the API turned on |
GET …/publications/{publication}/articles |
Published articles, with their content. Add ?limit= (up to 100) and &offset= to page through them, newest first; without limit you get all of them |
GET …/publications/{publication}/articles/{article-slug} |
One published article, with its author |
GET …/publications/{publication}/articles/tree |
Published articles without their content, ordered for a sidebar — handy for documentation |
List of articles (?limit=1):
{
"articles": [
{
"uuid": "19c1bfd7-83f0-4b72-81fb-83f7285f515f",
"slug": "how-to-plan-your-week",
"title": "How to plan your week",
"content": "<p>…</p>",
"metaDescription": "A simple weekly planning routine…",
"featuredImageUrl": "https://…/cover.jpg",
"sortOrder": 0,
"createdAt": "2026-10-01T07:21:55Z",
"updatedAt": "2026-10-03T10:43:33Z"
}
],
"count": 1,
"total": 24,
"limit": 1,
"offset": 0
}
A single article has the same fields plus the byline (see Authors and bylines):
"author": {
"name": "Alex Morgan",
"role": "Head of Content",
"bio": "…",
"avatarUrl": "https://…/alex.jpg",
"socialLinks": ["https://www.linkedin.com/in/…"]
}
content is HTML. Errors come back as 404 with a short message: Project not found, Publication not found, Article not found, or Articles API not enabled for this publication when publishing through the API is off for that blog.
Tip: Build each article page at your Blog URL plus the article's
slug, so the links ProjectHQ uses match the pages your site serves.