10 min

How to Publish to Any Site by Webhook

Receive every article as a signed JSON request at an endpoint you run. For custom sites, headless CMSs, Next.js and sites built with app builders.

By Kush Agrawal · Updated

What gets published

Each article arrives as one JSON request with everything a page needs: the title and SEO title, the HTML body, slug, meta description, header image, author, category, format, FAQ, reading time and the JSON-LD markup. Your endpoint saves it and, optionally, answers with the live address.

Later changes arrive as post.updated with the same id, so republishing, automatic article updates and link fixes from Site Health update the article you already have.

Before you start

  • 1A BlogAmplify plan for this website (the 3-day trial counts). See pricing
  • 2A public server or serverless function that can receive a POST request
  • 3Basic knowledge of HTTP, JSON and your site's code (or a developer who has it)

Steps

  1. Step 1: Set up an endpoint on your server

    Create an endpoint that BlogAmplify can POST articles to. It should:

    • Accept POST requests with a JSON body (Content-Type: application/json)
    • Answer with a 2xx status within 30 seconds once the article is saved
    • Be reachable from the internet. BlogAmplify refuses private and local addresses such as localhost, 10.x or 192.168.x
    • Accept large bodies: a full article with its HTML and markup is far bigger than a test ping, so allow at least 1 MB
  2. Step 2: Read the payload

    Every request has an event and the article under data. The event is post.published the first time an article goes out, post.updated whenever a published article changes (republished from the editor, an automatic article update, a fixed link on Site Health) and post.test for test pings.

    post.published payload
    {
      "event": "post.published",
      "timestamp": "2026-10-15T09:00:00.000Z",
      "data": {
        "id": "8f0c2a4e-6b1d-4c3a-9e7f-2d5b1a0c9e11",
        "title": "Your Article Title",
        "meta_title": "SEO Title (Up to 60 Characters)",
        "slug": "your-article-title",
        "content_html": "<p>The answer first...</p><h2>...</h2>",
        "content_markdown": null,
        "meta_description": "The meta description",
        "excerpt": "The meta description",
        "featured_image": {
          "url": "https://cdn.example.com/featured-image.webp",
          "alt_text": "Description of the image"
        },
        "author": {
          "name": "Jane Doe",
          "role": "Head of Content",
          "bio": "Short author bio",
          "credentials": null,
          "profile_url": "https://yoursite.com/about/jane",
          "image_url": "https://yoursite.com/jane.jpg",
          "same_as": ["https://www.linkedin.com/in/jane"]
        },
        "published_at": "2026-10-15T09:00:00.000Z",
        "updated_at": null,
        "language": "en",
        "category": "Product photography",
        "content_type": "how-to",
        "faq": [
          { "question": "A question readers ask", "answer": "Its answer." }
        ],
        "reading_time_minutes": 7,
        "schema_markup": [
          { "@context": "https://schema.org", "@type": "BlogPosting", "...": "..." },
          { "@context": "https://schema.org", "@type": "FAQPage", "...": "..." }
        ],
        "primary_keyword": "main keyword",
        "secondary_keywords": ["keyword one", "keyword two"],
        "word_count": 1500,
        "published_url": null,
        "remote_id": null
      }
    }
    • content_html is the full article body as HTML, including its FAQ, sources and disclaimer, with images on absolute URLs. It has no H1: render title as the page heading. content_markdown is currently null; convert the HTML on your side if your site needs Markdown.
    • id never changes. A post.updated event carries the same id as the original post.published, so update the article you saved instead of adding a second one, and keep its address.
    • meta_title is the title for the page's <title> tag and can differ from title, the on-page headline. meta_description (also sent as excerpt) is the meta description.
    • author comes from the author profile in Article Config (only name is always set), or is null when no author is set. category is the topic the article belongs to, ready to use as a category, and content_type its format (how-to, comparison, listicle and so on).
    • published_at is the first publish time and stays the same on updates; updated_at is set on post.updated. faq repeats the article's questions and answers for sites that render FAQs themselves.
    • schema_markup is an array of JSON-LD objects: a BlogPosting, plus an FAQPage and a VideoObject when the article has an FAQ or a video. Output each in its own <script type="application/ld+json"> tag.
    • published_url and remote_id are filled on updates when BlogAmplify knows them (from your reply, below); otherwise they are null.

    Optionally, answer with the live address and your own id. BlogAmplify then links to the live article, checks it on Site Health, and sends your id back as remote_id on later updates. The address must start with https:// or http:// (published_url is accepted as another name for url), and the id may be a string or a number:

    Optional reply
    { "url": "https://yoursite.com/blog/your-article-title", "id": "your-own-post-id" }
  3. Step 3: Verify the signature

    When you set a webhook secret, BlogAmplify signs every request: the X-Webhook-Signature header holds the HMAC-SHA256 of the exact request body, keyed with your secret, as lowercase hex. Compute the same over the raw body and compare the two in constant time. Without a secret, requests are sent unsigned, so set one.

    const crypto = require('crypto');
    const express = require('express');
    
    const app = express();
    
    class="cb-cmt">// The signature covers the exact bytes BlogAmplify sent, so verify the raw body
    function verifyWebhook(rawBody, signature, secret) {
      if (!signature || !secret) return false;
    
      const expected = crypto
        .createHmac('sha256', secret)
        .update(rawBody)
        .digest('hex');
    
      const a = Buffer.from(signature);
      const b = Buffer.from(expected);
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    }
    
    class="cb-cmt">// express.raw keeps the body as a Buffer for verification
    app.post('/api/blog', express.raw({ type: 'application/json', limit: '2mb' }), async (req, res) => {
      if (!verifyWebhook(req.body, req.headers['x-webhook-signature'], process.env.WEBHOOK_SECRET)) {
        return res.status(401).json({ error: 'Invalid signature' });
      }
    
      const { event, data } = JSON.parse(req.body.toString('utf8'));
    
      if (event === 'post.published' || event === 'post.updated') {
        class="cb-cmt">// Upsert by data.id: post.updated replaces the article saved for post.published
        const saved = await upsertArticle(data.id, {
          slug: data.slug,
          title: data.title,
          seoTitle: data.meta_title,
          description: data.meta_description,
          html: data.content_html,
          jsonLd: data.schema_markup, class="cb-cmt">// an array of JSON-LD objects
        });
        class="cb-cmt">// Optional: tell BlogAmplify where the article lives
        return res.status(200).json({ url: saved.url, id: saved.id });
      }
    
      class="cb-cmt">// post.test and any event added later: answer 200, save nothing
      res.status(200).json({ ok: true });
    });
  4. Step 4: Open the publishing settings in BlogAmplify

    Sign in to BlogAmplify. On a new website, publishing is the last onboarding step (step 5, Publishing). On a website that is already set up, open Integrations in the sidebar. Click the Custom Webhook card.

    The Custom Webhook card selected in BlogAmplify, with Endpoint URL and Webhook Secret fields
  5. Step 5: Enter the endpoint and secret

    • Endpoint URL: the full address of your endpoint, such as https://yoursite.com/api/blogamplify.
    • Webhook Secret: a long random string, for example from openssl rand -hex 32. Store the same value on your server (the examples read it from WEBHOOK_SECRET).
    The webhook form in BlogAmplify with an Endpoint URL and a Webhook Secret filled in
  6. Step 6: Test the connection

    Click Test Connection. BlogAmplify sends a signed post.test request with the same shape as a real publish and sample data, and shows whether your endpoint answered with a 2xx status. Answer test pings with 200 and save nothing.

    During onboarding, a test that passes also saves the webhook. On the Integrations page, click Save after the test passes.

  7. Step 7: Finish setup

    During onboarding, click Complete Setup. From then on, every article BlogAmplify publishes arrives at your endpoint as a post.published request.

    Auto-publish is on by default, so each planned article is written and sent on its day, at the time set in Article Config. To read articles first, turn off Auto-Publish there: you then start each article yourself and publish it from the editor.

