---
title: "Publishing to a webhook"
category: "Integrations"
source: "https://getbloggable.com/help/publish-webhook"
updated: 2026-09-10T18:45:46.576Z
---

# Publishing to a webhook

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](/help/connect-zapier)** — 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**.

1. **Endpoint URL** — any HTTPS URL that accepts a POST. It has to be publicly reachable; private and loopback addresses are refused.
2. **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.
3. **Connect.** A `ping` event goes out immediately. Verify its signature against your secret before you trust anything else.
4. **Arm it.** *Publish new posts automatically* is off by default.

## What arrives

```json
{
  "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.

```js
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:

1. **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.
2. **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.
