For developers
API docs: publishing to a custom-built website
Websites with their own admin (Next.js, Laravel, PHP, Python…) receive articles from AI Brand Visibility at an API address you create. This page describes exactly what we send, how to verify it and how to answer.
How it works
- Create a receiving address (POST, HTTPS) on your website, e.g. https://your-site.com/api/blog/receive, and a secret.
- In AI Brand Visibility: Website settings › Connections › Custom API, paste the address and secret (or click “Generate”), then Test & save — we send a test request with "event": "ping".
- Each time you approve an article (or Autopilot publishes one), we send it to that address. One request per article, 30-second timeout.
- Your website saves the article (by slug) and returns its id and url. With a url, we request Google indexing and auto-share it to social channels.
Authentication
Every request has two headers: X-Webhook-Secret with the secret itself, and X-Signature = "sha256=" + the HMAC-SHA256 of the raw body keyed with the secret. Check either (X-Signature is recommended) and answer 401 when it doesn't match.
Compute the signature on the raw body bytes before parsing JSON — parsing and re-serializing gives a different signature. Use a constant-time comparison (timingSafeEqual / hash_equals / compare_digest).
Payload
The body is JSON (UTF-8). Fields:
POST https://your-site.com/api/blog/receive
Content-Type: application/json; charset=utf-8
X-Webhook-Secret: <your secret>
X-Signature: sha256=<HMAC-SHA256 of the raw body, key = your secret>
{
"event": "article.published",
"title": "Hướng dẫn chọn sàn gỗ công nghiệp",
"slug": "huong-dan-chon-san-go-cong-nghiep",
"primary_keyword": "sàn gỗ công nghiệp",
"content_html": "<h2 id=\"...\">...</h2><p>...</p>",
"content_md": "## ...",
"meta_description": "…",
"excerpt": "…",
"faq": [{ "q": "…", "a": "…" }],
"cover_image": "https://images.pexels.com/…jpg",
"images": [
{ "url": "https://images.pexels.com/…jpg", "alt": "…", "role": "cover" },
{ "url": "https://…/inline.jpg", "alt": "…", "role": "inline" }
],
"category": "tin-tuc",
"status": "publish",
"test": false
}| event | "ping" (connection test), "article.published" (public) or "article.draft" (save as draft). |
| title | Article title. |
| slug | Suggested URL slug. Use it as the key when saving: the same slug again means update that article. |
| primary_keyword | The article's main keyword (may be null). |
| content_html | Ready-to-display HTML: headings with ids, table of contents (if enabled), FAQ section. Recommended. |
| content_md | The same content as Markdown, if your site renders Markdown itself. |
| meta_description | Meta description; excerpt carries the same value for summaries. |
| faq | Question–answer list [{q, a}] (already included in content_html). |
| cover_image | Cover image URL (may be null). |
| images | Every image in the article with its role (cover/inline) — for sites that download and store images themselves (S3, an uploads folder…) instead of hot-linking. |
| category | The category you entered on the connection (default "tin-tuc"). |
| status | "publish" or "draft" — note: "publish", not "published". |
| test | true for the connection test: answer 200 and save nothing. |
Response
Answer 200, 201 or 202 with JSON {id, url, status}. url is the article's public link (only once live). Without a url the article is still recorded, but we won't index or share it, so a wrong link is never sent. On failure answer 4xx/5xx with {"error": "..."} — that message is shown to the user.
HTTP/1.1 201 Created
Content-Type: application/json
{ "id": "123", "url": "https://your-site.com/blog/huong-dan-chon-san-go-cong-nghiep", "status": "publish" }Sample code
A complete receiver: verifies the signature, answers the ping, saves by slug and returns id/url. Replace the saving part with your own database.
import crypto from "node:crypto";
import { NextResponse } from "next/server";
const SECRET = process.env.BLOG_WEBHOOK_SECRET ?? "";
export async function POST(req: Request) {
// Verify the signature on the RAW body, before parsing it.
const raw = await req.text();
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(raw).digest("hex");
const got = req.headers.get("x-signature") ?? "";
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const body = JSON.parse(raw);
// The connection test: answer OK, save nothing.
if (body.event === "ping" || body.test) return NextResponse.json({ ok: true });
const published = body.status === "publish";
// Save or update by slug in your own database, e.g. with Prisma:
const post = await db.post.upsert({
where: { slug: body.slug },
create: { slug: body.slug, title: body.title, content: body.content_html, excerpt: body.excerpt, coverImage: body.cover_image, published },
update: { title: body.title, content: body.content_html, excerpt: body.excerpt, coverImage: body.cover_image, published },
});
return NextResponse.json(
{ id: String(post.id), url: published ? `https://your-site.com/blog/${post.slug}` : null, status: published ? "publish" : "draft" },
{ status: 201 },
);
}Troubleshooting
401 Unauthorized
The secret on your website differs from the one saved in AI Brand Visibility, or the signature was computed on parsed JSON instead of the raw body. Paste the same secret in both places.
405 Method Not Allowed
The address doesn't accept POST (or the server redirects to another address and turns it into a GET). Declare a POST route and use the final address, including any trailing slash.
“This address returns a web page, not an API”
Wrong address, or it redirects to a login page / your site's 404 page. Try the address with a POST (e.g. curl) to see what it returns.
Article sent but not visible on the website
Usually the site only treats status "published" as public. We send "publish" — accept "publish" (and "published" if you like) when setting the public state.
Published but no link in the dashboard
Your site didn't return a url. Return the article's real public link to get Google indexing and auto-sharing.
Timeouts
Each request waits up to 30 seconds. Save the article first and do heavy work (downloading images, rebuilding pages) afterwards or asynchronously.
Images don't show in the article
If your site uses an image optimizer (e.g. next/image), allow the image host (images.pexels.com), or download the images array and store them yourself.
Ready-made platforms (no code)
If your website runs on one of these, just connect it under Website settings › Connections:
- WordPressUsername + Application Password.
- Shopify.myshopify.com domain + Admin API access token (write_content).
- HaravanStore domain + private app access token.
- SapoStore domain + private app API key/secret.
- GhostAPI URL + Admin API key from a custom integration.
- WebflowAPI token (CMS read & write) + the blog's Collection ID.
- HashnodePersonal Access Token + Publication ID.