Next.js, Lovable and other app builders

Next.js

Put the receiver in a route handler (the Next.js tab above) and store articles where your pages can read them: a database, a headless CMS or files in your repository. Render each article at /blog/[slug], use meta_title and meta_description in generateMetadata, and print each schema_markup object in its own JSON-LD script. With a database, revalidate the article's path when a request arrives so updates show without a new deploy.

Lovable and other app builders

Sites made with Lovable and similar builders are web apps with a backend you can ask the builder to extend, often Supabase. Ask for a backend function that checks the X-Webhook-Signature header with your secret, saves each article by data.id in a posts table, and answers with the article's address. Then use that function's address as the Endpoint URL.

Give every article its own address and make sure its text is in the page's HTML, not only loaded after the page opens, so search engines and AI assistants can read it.

Troubleshooting

The test says the endpoint can't be reached

  • The address must be public: not localhost, a private network address or a VPN-only host
  • A firewall or bot protection in front of the endpoint may be blocking server-to-server requests
  • The endpoint must answer within 30 seconds
  • CORS headers aren't needed: the request comes from our server, not a browser

Signature checks fail on your server

  • The secret on your server must match the one saved in BlogAmplify exactly
  • Verify the raw body. Parsing the JSON and serializing it again changes the bytes, and the signature no longer matches
  • Some frameworks parse the body before your handler runs. Read the raw body first, as in the examples

The test passes but real publishes fail

Test pings are small; real articles are much larger. Raise your server's body size limit (in Express, express.raw({ limit: '2mb' })) and check that saving a full article finishes within 30 seconds.

The same article arrived twice

When your endpoint answers with a 5xx or 429 status, times out or can't be reached, BlogAmplify sends the same request again, up to three more times within about two minutes. Republishing sends post.updated with the same data.id. Save articles by that id (an upsert), and repeats never create a duplicate.

Fields are empty or missing

All article fields are under the data key. content_markdown is always null for now, and featured_image, author, category or content_type can be null when the article has none.

Other events

BlogAmplify sends post.published, post.updated and post.test. Check the event field and answer 200 to values you don't handle, so your endpoint keeps working if new events are added.

Ready to connect your website?

Start a plan, then connect your blog during setup. It takes a few minutes.

Card required. Free for 3 days, then from $99/mo. Cancel before then and pay nothing.