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

  1. Choose Webhook.
  2. Paste your Endpoint URL.
  3. 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.
  4. Click Connect & verify. ProjectHQ sends your endpoint a test ping, which it must answer with a 2xx status.
The Webhook connect form: Endpoint URL, Signing secret with copy and regenerate buttons, and the expanded What we send section with an example payload

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.
A connected webhook: Webhook, Connected, https://hooks.example.com/projecthq, with Test connection, Update credentials and Disconnect

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.
ProjectHQ API is Live: the Blog URL field and the API endpoints for listing all published articles and getting a single article

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.

Publish with a webhook or the API — ProjectHQ Docs