Launch offer: 3-day trial for $1, then $39/mo. Cancel anytime.

For developers

Publish to your own site with a webhook

With a webhook, LazySEO sends every article as a JSON request to a URL you choose, and code on your side saves it as a post. This works with any custom-built site (Next.js, Laravel, Rails, Django, a static site generator with a build hook, and so on), but only if your site has an endpoint that receives the request. LazySEO cannot create that code for you; a webhook with nothing listening does nothing.

7 min readLast reviewed PDF version

In LazySEO: Settings → Integrations → Add Integration → Webhook

Before you start

  • A developer who can add an HTTPS endpoint to your site or backend.
  • A place to store posts (database, headless CMS, Markdown files in a repository, and so on).
  • Optional but recommended: a random secret to sign requests, for example generated with openssl rand -hex 32.

Step by step

  1. 1

    Step 1: Build the receiving endpoint

    Accept the request described below, verify the signature if you set a secret, turn content into HTML (it is usually Markdown, see "The article content"), create or update the post, and reply with a 2xx status and a JSON body containing the post id and URL. You can start from the Node.js or PHP example further down.

  2. 2

    Step 2: Open the integration form in LazySEO

    • Open your project in LazySEO.
    • In the left sidebar, under Settings, click Integrations.
    • Click Add Integration.
    • In the window that says "Choose the type of integration you want to add.", click Webhook.
  3. 3

    Step 3: Save the integration

    Fill in the fields as described in the table below, leave Enabled switched on (disabled integrations are skipped during publish) and click Add integration.

Fill in the LazySEO form

Labels below match the Webhook form in LazySEO. Required fields are marked with an asterisk (*) in the form and must be filled in before you can save.

NameRequired

Any name that helps you recognise this connection. Only shown inside LazySEO.

Example: Production Webhook

Endpoint URLRequired

The full URL of your endpoint. Use https:// in production.

Example: https://www.example.com/api/lazyseo-webhook

MethodRequired

POST or PUT. Used for every article sent to the endpoint.

Example: POST

Headers (optional)Optional

Extra headers sent with every request, added with Add header. Useful for an API key your endpoint expects. Header values other than common non-secret ones (such as Accept) are stored encrypted and hidden after saving.

Example: Authorization → Bearer my-endpoint-key

Signing Secret (optional)Optional

If set, LazySEO signs every request with this secret (see "Verify the signature"). Store the same value on your server.

Example: 3f9c2a...(64 random hex characters)

EnabledOptional

Leave on. When it is off, LazySEO skips this integration when publishing.

Test the connection

  • Right after you save, click Run quick test in the window, or test later from the integration card with Test.
  • Quick test sends a small POST request (always POST) with "_test": true and waits up to 10 seconds for a 2xx response. Your endpoint should answer it without creating a post.
  • On the card's Test menu, Full test sends a test article titled "[TEST] LazySEO Test Article" with "status": "draft", using your Method, so you can check that your endpoint creates the post correctly. Delete the test post afterwards.
  • If a test fails, LazySEO shows the reason, for example "Your endpoint answered HTTP 404; it must reply with a 2xx status", a timeout, or a certificate or DNS problem.
  • Under Sync History on the Integrations page, each successful publish shows the request LazySEO sent and your endpoint's response. A publish that still fails after all retries is listed with the error.

What gets published

  • What

    Request

    How LazySEO handles it
    One JSON request per article to your Endpoint URL, with your Method.
  • What

    Content

    How LazySEO handles it
    The article body in content: Markdown for articles as LazySEO generates them (it can contain embedded HTML blocks such as <figure> or a YouTube <iframe>), and HTML for articles edited and saved in the LazySEO editor. Render Markdown before you display it (see "The article content").
  • What

    Images

    How LazySEO handles it
    The banner image is sent as a URL (featuredImageUrl and bannerUrl); download it if you want to host it yourself.
  • What

    Status

    How LazySEO handles it
    "published" for real articles, "draft" for the Full test.
  • What

    Updates

    How LazySEO handles it
    When an article is sent again after a successful publish, the payload also contains externalId: the id your endpoint returned the first time. If you no longer have that post, create it again (create-or-update), so republishing a deleted post brings it back.

