Custom Webhook
Send finished articles to any platform that accepts HTTP requests — your own CMS, Zapier, Make, n8n, or a custom API your team builds.
Overview
When you publish an article via the Custom Webhook integration, SEOcraftAI sends a structured HTTP POST request to your configured endpoint.
The payload contains the article title, content (Markdown or HTML), keyword, SEO metadata, and a cover image URL.
Your endpoint must be publicly accessible over HTTPS. Localhost URLs will not work in production.
Setup
Go to Integrations -> Custom Webhook
Enter your endpoint URL — must start with https://
Select Auth Type: None, Bearer Token, or Custom Header. For Bearer Token paste your secret; for Custom Header enter the header name (e.g. X-API-Key) and value.
Click Send Test — SEOcraftAI POSTs a sample payload and shows the response status.
Click Save once the test succeeds.
The test payload has event set to "test" so your endpoint can distinguish test pings from real publishes.
Payload structure
{
"event": "publish",
"timestamp": "2026-01-01T00:00:00.000Z",
"article": {
"id": "f255c3e4-d3c9-4010-8998-462379beb0f3",
"title": "Article Title",
"slug": "article-title",
"excerpt": "Short plain-text summary of the article…",
"content_markdown": "# Article Title\n\nFull article in Markdown…",
"content_html": "<h1>Article Title</h1><p>Full article in HTML…</p>",
"format": "markdown",
"images": ["https://storage.googleapis.com/seocraftai-image-bucket/covers/…"],
"seo": {
"meta_title": "Article Title",
"meta_description": "First ~157 chars of article…",
"keywords": ["target keyword"]
},
"published_at": "2026-01-01T00:00:00.000Z",
"main_image_url": "https://storage.googleapis.com/seocraftai-image-bucket/covers/…"
},
"main_image": {
"url": "https://storage.googleapis.com/seocraftai-image-bucket/covers/…",
"alt": "Article Title"
},
"website": {
"id": "site-uuid",
"baseUrl": "https://yoursite.com"
}
}Payload fields
| Field | Type | Description |
|---|---|---|
event | string | "publish" on real publishes, "test" on test pings |
timestamp | ISO 8601 | Time the webhook was dispatched |
article.id | UUID | Stable identifier — store it to upsert on republish |
article.title | string | Article headline |
article.slug | string | SEO-friendly URL path component |
article.excerpt | string | Plain-text summary (~160 chars), stripped of Markdown |
article.content_markdown | string | Full article body in Markdown |
article.content_html | string | Full article body converted to HTML |
article.format | string | "markdown" or "html" — indicates which content field to use |
article.images | string[] | All image URLs found in the article (cover first, then inline) |
article.seo.meta_title | string | SEO title (equals article title) |
article.seo.meta_description | string | Auto-generated meta description (~157 chars) |
article.seo.keywords | string[] | Target SEO keywords; empty array if none set |
article.published_at | ISO 8601 | Publication timestamp |
article.main_image_url | string | null | Cover image URL — null if no image generated yet |
main_image.url | string | null | Same as article.main_image_url, for convenience |
main_image.alt | string | null | Alt text for the image (equals article title) |
website.id | UUID | Your SEOcraftAI site ID |
website.baseUrl | string | Your site's base URL as configured in SEOcraftAI |
Handling the cover image
Read article.main_image_url from the payload. If it is null, the article has no cover image yet — skip the image step.
Fetch the image with a standard HTTP GET (no auth needed). The response is image/jpeg.
Store the image in your own CDN, S3, or media library — do not rely on SEOcraftAI as permanent image hosting.
if (payload.article.main_image_url) {
const res = await fetch(payload.article.main_image_url);
const buffer = Buffer.from(await res.arrayBuffer());
// upload buffer to S3, Cloudinary, WordPress media, etc.
// then store the new URL in your CMS — don't link directly to SEOcraftAI
}if (!empty($payload['article']['main_image_url'])) {
$img = file_get_contents($payload['article']['main_image_url']);
// file_put_contents('/uploads/cover.jpg', $img);
}Handling updates
The article.id UUID is stable across republications — it never changes for the same article.
Store the UUID in your database. On each incoming webhook, check if a record with that UUID already exists.
If found: update the existing record. If not found: insert a new one. This prevents duplicate posts when an article is edited and republished.
const existing = await db.query(
'SELECT id FROM posts WHERE SEOcraftAI_id = $1',
[payload.article.id]
);
if (existing.rows.length > 0) {
await db.query('UPDATE posts SET title=$1, content=$2 WHERE SEOcraftAI_id=$3',
[payload.article.title, payload.article.content_markdown, payload.article.id]);
} else {
await db.query('INSERT INTO posts (SEOcraftAI_id, title, content) VALUES ($1,$2,$3)',
[payload.article.id, payload.article.title, payload.article.content_markdown]);
}Response requirements
Your endpoint must return a 2xx status code (200-299) within 30 seconds. Any other response is treated as a failure.
Return the response immediately and process heavy work (image upload, rendering) asynchronously to stay within the time limit.
A minimal success response: HTTP 200 with any body, e.g. { "ok": true }.
Publishing articles
Open Integrations -> Custom Webhook from the sidebar.
Click Publish Article to expand the publish form.
Enter the article title and paste the Markdown content.
Click Send via Webhook — SEOcraftAI POSTs the full payload (title, content_markdown, content_html, SEO fields, cover image URL) to your endpoint.
The response status appears inline — green means your endpoint accepted it.
Common issues
Endpoint not reachable — verify your URL is publicly accessible over HTTPS with a valid SSL certificate. Test it with curl from another machine.
Auth failures — check for trailing whitespace in your token. Header names are case-sensitive on some servers.
Timeout errors — your endpoint must respond within 30 seconds. Move image processing, database writes, and rendering to a background job.
Image not showing — main_image_url points to SEOcraftAI's server. Fetch and re-host the image in your own storage — do not embed the URL directly in your CMS.
Duplicate posts — store article.id and use it to upsert instead of always inserting.
Verifying the request
Always verify incoming requests before processing. SEOcraftAI sends your secret in the Authorization header as a Bearer token or in a custom header you configure.
// app/api/webhooks/blog/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
// 1. Verify the secret
const authHeader = request.headers.get('authorization') ?? '';
const secret = authHeader.startsWith('Bearer ')
? authHeader.slice(7).trim()
: request.headers.get('x-webhook-secret') ?? '';
if (secret !== process.env.SEOCRAFTAI_WEBHOOK_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. Parse the payload
const { event, article } = await request.json();
// 3. Ignore test pings
if (event === 'test') {
return NextResponse.json({ ok: true });
}
// 4. Save to your database (upsert by article.id)
await db.upsert({
where: { seocraftaiId: article.id },
create: {
seocraftaiId: article.id,
slug: article.slug,
title: article.title,
content: article.content_markdown,
excerpt: article.excerpt,
coverImageUrl: article.main_image_url,
metaDescription: article.seo.meta_description,
},
update: {
title: article.title,
content: article.content_markdown,
excerpt: article.excerpt,
coverImageUrl: article.main_image_url,
},
});
// 5. Return 2xx within 30 seconds
return NextResponse.json({ ok: true });
}Store SEOCRAFTAI_WEBHOOK_SECRET in your environment variables — never hardcode it.
Rendering markdown content
Use the react-markdown package to render article.content_markdown in React. Install it along with remark-gfm (tables, strikethrough) and rehype-highlight (code syntax highlighting):
npm install react-markdown remark-gfm rehype-highlight highlight.js
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github.css';
export function Article({ title, content, mainImageUrl }) {
return (
<article>
<h1>{title}</h1>
{mainImageUrl && <img src={mainImageUrl} alt={title} />}
<div className="prose">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{content}
</ReactMarkdown>
</div>
</article>
);
}.prose table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; }
.prose th, .prose td { border: 1px solid #d1d5db; padding: 0.75rem 1rem; text-align: left; }
.prose th { background: #f9fafb; font-weight: 600; }
.prose tr:nth-child(even) { background: #f9fafb; }Without remark-gfm, tables in the article will render as raw pipes and dashes instead of a formatted table.
If you chose HTML format instead, set article.format === 'html' and render with dangerouslySetInnerHTML={{ __html: article.content_html }} — tables are already pre-rendered.
Popular setups
Next.js on Vercel (no DB) — use Vercel KV to store each article as a JSON blob keyed by slug. No separate database needed.
Next.js with Supabase / Neon — free-tier Postgres. Build a /api/webhooks/blog route, upsert by article.id, then query it in your blog pages.
Static site (Hugo, Jekyll, Gatsby) — webhook handler commits the article as an MDX file to your GitHub repo via the GitHub API, triggering an automatic Vercel/Netlify rebuild.
Headless CMS — push the payload to Contentful, Sanity, or Strapi via their content APIs. Your frontend reads from the CMS as usual.
Zapier / Make — add a Webhook trigger, then pass article.content_markdown and main_image.url to WordPress, Notion, or any other action step.
n8n — HTTP webhook node receives the payload; downstream nodes handle storage and image upload.
Slack / email notifications — trigger a message whenever a new article is published using article.title and article.slug.
Frequently asked questions
What happens if my endpoint is down? — SEOcraftAI automatically retries failed requests. If failures persist, the article stays in a pending state and you can manually retry from the Integrations dashboard.
Can I change the webhook URL after setup? — Yes. Update it anytime in Integrations → Custom Webhook. The next publish will use the new URL.
How do I tell new articles from updates? — Use article.id. Store it alongside your internal record and check for it on every incoming webhook. If it exists, update — if not, insert.
Can I have multiple webhook endpoints? — One endpoint per site is supported. Contact support if you need to fan out to multiple destinations.
Why are tables showing as pipes and dashes? — Your markdown renderer doesn't support GitHub Flavored Markdown (GFM). Install remark-gfm for react-markdown, or switch to HTML format in your SEOcraftAI webhook settings.
Do I need to re-host the cover image? — Yes. main_image_url points to SEOcraftAI's CDN. Fetch and store it in your own storage (S3, Cloudinary, etc.) — do not embed the URL directly in your CMS long-term.
Notes
Webhook credentials are saved with your account — you only need to configure them once.
One webhook endpoint per site is supported. Contact support if you need multiple endpoints.