Custom Webhook Integration
Connect BlogSEO to any platform or custom application using webhooks. Receive article data via HTTP POST requests whenever articles are published.
Custom webhooks allow you to integrate BlogSEO with any system that can receive HTTP requests, including custom CMS platforms, static site generators, headless CMS systems, or your own backend services.
Setting Up Webhook Integration
Navigate to Integrations in your BlogSEO dashboard, click Custom Webhook, then click Create Webhook. You'll need to configure:
1. Webhook URL
Enter the endpoint URL where BlogSEO will send article data. This must be a publicly accessible URL that accepts POST requests. HTTPS is strongly recommended: your shared secret travels as a request header, so plain HTTP would expose it in transit.
https://your-app.com/api/webhooks/blogseo
2. Content Format
Choose how you want to receive article content:
- Markdown: Raw markdown content with formatting (recommended for further processing)
- HTML: Pre-converted HTML ready for display
3. Authentication Method
Select how BlogSEO should authenticate requests to your endpoint:
Generated Shared Secret (Recommended)
BlogSEO generates a secure 64-character hex string that is sent with every request in the X-Webhook-Secret header. You'll need to store this secret and verify it on your server.
Custom Header
Use your own authentication header. Specify a custom header name and value (e.g., Authorization: Bearer your-api-key).
The two authentication methods are mutually exclusive. When you pick the
custom header, no X-Webhook-Secret header is sent at all, and vice versa.
Request Headers
Every webhook request carries these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SEO-Blog-Generator-Webhook/1.0 |
X-Webhook-Secret | Your generated shared secret — sent only with the generated-secret authentication method |
| Your custom header | Your custom value — sent only with the custom header authentication method |
Webhook Payload Structure
When an article is published, BlogSEO sends a POST request with the following JSON payload:
{
"article": {
"id": "01234567-89ab-cdef-0123-456789abcdef",
"slug": "your-article-title-as-url-slug",
"title": "Your Article Title",
"content": "# Article content in markdown or HTML...",
"format": "markdown",
"published_at": "2024-01-15T10:30:00.000Z",
"main_image_url": "https://storage.blogseo.io/images/article-image.webp",
"meta_description": "An SEO-optimized description for search engine results, under 160 characters.",
"excerpt": "A short, two-line teaser of the article — complete sentences, never cut mid-word — suitable for displaying on your blog index or article cards.",
"locale": "en-US",
"keyword": "target seo keyword"
},
"main_image": {
"url": "https://storage.blogseo.io/images/article-image.webp",
"alt": "Your Article Title"
},
"website": {
"id": "fedcba98-7654-3210-fedc-ba9876543210",
"baseUrl": "https://your-website.com"
},
"published_at": "2024-01-15T10:30:00.000Z",
"timestamp": "2024-01-15T10:30:00.000Z"
}
Payload Fields
Fields marked optional are left out of the JSON entirely when they have
no value — the key is absent, not null. Fields typed string | null are
always present and carry null when empty. Check for presence before
accessing optional fields.
| Field | Type | Description |
|---|---|---|
article.id | string (UUID) | Unique identifier for the article. Stable across republishes, and shared by every translation of the article — see Multi-language Delivery. |
article.slug | string | URL-friendly slug. Uses the custom slug set in BlogSEO when there is one, otherwise it is derived from the title. Store the value we send rather than recomputing it from the title. |
article.title | string | The article headline |
article.content | string | Article content in markdown or HTML format |
article.format | string | Either "markdown" or "html" |
article.published_at | string | ISO 8601 timestamp of the article's publication. Keeps its original value when the article is republished. |
article.main_image_url | string (optional) | URL of the featured image. Omitted when the article has no featured image. |
article.meta_description | string (optional) | SEO meta description for search engines (under 160 characters). Omitted when the article has none. |
article.excerpt | string | null | A 2-line article teaser (~30-45 words, max ~280 characters). Complete sentences, never cut mid-word. Suitable for blog index cards. null when the article has no excerpt. |
article.locale | string (optional) | Language of this payload, e.g. "en-US". Sent only when the website has additional languages configured; absent for single-language websites. |
article.keyword | string | null | The targeted SEO keyword, or null if none |
main_image | object (optional) | The whole object is omitted when the article has no featured image |
main_image.url | string | Same as article.main_image_url |
main_image.alt | string | Alt text for the image — the generated image description, falling back to the article title when there is none |
website.id | string (UUID) | Unique identifier for the website |
website.baseUrl | string | Base URL of your website |
published_at | string | ISO 8601 timestamp of when this webhook was sent. This is not the article's publication date — use article.published_at for that. |
timestamp | string | ISO 8601 timestamp of when the webhook was sent (same value as the top-level published_at) |
Slug
The slug field is an SEO-optimized, URL-friendly string. It is the custom slug set in BlogSEO when the article has one, and is otherwise derived from the article title. Always store and use the slug exactly as we send it — do not regenerate it from the title, or your URLs will drift from the ones BlogSEO considers canonical. Use it as the pathname for the page displaying the article content (e.g., /blog/your-article-slug). If you're storing articles in a database, consider indexing this field for better query performance.
Multi-language Delivery
If the website has additional languages configured in BlogSEO, publishing an article sends several POST requests to the same endpoint: one for the source-language article, then one for each translation.
Every one of those requests carries the same article.id. What differs is article.locale, along with the translated title, content, slug and excerpt.
article.id alone is not a unique key. Store and look up articles by the
pair (article.id, article.locale) — otherwise each translation overwrites
the previous language and you keep only whichever one arrived last.
For single-language websites, article.locale is absent and one request is sent per publication. Treat a missing locale as your website's default language.
Translation payloads differ from the source article in a few ways:
article.slugis derived from the translated title, so every language has its own slugarticle.published_atis the time the translation was sent, not the source article's publication datemain_image,main_image.altandarticle.keywordare inherited from the source article, so they stay in the original language
Receiving Article Updates
When you republish an article to your custom webhook (for example, after editing the content in BlogSEO), your endpoint receives the same webhook request format as for new articles. The key to distinguishing between new articles and updates is the article.id field, combined with article.locale when the website is multi-language.
The article.id is a UUID that remains identical across all publications of the same article, and across all of its translations. Together with article.locale it allows you to:
- Deduplicate requests: Check if an article with this id and locale already exists in your database
- Update existing content: Replace the content of the existing article instead of creating a duplicate
- Track article history: Maintain version history by comparing the new content with the previous version
Deliveries are at-least-once: the same article can legitimately arrive more than once — a retry after a failed or slow response, or a manual republish from the dashboard. Make your handler idempotent so that a repeat delivery updates the existing record instead of creating a duplicate.
Implementation Example
Handling article updates
// A translation carries the same article.id as its source article,
// so the unique key is the (id, locale) pair.
const locale = article.locale ?? 'default';
const existingArticle = await db.article.findUnique({
where: { blogseoId_locale: { blogseoId: article.id, locale } },
});
if (existingArticle) {
// Update existing article
await db.article.update({
where: { blogseoId_locale: { blogseoId: article.id, locale } },
data: {
title: article.title,
content: article.content,
slug: article.slug,
updatedAt: new Date(),
},
});
} else {
// Create new article
await db.article.create({
data: {
blogseoId: article.id,
locale,
title: article.title,
content: article.content,
slug: article.slug,
},
});
}
Store the article.id (BlogSEO's UUID) alongside article.locale in your
database and make that pair your unique key. This is separate from your own
internal article IDs.
Verifying Webhook Requests
Always verify that incoming webhook requests are authentic by checking the shared secret or custom header.
Verifying a BlogSEO webhook request
const signingSecret = request.headers['x-webhook-secret'];
if (signingSecret === process.env.BLOGSEO_WEBHOOK_SECRET) {
// Process request
} else {
throw new Error('Invalid signature');
}
Do not expose your webhook signing secret to the public. In particular, make sure your signing secret is not included in your git history and do not hardcode it in your application code. Use environment variables to store your signing secret securely.
Handling the Webhook Payload
After verifying the request, parse the JSON payload and process the article data.
Processing a BlogSEO webhook payload
// app/api/webhooks/blogseo/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const signingSecret = request.headers.get('x-webhook-secret');
if (signingSecret !== process.env.BLOGSEO_WEBHOOK_SECRET) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 },
);
}
const { article, website, timestamp } = await request.json();
console.log(`Received article: ${article.title}`);
console.log(`Slug: ${article.slug}`);
console.log(`Format: ${article.format}`);
console.log(`Image: ${article.main_image_url}`);
// Save to your database, trigger builds, etc.
// await saveArticle(article);
return NextResponse.json({ success: true });
}
Testing Your Webhook
Use the Test button in the webhook configuration dialog to send a test payload to your endpoint. This allows you to verify:
- Your endpoint is accessible
- Authentication is working correctly
- Your code correctly parses the payload
The test payload uses randomly generated UUIDs and sample content, allowing you to validate your integration without publishing a real article. Two limitations are worth knowing about:
- It is the fully-populated happy path. Every optional field is present and nothing is
null, so the test never exercises an article without a featured image, without a meta description, or with anullexcerpt or keyword. Handle those cases explicitly — see the Payload Fields table. - It always sends exactly one request. The translation fan-out described in Multi-language Delivery is not reproduced by the test.
Testing before the webhook is created sends the request without the
X-Webhook-Secret header, because the shared secret is only generated when
you save the integration. To verify authentication, save the webhook first,
then use the Test button on the saved configuration.
Response Requirements
Your webhook endpoint should:
- Return a
2xxstatus code (200-299) to indicate success - Respond within 30 seconds — each attempt is aborted at that point
- Return any status code outside
2xxto indicate failure
If your endpoint returns a non-2xx status or times out, BlogSEO retries the request once, roughly 4 seconds later. If that retry also fails, the article is set back to preview status and you can republish it from your dashboard.
Because an attempt your server already processed can still be retried — for instance when the response was too slow — treat deliveries as at-least-once and keep your handler idempotent.
Use Cases
Custom webhooks are ideal for:
- Static Site Generators: Trigger rebuilds when new content is published (Hugo, Jekyll, Gatsby, Next.js)
- Headless CMS: Push content to Sanity or other headless platforms (for Strapi, use the dedicated Strapi integration)
- Custom Applications: Integrate with your own backend services or databases
- Notification Systems: Send alerts to Slack, Discord, or email when articles are published
- Content Syndication: Automatically distribute content to multiple platforms
Rendering Articles in React
If you're building a React application and receiving content in markdown format, we recommend using the react-markdown package to render your articles.
ReactMarkdown follows the CommonMark specification by default, which does
not include tables. To render tables and other GitHub Flavored Markdown
(GFM) features like strikethrough and task lists, you need the remark-gfm
plugin. For syntax highlighting in code blocks, use rehype-highlight.
Installation
npm install react-markdown remark-gfm rehype-highlight highlight.js
Basic Usage
Create a reusable article component that renders markdown content with full GFM support:
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github.css';
interface ArticleProps {
title: string;
content: string;
mainImageUrl?: string;
mainImageAlt?: string;
}
export function Article({
title,
content,
mainImageUrl,
mainImageAlt,
}: ArticleProps) {
return (
<article>
<h1>{title}</h1>
{mainImageUrl && (
<img src={mainImageUrl} alt={mainImageAlt || title} />
)}
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{content}
</ReactMarkdown>
</div>
</article>
);
}
Styling Tables
Add CSS styles for tables to display correctly. Here's an example you can include in your stylesheet:
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #d1d5db;
padding: 0.75rem 1rem;
text-align: left;
}
.markdown-content th {
background-color: #f9fafb;
font-weight: 600;
}
.markdown-content tr:nth-child(even) {
background-color: #f9fafb;
}
You'll also want to add styles for headings, paragraphs, lists, code blocks, and other markdown elements for consistent rendering.
Troubleshooting
Webhook Not Receiving Requests
- Verify your endpoint URL is correct and publicly accessible
- Check that your server accepts POST requests at the specified path
- Ensure your SSL certificate is valid (HTTPS is required)
Authentication Failures
- Confirm you're checking the correct header (
X-Webhook-Secretfor generated secrets) - Verify the secret in your environment matches the one shown in BlogSEO
- Check for trailing whitespace in your environment variables
Timeout Errors
- Ensure your endpoint responds within 30 seconds
- Process heavy operations asynchronously after sending the response
- Consider using a queue system for time-consuming tasks
Payload Parsing Errors
- Verify your endpoint expects
application/jsoncontent type - Check that you're parsing the request body as JSON
- Validate all expected fields exist before accessing them
Tables Not Rendering Correctly
If you're using the HTML format and tables appear unstyled, you'll need to add CSS styles for table elements. Here's example CSS you can use:
.article-content table {
width: 100%;
border-collapse: collapse;
border: 1px solid #d1d5db;
margin-bottom: 1rem;
}
.article-content th {
background-color: #f9fafb;
border: 1px solid #d1d5db;
padding: 0.5rem 1rem;
text-align: left;
font-weight: 600;
}
.article-content td {
border: 1px solid #d1d5db;
padding: 0.5rem 1rem;
}
You may also want to style other HTML elements like headings, lists, blockquotes, and code blocks for consistent rendering.
Security Best Practices
- Always verify the webhook signature before processing
- Use HTTPS for your webhook endpoint
- Store secrets in environment variables, never in code
- Log webhook requests for debugging and auditing
- Validate and sanitize all incoming data before use
Frequently Asked Questions
Can I have multiple webhook endpoints?
Currently, each BlogSEO organization can have one webhook endpoint. Contact support if you need to send to multiple destinations.
What happens if my endpoint is down?
BlogSEO retries the request once, roughly 4 seconds later. If that retry also fails, the article status is set to "preview" and you can manually republish it from your dashboard.
Can I change the webhook URL after creation?
Yes, you can update your webhook configuration at any time in the Integrations settings.
Why did I receive several webhooks with the same article.id?
Every translation of an article is sent as its own POST request and reuses the source article's id, so a multi-language website produces one request per language. Use article.locale to tell them apart and key your records on the (article.id, article.locale) pair — see Multi-language Delivery. Repeat deliveries of the same language are also possible because delivery is at-least-once, so your handler should be idempotent.
Can I receive webhooks for article updates, not just new publications?
Webhooks are sent when articles are published, and once per translation when the website has additional languages configured. Republishing an existing article will trigger a new webhook with the updated content.
How do I detect if a webhook is for a new article or an update?
Use the article.id field together with article.locale. The id remains identical when you republish an article, and is also shared by all of its translations. Store that pair in your database and check whether it already exists when receiving a webhook. If it does, update the existing record instead of creating a new one. See the Receiving Article Updates section for implementation examples.
My tables are showing as dashes and pipes instead of actual tables. How do I fix this?
This happens when using markdown format with a renderer that doesn't support GitHub Flavored Markdown (GFM) tables. If you're using react-markdown, you need to install and add the remark-gfm plugin. See the Rendering Articles in React section above for the full setup. Alternatively, you can switch to HTML format in your webhook settings, which sends pre-rendered tables that don't require additional plugins. For additional support, contact our team through the in-app support chat.