The request LazySEO sends

  • Header

    Content-Type

    Value
    application/json (unless you override it with a custom header)
  • Header

    Your custom headers

    Value
    Every header from Headers (optional)
  • Header

    X-Signature

    Value
    sha256= followed by the HMAC-SHA256 of the body, in hex. Only sent when Signing Secret (optional) is set.

Payload fields

Fields without a value are left out of the JSON (for example metaDescription when an article has none). Ignore fields you don't recognise, so new fields don't break your endpoint.

  • Field

    title

    Type
    string
    Description
    Article title.
  • Field

    slug

    Type
    string
    Description
    URL-friendly slug, e.g. how-to-brew-better-coffee-at-home.
  • Field

    content

    Type
    string
    Description
    Full article body. Markdown (GitHub style, may contain embedded HTML blocks) for generated articles; HTML for articles edited in the LazySEO editor. The title is sent separately in title.
  • Field

    metaDescription

    Type
    string, optional
    Description
    SEO meta description.
  • Field

    topics

    Type
    string[]
    Description
    Topic labels for the article (may be empty).
  • Field

    tags

    Type
    string[]
    Description
    Same values as topics.
  • Field

    status

    Type
    string
    Description
    "published" for real articles, "draft" for the Full test.
  • Field

    bannerUrl

    Type
    string, optional
    Description
    URL of the article banner image.
  • Field

    featuredImageUrl

    Type
    string, optional
    Description
    Same image as bannerUrl.
  • Field

    externalId

    Type
    string, optional
    Description
    Only on updates: the id your endpoint returned when the article was first published, as a string (a numeric id such as 123 comes back as "123").
Example payload
{
  "title": "How to Brew Better Coffee at Home",
  "slug": "how-to-brew-better-coffee-at-home",
  "content": "Great coffee starts with fresh beans...\n\n## Choose the right grind\n\n- **French press:** coarse\n- **Espresso:** fine\n\n...",
  "metaDescription": "Learn how to brew better coffee at home with the right beans, grind size and water temperature.",
  "topics": ["Coffee Brewing", "Home Barista"],
  "tags": ["Coffee Brewing", "Home Barista"],
  "status": "published",
  "bannerUrl": "https://cdn.example.com/banners/how-to-brew-better-coffee.jpg",
  "featuredImageUrl": "https://cdn.example.com/banners/how-to-brew-better-coffee.jpg"
}

The article content: Markdown or HTML

Articles as LazySEO generates them are Markdown, so content usually arrives as Markdown. If you display it as it is, readers see ## and ** instead of headings and bold text. Render it with a Markdown library that allows embedded HTML, for example marked or markdown-it (with html: true) in Node.js, Parsedown or league/commonmark in PHP, or Python-Markdown.

Articles that were edited and saved in the LazySEO editor arrive as HTML. To tell them apart, check whether content starts with an HTML block tag such as <p>, <h2>, <div> or <figure>; LazySEO uses the same check. Both examples below do this. If your system stores Markdown, you can also keep generated articles as Markdown and only convert the HTML ones.

Test payloads

Quick test (always POST)
{
  "_test": true,
  "title": "LazySEO Webhook Test",
  "slug": "lazyseo-webhook-test",
  "content": "<p>This is a test payload from LazySEO to verify your webhook endpoint is working.</p>",
  "status": "draft"
}
Full test (your Method)
{
  "title": "[TEST] LazySEO Test Article",
  "slug": "lazyseo-test-article",
  "content": "<h1>LazySEO Test Article</h1><p>This is a <strong>test article</strong> sent by LazySEO ...</p>",
  "metaDescription": "Test article from LazySEO integration test.",
  "status": "draft",
  "tags": ["lazyseo-test"]
}

What your endpoint should return

  • Any 2xx status means success. Any other status (or no response) is a failure, and LazySEO stores your response body as the error message, so return a helpful one.
  • To link the article to your post, reply with Content-Type: application/json and a body containing id (or externalId) and url (or publishedUrl). The id can be a string or a number; LazySEO stores it as a string.
  • LazySEO saves url as the article's published URL. It is used to link to the live article and, if you have connected Google Search Console, to ask Google to index it. Without a url, the article is still marked as published.
  • LazySEO sends id back as externalId the next time the same article is published, so you can update the post instead of creating a duplicate.
