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.

Last reviewed October 1, 2026.

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. 1Build 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. 2Open 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. 3Save 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. Fields marked required 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)

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)

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)

Enabled

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

WhatHow LazySEO handles it
RequestOne JSON request per article to your Endpoint URL, with your Method.
ContentThe 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").
ImagesThe banner image is sent as a URL (featuredImageUrl and bannerUrl); download it if you want to host it yourself.
Status"published" for real articles, "draft" for the Full test.
UpdatesWhen 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

HeaderValue
Content-Typeapplication/json (unless you override it with a custom header)
Your custom headersEvery header from Headers (optional)
X-Signaturesha256= 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.

FieldTypeDescription
titlestringArticle title.
slugstringURL-friendly slug, e.g. how-to-brew-better-coffee-at-home.
contentstringFull 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.
metaDescriptionstring, optionalSEO meta description.
topicsstring[]Topic labels for the article (may be empty).
tagsstring[]Same values as topics.
statusstring"published" for real articles, "draft" for the Full test.
bannerUrlstring, optionalURL of the article banner image.
featuredImageUrlstring, optionalSame image as bannerUrl.
externalIdstring, optionalOnly 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 notification; you can publish again with Retry 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'],
]);

Troubleshooting

Quick test fails.

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.

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 …".

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.

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.

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.

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.

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."

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?

Email support@lazyseo.io with the error message from LazySEO and we'll help you connect.