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
1Build the receiving endpoint
Accept the request described below, verify the signature if you set a secret, turn
contentinto 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.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.
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": trueand 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 | How LazySEO handles it |
|---|---|
| Request | One JSON request per article to your Endpoint URL, with your Method. |
| Content | 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"). |
| Images | The 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. |
| Updates | 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 | Value |
|---|---|
Content-Type | application/json (unless you override it with a custom header) |
| Your custom headers | Every header from Headers (optional) |
X-Signature | 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 | Type | Description |
|---|---|---|
title | string | Article title. |
slug | string | URL-friendly slug, e.g. how-to-brew-better-coffee-at-home. |
content | string | 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. |
metaDescription | string, optional | SEO meta description. |
topics | string[] | Topic labels for the article (may be empty). |
tags | string[] | Same values as topics. |
status | string | "published" for real articles, "draft" for the Full test. |
bannerUrl | string, optional | URL of the article banner image. |
featuredImageUrl | string, optional | Same image as bannerUrl. |
externalId | string, optional | 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"). |
{
"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
{
"_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"
}{
"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/jsonand a body containingid(orexternalId) andurl(orpublishedUrl). The id can be a string or a number; LazySEO stores it as a string. - LazySEO saves
urlas 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 aurl, the article is still marked as published. - LazySEO sends
idback asexternalIdthe next time the same article is published, so you can update the post instead of creating a duplicate.
{
"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 byexternalId) 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)
// 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
<?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.