Example response
{
  "id": "123",
  "url": "https://www.example.com/blog/how-to-brew-better-coffee-at-home"
}

Verify the signature

When Signing Secret (optional) is set, LazySEO computes an HMAC-SHA256 of the exact request body with your secret and sends it, hex-encoded, as X-Signature: sha256=<hex>. Compute the same value over the raw body bytes (before any JSON parsing or re-serialising) and compare it with a constant-time comparison. Reject the request with 401 if it doesn't match.

Retries and timeouts

  • If your endpoint fails, LazySEO tries the same article up to 3 times, a few seconds apart.
  • For scheduled auto-publishing, LazySEO tries again later (at least an hour apart, up to 3 rounds). After that it stops and sends you a "Publishing failed" notification; you can send it again with Retry in Sync History or Publish on the article.
  • Reply quickly. LazySEO waits up to 30 seconds for a publish request and 10 seconds for the Quick test, then counts it as failed (and retries the publish). If your endpoint does slow work (image processing, a site rebuild), save the post, reply, and do the rest in the background.
  • Because of retries, your endpoint can receive the same article more than once. Create or update by slug (or by externalId) instead of always inserting a new row.

Deletion requests (optional)

The webhook is designed to send a DELETE request to the same URL with the body {"externalId": "..."} (signed the same way) to remove a post. Handling it is optional: unpublishing an article inside LazySEO does not currently send it, so it does not remove the post from your site.

Example receiver: Node.js (Express)

server.js
// npm install express marked
const express = require('express');
const crypto = require('crypto');
const { marked } = require('marked');

const app = express();
const SECRET = process.env.LAZYSEO_SECRET; // same value as "Signing Secret" in LazySEO
const posts = new Map(); // replace with your database or CMS

// Keep the raw body: the signature is computed over the exact bytes LazySEO sent.
app.use('/lazyseo-webhook', express.raw({ type: 'application/json', limit: '10mb' }));

function hasValidSignature(rawBody, header) {
  if (!SECRET) return true; // no Signing Secret configured
  if (!header) return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// "content" is Markdown for generated articles and HTML for articles edited
// in the LazySEO editor. Same check LazySEO uses: HTML starts with a block tag.
function contentToHtml(content) {
  const isHtml = /^\s*<(!doctype|html|head|body|div|p|h[1-6]|ul|ol|table|section|article|header|main|figure|blockquote)[\s>]/i.test(content);
  return isHtml ? content : marked.parse(content); // keeps embedded HTML such as <figure> or <iframe>
}

app.all('/lazyseo-webhook', (req, res) => {
  if (!hasValidSignature(req.body, req.get('X-Signature'))) {
    return res.status(401).json({ error: 'invalid signature' });
  }
  const data = JSON.parse(req.body.toString('utf8'));

  if (req.method === 'DELETE') {
    posts.delete(String(data.externalId));
    return res.json({ ok: true });
  }
  if (data._test) {
    return res.json({ ok: true }); // "Quick test" ping: don't create a post
  }

  // Create, or update when LazySEO sends an article again (same slug / externalId)
  const id = String(data.externalId || data.slug);
  posts.set(id, {
    title: data.title,
    slug: data.slug,
    html: contentToHtml(data.content || ''),
    excerpt: data.metaDescription || '',
    image: data.featuredImageUrl || null,
    tags: data.tags || [],
    draft: data.status !== 'published',
  });

  // Return the post id and its public URL
  res.json({ id, url: 'https://www.example.com/blog/' + data.slug });
});

app.listen(3000);

Example receiver: PHP

lazyseo-webhook.php
<?php
// lazyseo-webhook.php
// Needs a "posts" table with a UNIQUE index on "slug",
// and Parsedown for Markdown: composer require erusev/parsedown
require __DIR__ . '/vendor/autoload.php';
$secret = getenv('LAZYSEO_SECRET'); // same value as "Signing Secret" in LazySEO
$raw = file_get_contents('php://input');

header('Content-Type: application/json');

if ($secret) {
    $expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
    $given = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
    if (!hash_equals($expected, $given)) {
        http_response_code(401);
        echo json_encode(['error' => 'invalid signature']);
        exit;
    }
}

$data = json_decode($raw, true);
if (!is_array($data)) {
    http_response_code(400);
    echo json_encode(['error' => 'invalid JSON']);
    exit;
}

$pdo = new PDO('mysql:host=localhost;dbname=blog;charset=utf8mb4', 'db_user', 'db_password');

if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
    $pdo->prepare('DELETE FROM posts WHERE id = ?')->execute([$data['externalId'] ?? '']);
    echo json_encode(['ok' => true]);
    exit;
}

