For any CMS Bloggable doesn't have an adapter for. Point the blog at an HTTPS endpoint and every publish, update and unpublish arrives there as JSON — the markdown and the rendered HTML both included, signed so your endpoint can prove it came from us.
Typical uses: a headless CMS (Contentful, Sanity, Strapi), a static site that rebuilds on a hook (Next.js, Astro, Hugo), or your own application.
Is this the one you want?
There are two different webhooks in Bloggable and they do different jobs:
- This one — set up per blog, under Site address → Your own platform. It is that blog's publishing destination: the full article, in markdown and HTML, for a system that will render it.
- Event webhooks — set up per account, under Integrations → Webhooks. Notifications that something happened, for Zapier, Make, n8n or Slack. They carry a summary and a link, not the article body.
If you want to publish the post, you're in the right place. If you want to know that a post published, use the other one.
Setting it up
In Bloggable: Blog settings → General → Site address → Your own platform → Webhook.
- Endpoint URL — any HTTPS URL that accepts a POST. It has to be publicly reachable; private and loopback addresses are refused.
- Copy the signing secret. Bloggable mints it and shows it exactly once, before the connection is created. It is stored encrypted and cannot be read back afterwards — a secret you choose is a secret you reuse, so we generate it for you.
- Connect. A
pingevent goes out immediately. Verify its signature against your secret before you trust anything else. - Arm it. Publish new posts automatically is off by default.
What arrives
{
"event": "post.published",
"timestamp": 1768382602,
"idempotencyKey": "…",
"post": {
"title": "How we cut onboarding in half",
"slug": "how-we-cut-onboarding-in-half",
"html": "<h2>…</h2>",
"markdown": "## …",
"excerpt": "…",
"metaDescription": "…",
"category": "Product",
"tags": ["onboarding", "product"],
"featuredImage": { "url": "https://…", "alt": "…" },
"inlineImages": [],
"authorName": "Sam Okafor",
"publishedAt": "2026-01-14T09:03:21.000Z",
"sourceUrl": "https://blog.acme.com/how-we-cut-onboarding-in-half"
}
}
Events are post.published, post.updated, post.unpublished and ping. post is null on ping only — the key is always present, so your schema stays stable.
Optional values are sent as explicit null, never omitted. A key that disappears between posts silently breaks a live scenario, so the shape is the same every time. Changes to it will only ever be additive.
sourceUrl is informational. It is the Bloggable address, for receivers that want to link back. It is not an instruction to write a canonical pointing at us — your site is the canonical.
Images stay as URLs
This is the one connection where images are not uploaded for you. Your endpoint receives absolute image URLs and decides what to do with them.
There is no media endpoint to upload to on a raw relay, so this is stated up front rather than discovered when a Bloggable CDN URL turns up in your published article. If you want the images on your own storage, download them from those URLs as part of your handler.
Headers
| Header | What it is |
|---|---|
X-Bloggable-Event |
post.published, post.updated, post.unpublished or ping |
X-Bloggable-Timestamp |
Unix seconds — also the first half of the signed string |
X-Bloggable-Idempotency-Key |
Stable per post and intent. Dedupe on it and a retry never publishes twice. |
X-Bloggable-Signature |
sha256= followed by a hex HMAC-SHA256 |
Verifying the signature
The signed string is {timestamp}.{raw body}, not the body alone. Signing the body alone produces a signature that stays valid forever, so anyone who captures one request could replay it indefinitely.
const crypto = require('crypto')
// `raw` must be the RAW request body, read BEFORE JSON.parse
function verify(raw, headers, secret) {
const ts = headers['x-bloggable-timestamp']
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(`${ts}.${raw}`, 'utf8').digest('hex')
const received = headers['x-bloggable-signature'] || ''
return (
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)) &&
Math.abs(Date.now() / 1000 - Number(ts)) < 300 // five-minute window
)
}
Two gotchas worth stating loudly:
- Verify the raw body. Re-serialising the parsed JSON changes key order and whitespace, and the digest will not match. Most frameworks need an explicit raw-body option for this.
- Compare in constant time. A byte-at-a-time
===leaks the expected signature to a patient attacker.
Answering
Reply with any 2xx. Bloggable treats 3xx as a failure and never follows it — a redirect from an endpoint would replay the signature against whatever origin it named.
Answer promptly and do slow work afterwards. A long rebuild that runs before the response will look like a timeout on our side.
Troubleshooting
404 or 410. Usually an expired test URL rather than a deleted post — n8n's Test URL and unpublished Zapier hooks both do this. Bloggable reports it as an unreachable endpoint rather than claiming your post was deleted, because the two need completely different actions.
Signature never matches. You are almost certainly verifying re-serialised JSON. Read the raw body first.
The same post arrives twice. Dedupe on X-Bloggable-Idempotency-Key, which is stable across retries.
Everything is failing. Check the connection row in blog settings — it names the actual response we got.
Removing it
Detach the connection in blog settings. Posts already delivered are yours and stay exactly where they are; the signing secret is destroyed, and reconnecting later mints a new one.