if (!empty($data['_test'])) {
    echo json_encode(['ok' => true]); // "Quick test" ping: don't create a post
    exit;
}

// "content" is Markdown for generated articles and HTML for articles edited in
// the LazySEO editor. HTML starts with a block tag; render everything else.
$content = (string) ($data['content'] ?? '');
$isHtml = preg_match('/^\s*<(!doctype|html|head|body|div|p|h[1-6]|ul|ol|table|section|article|header|main|figure|blockquote)[\s>]/i', $content);
$html = $isHtml ? $content : (new Parsedown())->text($content);

// Create the post, or update it if the slug already exists
$sql = 'INSERT INTO posts (slug, title, content, excerpt, image_url, status)
        VALUES (:slug, :title, :content, :excerpt, :image, :status)
        ON DUPLICATE KEY UPDATE title = VALUES(title), content = VALUES(content),
          excerpt = VALUES(excerpt), image_url = VALUES(image_url), status = VALUES(status)';
$pdo->prepare($sql)->execute([
    ':slug'    => $data['slug'],
    ':title'   => $data['title'],
    ':content' => $html,
    ':excerpt' => $data['metaDescription'] ?? '',
    ':image'   => $data['featuredImageUrl'] ?? null,
    ':status'  => ($data['status'] ?? '') === 'published' ? 'published' : 'draft',
]);

$stmt = $pdo->prepare('SELECT id FROM posts WHERE slug = ?');
$stmt->execute([$data['slug']]);
$id = $stmt->fetchColumn();

echo json_encode([
    'id'  => $id,
    'url' => 'https://www.example.com/blog/' . $data['slug'],
]);

Example receiver: Symfony (PHP)

A controller plus a Doctrine entity for Symfony 6/7 sites. Install the Markdown converter with composer require league/commonmark, then point the Webhook Endpoint URL at /lazyseo/webhook on your site.

src/Controller/LazySeoWebhookController.php
<?php

namespace App\Controller;

use App\Entity\BlogPost;
use Doctrine\ORM\EntityManagerInterface;
use League\CommonMark\GithubFlavoredMarkdownConverter;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

/**
 * Receives articles from LazySEO (Webhook integration) and saves them as BlogPost.
 *
 * In LazySEO: Integrations > Add integration > Webhook
 *   Endpoint URL:   https://your-site.com/lazyseo/webhook
 *   Method:         POST
 *   Signing Secret: the same value as LAZYSEO_WEBHOOK_SECRET in your .env.local
 *
 * Needs: composer require league/commonmark
 */
final class LazySeoWebhookController
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        #[Autowire('%env(LAZYSEO_WEBHOOK_SECRET)%')]
        private readonly string $secret,
    ) {
    }

    #[Route('/lazyseo/webhook', name: 'lazyseo_webhook', methods: ['POST'])]
    public function __invoke(Request $request): JsonResponse
    {
        $raw = $request->getContent();

        // 1. Verify the signature (HMAC-SHA256 of the raw body, header "X-Signature: sha256=<hex>").
        if ($this->secret !== '') {
            $expected = 'sha256=' . hash_hmac('sha256', $raw, $this->secret);
            if (!hash_equals($expected, (string) $request->headers->get('X-Signature', ''))) {
                return new JsonResponse(['error' => 'invalid signature'], 401);
            }
        }

        $data = json_decode($raw, true);
        if (!is_array($data) || empty($data['title']) || empty($data['slug'])) {
            return new JsonResponse(['error' => 'invalid payload'], 400);
        }

        // 2. LazySEO's "Quick test" sends {"_test": true}: answer OK without creating a post.
        if (!empty($data['_test'])) {
            return new JsonResponse(['ok' => true]);
        }

        // 3. "content" is Markdown for generated articles and HTML for articles edited in
        //    the LazySEO editor. Convert Markdown to HTML; keep HTML as it is.
        $content = (string) ($data['content'] ?? '');
        $isHtml = (bool) preg_match('/^\s*<(!doctype|html|div|p|h[1-6]|ul|ol|table|section|article|figure|blockquote)[\s>]/i', $content);
        $html = $isHtml ? $content : (string) (new GithubFlavoredMarkdownConverter())->convert($content);

        // 4. Create the post, or update it when LazySEO re-sends the same article.
        $repo = $this->em->getRepository(BlogPost::class);
        $post = null;
        if (!empty($data['externalId'])) {
            $post = $repo->find((int) $data['externalId']);
        }
        $post ??= $repo->findOneBy(['slug' => $data['slug']]) ?? new BlogPost();

        $post->setSlug((string) $data['slug'])
            ->setTitle((string) $data['title'])
            ->setContent($html)
            ->setExcerpt(isset($data['metaDescription']) ? (string) $data['metaDescription'] : null)
            ->setImageUrl($data['featuredImageUrl'] ?? $data['bannerUrl'] ?? null)
            ->setTags(array_values(array_filter((array) ($data['topics'] ?? $data['tags'] ?? []), 'is_string')))
            ->setStatus(($data['status'] ?? 'published') === 'draft' ? 'draft' : 'published')
            ->touch();

        $this->em->persist($post);
        $this->em->flush();

        // 5. Reply with the post id and its public URL (LazySEO stores both).
        return new JsonResponse([
            'id' => (string) $post->getId(),
            'url' => 'https://your-site.com/blog/' . $post->getSlug(),
        ]);
    }
}

// Entity (src/Entity/BlogPost.php): fields slug (unique), title, content (TEXT),
// excerpt, imageUrl, tags (JSON), status, publishedAt, updatedAt; then run
// php bin/console make:migration && php bin/console doctrine:migrations:migrate
// .env.local: LAZYSEO_WEBHOOK_SECRET=<same value as Signing Secret in LazySEO>
// security.yaml access_control (first line): - { path: ^/lazyseo/webhook, roles: PUBLIC_ACCESS }

Troubleshooting

Quick test fails.

Fix: Your endpoint must answer a POST with a 2xx status within 10 seconds, even when Method is PUT. Check that the URL is public (not localhost or behind a VPN), uses a valid HTTPS certificate, and that your endpoint accepts the _test payload.

Publishing fails with HTTP 401 or 403 from your endpoint.

Fix: Your endpoint rejected the request. Check the signature code (use the raw body) and any header your endpoint expects in Headers (optional).

Publishing fails with "Timed out waiting for …".

Fix: Your endpoint took longer than 30 seconds to answer. Reply as soon as the post is saved and do slow work in the background.

Publishing fails with HTTP 413.

Fix: Articles are long documents. Raise the request body size limit of your server or framework (for example limit in Express).

Posts on my site show ##, ** or - instead of headings, bold text and lists.

Fix: content is Markdown for generated articles. Render it to HTML with a Markdown library before you save or display it (see "The article content").

The same article was created twice.

Fix: Your endpoint received a retry, or the earlier reply had no id. Make it create-or-update by slug or externalId, and return the post id in the JSON reply.

LazySEO shows no link to the published article.

Fix: Return JSON with a url field and the Content-Type: application/json header.

Saving the integration says "Please re-enter the credentials (password, token or secret headers) when changing the destination URL."

Fix: For security, saved secrets are not reused for a new URL. Enter the Signing Secret (optional) and secret header values again together with the new Endpoint URL.

Frequently asked questions

What is the difference between Webhook and Custom API?

With Webhook, LazySEO sends a fixed JSON payload (optionally signed) and you write the code that receives it. With Custom API, you point LazySEO at an API that already exists and shape the request body with a template so the API accepts it.

Can I use a webhook with Zapier, Make or n8n?

Yes, if the automation tool gives you a webhook URL that accepts JSON and answers with a 2xx status. The tool then has to create the post in your CMS.

Still stuck?

Send us the error message from LazySEO and we'll help you connect.

Email support